@phnx-labs/agents-cli 1.20.86 → 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 (41) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/sessions-browser.d.ts +18 -0
  4. package/dist/commands/sessions-browser.js +126 -24
  5. package/dist/commands/sessions-picker.d.ts +21 -8
  6. package/dist/commands/sessions-picker.js +83 -7
  7. package/dist/commands/sessions.d.ts +19 -0
  8. package/dist/commands/sessions.js +147 -18
  9. package/dist/commands/ssh.js +59 -1
  10. package/dist/commands/teams-picker.d.ts +2 -0
  11. package/dist/commands/teams-picker.js +2 -1
  12. package/dist/commands/teams.d.ts +4 -1
  13. package/dist/commands/teams.js +106 -70
  14. package/dist/commands/view.js +14 -3
  15. package/dist/index.js +31 -1
  16. package/dist/lib/claude-account-token.d.ts +12 -0
  17. package/dist/lib/claude-account-token.js +63 -0
  18. package/dist/lib/devices/registry.d.ts +25 -0
  19. package/dist/lib/devices/registry.js +82 -1
  20. package/dist/lib/events.d.ts +8 -1
  21. package/dist/lib/events.js +13 -0
  22. package/dist/lib/exec.js +10 -1
  23. package/dist/lib/format.d.ts +7 -0
  24. package/dist/lib/format.js +11 -0
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  26. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  27. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  28. package/dist/lib/session/db.d.ts +21 -0
  29. package/dist/lib/session/db.js +45 -4
  30. package/dist/lib/session/remote-list.d.ts +7 -0
  31. package/dist/lib/session/remote-list.js +8 -4
  32. package/dist/lib/session/state.js +69 -2
  33. package/dist/lib/session/team-filter.d.ts +22 -3
  34. package/dist/lib/session/team-filter.js +106 -17
  35. package/dist/lib/session/types.d.ts +8 -0
  36. package/dist/lib/signin-badge.d.ts +17 -0
  37. package/dist/lib/signin-badge.js +19 -0
  38. package/dist/lib/state.d.ts +2 -0
  39. package/dist/lib/state.js +2 -0
  40. package/dist/lib/usage.js +1 -60
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,117 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.87
4
+
5
+ - **`agents devices enable|disable|prefer|unprefer <name>` control which machines
6
+ Factory auto-launches onto.** A *disabled* device is skipped by
7
+ `New <Agent>` and the balanced launch, but stays available through
8
+ `New <Agent> (Pick Host)`. A *preferred* device wins ties against otherwise
9
+ equivalent machines — worth about two running agents in the ranking, so a
10
+ preference never sends work to a box that is genuinely swamped. Every device
11
+ is enabled and unpreferred by default, and an unregistered name is now
12
+ rejected instead of writing a preference that matches nothing. Preferences
13
+ live in `~/.agents/.history/devices/auto-launch.json`, written by the CLI and
14
+ read by the extension. Source: `apps/cli/src/lib/devices/registry.ts`,
15
+ `apps/cli/src/commands/ssh.ts`, `apps/factory/src/core/deviceAutoLaunch.ts`,
16
+ `apps/factory/src/core/launchHost.ts`.
17
+
18
+ - **Point-of-use friction events for `agents teams` failures.** The CLI's `die()`
19
+ chokepoints in `teams` now emit a structured `friction` event (`surface`,
20
+ `failureId`, `error`) before exiting, so the nightly factory-metrics routine can
21
+ rank recurring failures without re-parsing transcripts. A hidden
22
+ `agents _internal friction` recorder lets shell guard hooks (git-guard, rm-guard,
23
+ large-file-add-guard) self-report blocks into the same stream. Source:
24
+ `apps/cli/src/lib/events.ts`, `apps/cli/src/lib/format.ts`,
25
+ `apps/cli/src/commands/teams.ts`, `apps/cli/src/index.ts`.
26
+
27
+ - **`agents view` no longer reports a working Claude install as "logged out".**
28
+ Claude's `signedIn` is `!!email` read from a version home's `.claude.json`
29
+ (`lib/agents.ts`), so a version that authenticates from an ambient
30
+ `CLAUDE_CODE_OAUTH_TOKEN` — no account ever written to that home — rendered
31
+ "(logged out — log in with: claude, then /login)" while every run against it
32
+ succeeded. On one fleet box five of seven versions read as locked out and all
33
+ of them answered a live prompt. Those now render "(no per-version login —
34
+ using ambient CLAUDE_CODE_OAUTH_TOKEN)", which is both accurate and the more
35
+ useful warning: an ambient token is ONE account, so balanced rotation across
36
+ those versions rotates nothing. Source: `apps/cli/src/lib/signin-badge.ts`,
37
+ `apps/cli/src/commands/view.ts`.
38
+
39
+ - **Claude per-account run tokens.** `agents run` now injects the Claude setup token keyed to the selected version home's own account email, so balanced Claude rotation no longer inherits one ambient shared token across accounts. Source: `src/lib/exec.ts`.
40
+
41
+ - **`agents sessions` now shows which session spawned which team.** The link
42
+ already existed on disk and was discarded twice. `SessionMeta.spawnedTeam` — the
43
+ team name read off the `agents teams create/add` command at scan time — had no
44
+ column in `sessions.db`, so the writer dropped it and no consumer had ever seen
45
+ a non-`undefined` value; a new `spawned_team` column (schema **v21**, which
46
+ forces one full rescan) persists it, and orchestrator rows now carry a green
47
+ `team:<name>` badge. Separately, `classifyTeamSession` was already opening each
48
+ teammate's `meta.json` and throwing away its `task_name` and
49
+ `parent_session_id`, so a teammate row could not name its team or point back at
50
+ its orchestrator; teammate rows now read `[<team>/<handle>]` and the preview
51
+ pane carries a `Team:` line from either end of the lineage. New `--in-team
52
+ <name>` (and a `t` hotkey in the browser) filters to one team's orchestrator
53
+ plus its teammates, `agents teams status --parent-session <id>` lists the
54
+ teammates a given session spawned, and `agents teams list` gains a `by <id>`
55
+ column. Source: `apps/cli/src/lib/session/db.ts`,
56
+ `apps/cli/src/lib/session/team-filter.ts`, `apps/cli/src/commands/sessions.ts`,
57
+ `apps/cli/src/commands/sessions-browser.ts`, `apps/cli/src/commands/teams.ts`.
58
+
59
+ - **`agents sessions --device <box>` no longer opens an empty browser.** The
60
+ interactive one-host listing kept the browser's default this-repo scope, but
61
+ every row it fetches is the peer's and no peer cwd is under the local
62
+ `process.cwd()` — so the filter dropped all of them. A host scope now implies
63
+ all-directories (and the `p` hotkey is a no-op under one). Three more
64
+ scope bugs on the same path: `--device <this machine>` fanned out to the whole
65
+ tailnet, because `gatherRemoteList` reads the resulting empty peer list as "no
66
+ hosts given" and sweeps; `--local --device <box>` rendered a silent empty list
67
+ instead of reporting that the two flags ask for opposite things; and
68
+ `--device <box> --cloud` fell through to the cloud listing, which has no host
69
+ scope and silently ignored the device. An unreachable peer now says so in the
70
+ browser header — the fan-out's stderr note is repainted away by the full-screen
71
+ picker, so "that box is asleep" used to read as "no sessions match". Source:
72
+ `apps/cli/src/commands/sessions.ts`, `apps/cli/src/commands/sessions-browser.ts`,
73
+ `apps/cli/src/lib/session/remote-list.ts`.
74
+
75
+ - **Teammate records are found by the session id they actually produced.** A
76
+ teammate's directory under `teams/agents/` is named for its *agent* id, but the
77
+ harness mints its own session id and the spawn records it separately as
78
+ `remote_session_id`. `classifyTeamSession` looked only under the directory
79
+ name, so most teammates were unreachable — on a live box, 14 of 16 records
80
+ resolved only via `remote_session_id` — and their rows could not name their
81
+ team however complete the record was. Both keys are now registered in one
82
+ index built per process, which also replaces the `existsSync` + `readFileSync`
83
+ the old path paid for every row in the pool. Source:
84
+ `apps/cli/src/lib/session/team-filter.ts`.
85
+
86
+ - **`detectSpawnedTeam` no longer indexes prose or flag values as team names.**
87
+ Rendering the value exposed that it had been wrong for most of the rows that
88
+ had one: on a live index of 4627 sessions, 11 carried a team and 6 of those
89
+ read `2` or `t`. It matched documentation and echoed output rather than only
90
+ executed commands; its flag-skip used `\s` and so ran across a newline to
91
+ capture a word from the next line; and a value-taking flag did not swallow its
92
+ value, so `--device auto` left `auto` looking like the team name — which was
93
+ corrupting real detections, not just adding false ones. After the fix the same
94
+ index resolves ten teams, every one of them a real team name. Source:
95
+ `apps/cli/src/lib/session/state.ts`.
96
+
97
+ - **`--in-team` returns a team's whole lineage, not the slice inside the default
98
+ window.** It filtered in memory after the query, so a team older than the
99
+ default top-50 / 30-day / current-directory scope came back empty with no
100
+ message — and a team's teammates run in their own worktrees, which the
101
+ directory scope hid. The flag now widens its own scope the way `--all` does. It
102
+ is also refused with `--active`, whose live rows carry no lineage to match on,
103
+ rather than being silently ignored. Source: `apps/cli/src/commands/sessions.ts`.
104
+
105
+ - **The session preview pane sanitizes peer-supplied `plan` and directory
106
+ text.** A remote row's metadata is JSON the peer sent and `parseRemoteList`
107
+ hands over verbatim; `sanitizeMeta` covered `topic`/`label`/`cwd`/`todos` but
108
+ not these, so a terminal escape in another machine's plan text reached the TTY.
109
+ The remote preview also renders more of what already rides across the hop: the
110
+ checklist items, the directories the scan recorded, and a one-line plan summary
111
+ (never the full markdown blob). `directoriesTouched` now reads the real
112
+ `recentDirectoriesTouched` field instead of a `dirsTouched` that nothing in the
113
+ repo ever wrote. Source: `apps/cli/src/commands/sessions-picker.ts`.
114
+
3
115
  ## 1.20.86
4
116
 
5
117
  - **`agents sessions` now shows a Kimi session's todo list and its file-touching
package/dist/bin/agents CHANGED
Binary file
@@ -24,11 +24,27 @@ export interface BrowserFilter {
24
24
  agent?: string;
25
25
  /** filter to one machine, or all — the `D` key / `--device`. */
26
26
  device?: string;
27
+ /** filter to one team's lineage, or all — the `T` key / `--in-team`. */
28
+ team?: string;
27
29
  /** this-repo subtree vs every directory — the `P` key / `--all`. */
28
30
  projectScope: 'repo' | 'all';
29
31
  /** time window (undefined = all time) — the `W` key / `--since`. */
30
32
  window?: string;
31
33
  }
34
+ /**
35
+ * Complete a seed into the filter the picker actually runs on.
36
+ *
37
+ * Every optional field of {@link BrowserFilter} has to be named here, because the
38
+ * seed is copied field-by-field and an omission is silent: the field is optional,
39
+ * so the compiler says nothing and the browser just opens without that filter.
40
+ * `team` was dropped exactly this way, which made `--in-team` a no-op on the
41
+ * interactive path while the scope half of the same seed still applied — so the
42
+ * view opened wide and all-time and looked like the flag had worked.
43
+ *
44
+ * Exported so a test can assert the FILTER, not just the seed; testing the seed
45
+ * alone cannot see this class of bug.
46
+ */
47
+ export declare function buildInitialFilter(initial: Partial<BrowserFilter>): BrowserFilter;
32
48
  /** Return the next value in `[undefined, ...options]`, wrapping. */
33
49
  export declare function cycle(current: string | undefined, options: string[]): string | undefined;
34
50
  export declare function cycleWindow(current: string | undefined): string | undefined;
@@ -74,6 +90,8 @@ export declare function bareBrowserSeed(opts: {
74
90
  agent?: string;
75
91
  all?: boolean;
76
92
  since?: string;
93
+ host?: string[];
94
+ inTeam?: string;
77
95
  }): Partial<BrowserFilter>;
78
96
  /**
79
97
  * A live session's stable row key: its session id when the agent reported one,
@@ -11,13 +11,51 @@
11
11
  */
12
12
  import path from 'path';
13
13
  import { spawnSync } from 'child_process';
14
+ import chalk from 'chalk';
14
15
  import { dynamicPicker } from '../lib/picker.js';
15
16
  import { isSessionTrackedAgent } from '../lib/session/types.js';
16
17
  import { discoverSessions } from '../lib/session/discover.js';
17
18
  import { gatherRemoteList } from '../lib/session/remote-list.js';
19
+ import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
18
20
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
19
21
  import { buildPreview } from './sessions-picker.js';
20
- import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
22
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, } from './sessions.js';
23
+ /**
24
+ * Complete a seed into the filter the picker actually runs on.
25
+ *
26
+ * Every optional field of {@link BrowserFilter} has to be named here, because the
27
+ * seed is copied field-by-field and an omission is silent: the field is optional,
28
+ * so the compiler says nothing and the browser just opens without that filter.
29
+ * `team` was dropped exactly this way, which made `--in-team` a no-op on the
30
+ * interactive path while the scope half of the same seed still applied — so the
31
+ * view opened wide and all-time and looked like the flag had worked.
32
+ *
33
+ * Exported so a test can assert the FILTER, not just the seed; testing the seed
34
+ * alone cannot see this class of bug.
35
+ */
36
+ export function buildInitialFilter(initial) {
37
+ return {
38
+ running: initial.running ?? false,
39
+ teams: initial.teams ?? false,
40
+ agent: initial.agent,
41
+ device: initial.device,
42
+ team: initial.team,
43
+ projectScope: initial.projectScope ?? 'repo',
44
+ window: 'window' in initial ? initial.window : '30d',
45
+ };
46
+ }
47
+ /** Cache key for the transcript pool: every filter that changes what is FETCHED
48
+ * (window, team-origin inclusion, and the team filter's deeper limit) — not the
49
+ * ones applied in memory afterwards. */
50
+ function poolCacheKey(f) {
51
+ // The team filter contributes only whether it is SET, not which team: any team
52
+ // fetches the same deep pool and is then narrowed in memory. Keying on the name
53
+ // would make `t` the one hotkey that re-fans-out the fleet on every step of the
54
+ // cycle, at up to REMOTE_TIMEOUT_MS per unreachable peer.
55
+ return `${f.window ?? 'all'}|${f.teams}|${f.team ? 'team' : ''}`;
56
+ }
57
+ /** Pool size when a team filter is active; one team's rows can sit anywhere. */
58
+ const WHOLE_TEAM_POOL_LIMIT = 5000;
21
59
  /** Ordered window cycle for the `W` key. `undefined` = all time. */
22
60
  const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
23
61
  /** Return the next value in `[undefined, ...options]`, wrapping. */
@@ -73,6 +111,8 @@ export function browserFilterToArgv(f, query = '') {
73
111
  a.push('-a', f.agent);
74
112
  if (f.device)
75
113
  a.push('--device', f.device);
114
+ if (f.team)
115
+ a.push('--in-team', f.team);
76
116
  if (f.projectScope === 'all')
77
117
  a.push('--all');
78
118
  if (f.window)
@@ -116,12 +156,28 @@ export function activeBrowserSeed(opts) {
116
156
  * `-a`/`--device` still narrow their axis.
117
157
  */
118
158
  export function bareBrowserSeed(opts) {
159
+ // An explicit --device scopes the pool to a peer, whose cwds live under that
160
+ // machine's home — none of them can be under OUR process.cwd(), so the default
161
+ // 'repo' scope would filter every fetched row away and render an empty list.
162
+ // A host scope therefore implies all-directories, exactly as --all does.
163
+ const scoped = (opts.host?.length ?? 0) > 0;
164
+ // --in-team asks for ONE team's lineage, and a team's teammates run in their own
165
+ // `.agents/worktrees/<slug>/` — a different cwd from ours — while the team itself
166
+ // may be older than the default window. Both defaults would hide exactly the rows
167
+ // the flag exists to surface, so it widens the scope the way --all does. The flag
168
+ // path does this too (sessions.ts `wantsWholeTeam`); the browser is the one a
169
+ // human actually reaches, so it must not be the one that stays narrow.
170
+ const wholeTeam = !!opts.inTeam;
119
171
  return {
120
172
  teams: !!opts.teams,
121
173
  agent: opts.agent,
174
+ // The filter carries one device; seed it only when the scope names exactly
175
+ // one, so a two-device scope isn't narrowed to the first of them.
176
+ device: opts.host?.length === 1 ? normalizeDeviceSeed(opts.host[0]) : undefined,
177
+ team: opts.inTeam,
122
178
  // --all maxes every non-status filter: all dirs AND all-time. --since wins.
123
- projectScope: opts.all ? 'all' : 'repo',
124
- window: opts.since ?? (opts.all ? undefined : '30d'),
179
+ projectScope: opts.all || scoped || wholeTeam ? 'all' : 'repo',
180
+ window: opts.since ?? (opts.all || wholeTeam ? undefined : '30d'),
125
181
  };
126
182
  }
127
183
  /** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
@@ -154,6 +210,7 @@ function copyToClipboard(text) {
154
210
  * not add" contract instead of always sweeping the whole fleet. */
155
211
  async function fetchRawPool(f, self, local, hosts) {
156
212
  const since = f.window;
213
+ let unreachable = [];
157
214
  // Local pool: wide (every directory) — device/agent/project are applied in
158
215
  // memory so a hotkey toggle is instant and doesn't re-hit the disk. Skipped
159
216
  // when an explicit host scope excludes this machine.
@@ -163,7 +220,10 @@ async function fetchRawPool(f, self, local, hosts) {
163
220
  cwd: process.cwd(),
164
221
  since,
165
222
  excludeTeamOrigin: !f.teams,
166
- limit: 500,
223
+ // A team filter reaches back past the usual browse window, so the pool it
224
+ // draws from has to as well — otherwise the newest 500 rows decide which
225
+ // teams exist.
226
+ limit: f.team ? WHOLE_TEAM_POOL_LIMIT : 500,
167
227
  sortBy: 'timestamp',
168
228
  })
169
229
  : [];
@@ -171,22 +231,40 @@ async function fetchRawPool(f, self, local, hosts) {
171
231
  // Skipped under --local. An explicit --host/--device scopes exactly which peers
172
232
  // are dialed (undefined = sweep every online device). Best-effort — a fan-out
173
233
  // failure leaves the local list intact.
174
- if (!local) {
234
+ const remoteHosts = remoteHostsToDial(hosts, self);
235
+ // An explicit scope naming only this machine leaves nothing remote to dial.
236
+ // gatherRemoteList reads `[]` as "no hosts given" and falls through to the
237
+ // whole-fleet sweep, so `--device <self>` would dial every online box — the
238
+ // exact opposite of the flag's scope-not-add contract. Skip the fan-out here,
239
+ // the same way gatherActiveSessions does for --active.
240
+ const dialPeers = !local && (!hosts?.length || (remoteHosts && remoteHosts.length > 0));
241
+ if (dialPeers) {
175
242
  try {
176
- const forwarded = ['sessions', '--all', '--json', '--limit', '500'];
243
+ // The peer's cap has to match the local one, or a team filter widens only
244
+ // this machine's half of the pool and a peer's older rows stay invisible —
245
+ // the same bug one hop out. A numeric --limit is forwarded rather than
246
+ // --in-team itself, which a peer on an older build would reject as an
247
+ // unknown option and fail the whole fan-out.
248
+ const forwarded = ['sessions', '--all', '--json', '--limit', String(f.team ? WHOLE_TEAM_POOL_LIMIT : 500)];
177
249
  if (since)
178
250
  forwarded.push('--since', since);
179
251
  if (f.teams)
180
252
  forwarded.push('--teams');
181
- const { sessions: remote } = await gatherRemoteList(forwarded, remoteHostsToDial(hosts, self));
182
- if (remote.length > 0)
183
- rows = mergeLocalFirst([...rows, ...remote], self);
253
+ const remoteResult = await gatherRemoteList(forwarded, remoteHosts);
254
+ unreachable = remoteResult.unreachable;
255
+ if (remoteResult.sessions.length > 0)
256
+ rows = mergeLocalFirst([...rows, ...remoteResult.sessions], self);
184
257
  }
185
258
  catch {
186
259
  // enrichment, never a hard dependency
187
260
  }
188
261
  }
189
- return { key: `${since ?? 'all'}|${f.teams}`, rows };
262
+ // Team rows are anonymous without their meta.json (team name, handle, the
263
+ // orchestrator that spawned them). Only pay for that read when team rows are
264
+ // actually in the pool — with `c` off they were excluded at the query.
265
+ if (f.teams)
266
+ rows = enrichTeamOrigins(rows);
267
+ return { key: poolCacheKey(f), rows, unreachable };
190
268
  }
191
269
  /**
192
270
  * A live session's stable row key: its session id when the agent reported one,
@@ -288,6 +366,8 @@ function applyFilters(rows, live, f, self) {
288
366
  out = out.filter((r) => r.agent === f.agent);
289
367
  if (f.device)
290
368
  out = out.filter((r) => (r.machine ?? self) === f.device);
369
+ if (f.team)
370
+ out = out.filter((r) => matchesTeam(r, f.team));
291
371
  if (f.projectScope === 'repo') {
292
372
  const cwd = process.cwd();
293
373
  out = out.filter((r) => !!r.cwd && (r.cwd === cwd || r.cwd.startsWith(cwd + '/')));
@@ -310,6 +390,7 @@ function headerFor(f) {
310
390
  const bits = [
311
391
  `device:${f.device ?? 'all'}`,
312
392
  `agent:${f.agent ?? 'all'}`,
393
+ `team:${f.team ?? 'all'}`,
313
394
  f.projectScope === 'repo' ? 'this repo' : 'all dirs',
314
395
  `window:${f.window ?? 'all'}`,
315
396
  ];
@@ -323,7 +404,7 @@ function helpFor(_f, mode) {
323
404
  if (mode === 'search') {
324
405
  return 'type to filter · ↑↓ navigate · esc exit search · ⏎ resume';
325
406
  }
326
- return 's search · r running · c teams · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
407
+ return 's search · r running · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
327
408
  }
328
409
  /**
329
410
  * Launch the interactive session browser. `initial` seeds the filter (e.g.
@@ -337,10 +418,17 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
337
418
  // Updated after each load so the A/D cycles range over what's actually present.
338
419
  let agentsInPool = [];
339
420
  let devicesInPool = [];
421
+ let teamsInPool = [];
340
422
  let cols = {};
341
- // Cache the transcript fetch, keyed by (window, teams); agent/device/project/
342
- // running are applied in memory so their hotkeys don't re-fan-out the fleet.
423
+ // Cache the transcript fetch, keyed by poolCacheKey (everything that changes
424
+ // what is FETCHED); agent/device/project/running are applied in memory so their
425
+ // hotkeys don't re-fan-out the fleet.
343
426
  let rawCache = null;
427
+ // Peers that didn't answer the last fan-out. The fan-out's own note goes to
428
+ // stderr, which the full-screen picker repaints over — so it is surfaced in
429
+ // the header instead, where "that box is asleep" stays distinguishable from
430
+ // "that box has nothing matching".
431
+ let unreachable = [];
344
432
  // The live index is slow (a full ps/tmux scan) and only the running filter
345
433
  // needs it — fetch it once, lazily, the first time running is toggled on.
346
434
  let liveCache = null;
@@ -350,17 +438,13 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
350
438
  // caches) is a side channel it can't see — so a stale load must never commit
351
439
  // it. We compute into locals and only write the shared state as the latest load.
352
440
  let loadGen = 0;
353
- const initialFilter = {
354
- running: initial.running ?? false,
355
- teams: initial.teams ?? false,
356
- agent: initial.agent,
357
- device: initial.device,
358
- projectScope: initial.projectScope ?? 'repo',
359
- window: 'window' in initial ? initial.window : '30d',
360
- };
441
+ const initialFilter = buildInitialFilter(initial);
361
442
  const load = async (f) => {
362
443
  const myGen = ++loadGen;
363
- const key = `${f.window ?? 'all'}|${f.teams}`;
444
+ // f.team decides the fetch limit, so it belongs in the key otherwise arriving
445
+ // via `t` reuses a 500-row pool while --in-team fetched 5000, and the cycle can
446
+ // only offer the teams that happened to be in whichever pool was built first.
447
+ const key = poolCacheKey(f);
364
448
  let pool = rawCache && rawCache.key === key ? rawCache : null;
365
449
  if (!pool) {
366
450
  const fetched = await fetchRawPool(f, self, local, hosts);
@@ -386,6 +470,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
386
470
  // Latest load — commit shared state atomically (no await past this point, so
387
471
  // no newer load can interleave between these writes).
388
472
  rawCache = pool;
473
+ unreachable = pool.unreachable;
389
474
  if (live)
390
475
  liveCache = live;
391
476
  // Live sessions the transcript pool lacks become rows of their own, so the
@@ -393,6 +478,17 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
393
478
  const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
394
479
  agentsInPool = distinct(rows.map((r) => r.agent));
395
480
  devicesInPool = distinct(rows.map((r) => r.machine ?? self));
481
+ // Both ends of the lineage seed the cycle: teams a row spawned, and teams a
482
+ // row belongs to. Teammate rows only carry `teamOrigin` when `c` is on, so
483
+ // with teams hidden this ranges over spawned teams alone — which is exactly
484
+ // the set whose rows are visible.
485
+ // Through safeTeamText: the cycle's values become `f.team`, which headerFor
486
+ // interpolates into the header and browserFilterToArgv copies into a command,
487
+ // and on a peer's row these strings are that machine's to choose.
488
+ teamsInPool = distinct([
489
+ ...rows.map((r) => safeTeamText(r.spawnedTeam)),
490
+ ...rows.map((r) => safeTeamText(r.teamOrigin?.team)),
491
+ ]);
396
492
  const filtered = applyFilters(rows, live ?? new Map(), f, self);
397
493
  cols = pickerColumnsFor(filtered);
398
494
  cols.showHost = shouldShowHostColumn(f, live, filtered);
@@ -406,7 +502,9 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
406
502
  labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
407
503
  matches: sessionMatchesQuery,
408
504
  buildPreview,
409
- headerFor,
505
+ headerFor: (f) => unreachable.length > 0
506
+ ? `${headerFor(f)} · ${chalk.yellow(`${unreachable.join(', ')}: unreachable`)}`
507
+ : headerFor(f),
410
508
  helpFor,
411
509
  enterHint: 'resume',
412
510
  emptyMessage: 'No sessions match this filter.',
@@ -416,7 +514,11 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
416
514
  c: (f) => ({ ...f, teams: !f.teams }),
417
515
  a: (f) => ({ ...f, agent: cycle(f.agent, agentsInPool) }),
418
516
  d: (f) => ({ ...f, device: cycle(f.device, devicesInPool) }),
419
- p: (f) => ({ ...f, projectScope: f.projectScope === 'repo' ? 'all' : 'repo' }),
517
+ t: (f) => ({ ...f, team: cycle(f.team, teamsInPool) }),
518
+ // Under an explicit --device scope every row is a peer's, and no peer cwd
519
+ // is under our process.cwd() — so narrowing to "this repo" could only ever
520
+ // empty the list. Returning the same reference makes the key a no-op.
521
+ p: (f) => (hosts ? f : { ...f, projectScope: f.projectScope === 'repo' ? 'all' : 'repo' }),
420
522
  w: (f) => ({ ...f, window: cycleWindow(f.window) }),
421
523
  },
422
524
  onKey: (name, f, _active, query) => {
@@ -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 {};
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
  import chalk from 'chalk';
11
11
  import { truncate, humanDuration } from '../lib/format.js';
12
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');
@@ -360,6 +421,10 @@ function formatCompactPreview(events, session) {
360
421
  if (dirs.length) {
361
422
  lines.push(chalk.cyan('Dirs: ') + chalk.white(dirs.join(chalk.gray(' · '))));
362
423
  }
424
+ const teamLine = formatTeamLineage(session);
425
+ if (teamLine) {
426
+ lines.push(chalk.cyan('Team: ') + teamLine);
427
+ }
363
428
  const activity = [];
364
429
  const changed = chg.created + chg.modified + chg.deleted;
365
430
  if (changed) {
@@ -435,16 +500,27 @@ function isSubAgentTool(tool, command) {
435
500
  }
436
501
  /**
437
502
  * Unique directories the session touched, compact and human-readable.
438
- * Prefer `session.dirsTouched` when the parser teammate has populated it;
439
- * 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.
440
506
  */
441
507
  export function directoriesTouched(session, events, changes) {
442
- const fromMeta = session.dirsTouched;
508
+ const fromMeta = session.recentDirectoriesTouched;
443
509
  if (Array.isArray(fromMeta) && fromMeta.length > 0) {
444
- return fromMeta
445
- .map((d) => sanitizeForTerminal(String(d).trim()))
446
- .filter(Boolean)
447
- .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];
448
524
  }
449
525
  const counts = new Map();
450
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