@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
@@ -16,8 +16,10 @@ import { machineForSessionFile } from './origin-machine.js';
16
16
  import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
17
17
  const SESSIONS_DIR = getSessionsDir();
18
18
  const DB_PATH = getSessionsDbPath();
19
- /** Current schema version; bumped when migrations are added. */
20
- const SCHEMA_VERSION = 20;
19
+ /** Current schema version; bumped when migrations are added. Exported so tests
20
+ * assert against the constant instead of hardcoding a number that every bump
21
+ * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
22
+ export const SCHEMA_VERSION = 21;
21
23
  /**
22
24
  * Canonicalize a file path for use as a scan_ledger key. The same physical
23
25
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -74,6 +76,7 @@ CREATE TABLE IF NOT EXISTS sessions (
74
76
  pr_number INTEGER,
75
77
  worktree_slug TEXT,
76
78
  ticket_id TEXT,
79
+ spawned_team TEXT,
77
80
  plan TEXT,
78
81
  machine TEXT,
79
82
  todos TEXT,
@@ -383,6 +386,19 @@ function migrateSchema(db, fromVersion) {
383
386
  db.exec(`ALTER TABLE sessions ADD COLUMN model TEXT`);
384
387
  db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
385
388
  }
389
+ if (fromVersion < 21) {
390
+ // v20 → v21: persist the team a session SPAWNED (`agents teams create/add`).
391
+ // The value was already derived at scan time (discover.ts detectSpawnedTeam)
392
+ // and set on SessionMeta, but had no column — so it was dropped at the write
393
+ // and no consumer ever saw it. Wipe BOTH ledgers, not just scan_ledger: with
394
+ // dir_ledger intact, collectChangedFilesInLeafDirs treats every non-live-root
395
+ // dir as unchanged and derives its hot set from the scan stamps just deleted,
396
+ // so archived dirs would never be re-parsed and would stay NULL forever.
397
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
398
+ if (!cols.some(c => c.name === 'spawned_team'))
399
+ db.exec(`ALTER TABLE sessions ADD COLUMN spawned_team TEXT`);
400
+ db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
401
+ }
386
402
  }
387
403
  /** Open (or return the cached) sessions database, applying migrations as needed. */
388
404
  export function getDB() {
@@ -744,7 +760,7 @@ const upsertSessionStmt = (db) => db.prepare(`
744
760
  project, cwd, git_branch, topic, label, message_count, token_count,
745
761
  output_tokens, cost_usd, duration_ms, model,
746
762
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
747
- pr_url, pr_number, worktree_slug, ticket_id, plan, todos,
763
+ pr_url, pr_number, worktree_slug, ticket_id, spawned_team, plan, todos,
748
764
  recent_directories_touched, linear_project, linear_project_url, machine,
749
765
  actor, initiated_by
750
766
  ) VALUES (
@@ -753,7 +769,7 @@ const upsertSessionStmt = (db) => db.prepare(`
753
769
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
754
770
  @output_tokens, @cost_usd, @duration_ms, @model,
755
771
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
756
- @pr_url, @pr_number, @worktree_slug, @ticket_id, @plan, @todos,
772
+ @pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team, @plan, @todos,
757
773
  @recent_directories_touched, @linear_project, @linear_project_url, @machine,
758
774
  @actor, @initiated_by
759
775
  )
@@ -795,6 +811,7 @@ const upsertSessionStmt = (db) => db.prepare(`
795
811
  pr_number = excluded.pr_number,
796
812
  worktree_slug = excluded.worktree_slug,
797
813
  ticket_id = excluded.ticket_id,
814
+ spawned_team = excluded.spawned_team,
798
815
  plan = excluded.plan,
799
816
  todos = excluded.todos,
800
817
  recent_directories_touched = excluded.recent_directories_touched,
@@ -911,6 +928,7 @@ export function upsertSession(meta, content, scan) {
911
928
  pr_number: meta.prNumber ?? null,
912
929
  worktree_slug: meta.worktreeSlug ?? null,
913
930
  ticket_id: meta.ticketId ?? null,
931
+ spawned_team: meta.spawnedTeam ?? null,
914
932
  plan: meta.plan ?? null,
915
933
  todos: meta.todos ? JSON.stringify(meta.todos) : null,
916
934
  recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
@@ -1031,6 +1049,7 @@ export function upsertSessionsBatch(entries) {
1031
1049
  pr_number: meta.prNumber ?? null,
1032
1050
  worktree_slug: meta.worktreeSlug ?? null,
1033
1051
  ticket_id: meta.ticketId ?? null,
1052
+ spawned_team: meta.spawnedTeam ?? null,
1034
1053
  plan: meta.plan ?? null,
1035
1054
  todos: meta.todos ? JSON.stringify(meta.todos) : null,
1036
1055
  recent_directories_touched: meta.recentDirectoriesTouched ? JSON.stringify(meta.recentDirectoriesTouched) : null,
@@ -1219,6 +1238,7 @@ function rowToMeta(row) {
1219
1238
  prNumber: row.pr_number ?? undefined,
1220
1239
  worktreeSlug: row.worktree_slug ?? undefined,
1221
1240
  ticketId: row.ticket_id ?? undefined,
1241
+ spawnedTeam: row.spawned_team ?? undefined,
1222
1242
  plan: row.plan ?? undefined,
1223
1243
  todos: parseJsonColumn(row.todos),
1224
1244
  recentDirectoriesTouched: parseJsonColumn(row.recent_directories_touched),
@@ -1483,6 +1503,27 @@ export function queryUsageRollup(options) {
1483
1503
  `;
1484
1504
  return db.prepare(sql).all(...params);
1485
1505
  }
1506
+ /**
1507
+ * Map every team name to the session that ran `agents teams create/add` for it.
1508
+ *
1509
+ * One scan over the rows that carry a `spawned_team`, rather than a query per
1510
+ * team — `agents teams list` needs the whole map at once, and the column has no
1511
+ * index. When two sessions spawned the same team name (a team re-created after a
1512
+ * disband), the most recent wins, which is the one whose work the name refers to.
1513
+ */
1514
+ export function teamSpawners() {
1515
+ const db = getDB();
1516
+ const rows = db
1517
+ .prepare(`SELECT spawned_team, id, short_id, actor FROM sessions
1518
+ WHERE spawned_team IS NOT NULL AND spawned_team != ''
1519
+ ORDER BY timestamp ASC`)
1520
+ .all();
1521
+ const out = new Map();
1522
+ for (const r of rows) {
1523
+ out.set(r.spawned_team, { sessionId: r.id, shortId: r.short_id, actor: r.actor ?? undefined });
1524
+ }
1525
+ return out;
1526
+ }
1486
1527
  /**
1487
1528
  * Return the N most expensive sessions (cost_usd DESC, NULLs excluded),
1488
1529
  * honoring the same filter shape as querySessions. Drops rows whose JSONL
@@ -27,6 +27,17 @@ export declare function safeReadSessionFile(filePath: string, maxBytes?: number)
27
27
  export declare function parseSession(filePath: string, agent?: SessionAgentId): SessionEvent[];
28
28
  /** Infer the agent type from a session file path using known directory conventions. */
29
29
  export declare function detectAgent(filePath: string): SessionAgentId | null;
30
+ /**
31
+ * Checklist-snapshot tool names across harnesses — each sends the WHOLE list on
32
+ * every write, so the last call is the current checklist. Claude `TodoWrite`,
33
+ * Kimi `TodoList`, Droid/OpenCode `todo_write`, Codex `update_plan`.
34
+ */
35
+ export declare const SNAPSHOT_TODO_TOOLS: Set<string>;
36
+ /**
37
+ * Whether a harness's checklist status means "finished". Claude/Codex write
38
+ * `completed`; Kimi writes `done`.
39
+ */
40
+ export declare function isCompletedTodoStatus(status: unknown): boolean;
30
41
  /**
31
42
  * Summarize a tool_use into a one-liner string.
32
43
  */
@@ -205,6 +205,19 @@ export function detectAgent(filePath) {
205
205
  return 'gemini';
206
206
  return null;
207
207
  }
208
+ /**
209
+ * Checklist-snapshot tool names across harnesses — each sends the WHOLE list on
210
+ * every write, so the last call is the current checklist. Claude `TodoWrite`,
211
+ * Kimi `TodoList`, Droid/OpenCode `todo_write`, Codex `update_plan`.
212
+ */
213
+ export const SNAPSHOT_TODO_TOOLS = new Set(['TodoWrite', 'TodoList', 'todo_write', 'update_plan']);
214
+ /**
215
+ * Whether a harness's checklist status means "finished". Claude/Codex write
216
+ * `completed`; Kimi writes `done`.
217
+ */
218
+ export function isCompletedTodoStatus(status) {
219
+ return status === 'completed' || status === 'done';
220
+ }
208
221
  /**
209
222
  * Summarize a tool_use into a one-liner string.
210
223
  */
@@ -214,12 +227,13 @@ export function summarizeToolUse(tool, args) {
214
227
  switch (tool) {
215
228
  case 'Bash':
216
229
  return `Bash: ${truncate(String(args.command || '').replace(/\n/g, ' ').trim(), 120)}`;
230
+ // `path` is the Kimi spelling of Claude's `file_path` for the same tools.
217
231
  case 'Read':
218
- return `Read ${shortenPath(args.file_path || '')}`;
232
+ return `Read ${shortenPath(args.file_path || args.path || '')}`;
219
233
  case 'Write':
220
- return `Write ${shortenPath(args.file_path || '')}`;
234
+ return `Write ${shortenPath(args.file_path || args.path || '')}`;
221
235
  case 'Edit':
222
- return `Edit ${shortenPath(args.file_path || '')}`;
236
+ return `Edit ${shortenPath(args.file_path || args.path || '')}`;
223
237
  case 'Glob':
224
238
  return `Glob ${args.pattern || ''}`;
225
239
  case 'Grep':
@@ -234,14 +248,17 @@ export function summarizeToolUse(tool, args) {
234
248
  const steps = Array.isArray(args.plan) ? args.plan.length : 0;
235
249
  return `Plan: ${steps} step${steps === 1 ? '' : 's'}`;
236
250
  }
237
- // Claude's live checklist: show progress + the current step, not a bare "TodoWrite".
238
- case 'TodoWrite': {
251
+ // Live checklist: show progress + the current step, not a bare "TodoWrite".
252
+ // Claude writes `TodoWrite`, Kimi writes `TodoList`; both carry the whole list
253
+ // under `todos`, with Kimi spelling the item text `title` and "done" `done`.
254
+ case 'TodoWrite':
255
+ case 'TodoList': {
239
256
  const todos = Array.isArray(args.todos) ? args.todos : [];
240
257
  if (todos.length === 0)
241
258
  return 'Plan: 0 steps';
242
- const done = todos.filter((t) => t?.status === 'completed').length;
259
+ const done = todos.filter((t) => isCompletedTodoStatus(t?.status)).length;
243
260
  const active = todos.find((t) => t?.status === 'in_progress');
244
- const step = active?.activeForm || active?.content;
261
+ const step = active?.activeForm || active?.content || active?.title;
245
262
  return step
246
263
  ? `Plan ${done}/${todos.length}: ${truncate(String(step), 80)}`
247
264
  : `Plan: ${done}/${todos.length} done`;
@@ -20,6 +20,13 @@ export interface RemoteListResult {
20
20
  sessions: SessionMeta[];
21
21
  /** How many peer machines we attempted to reach (drives the empty-fleet tip). */
22
22
  deviceCount: number;
23
+ /**
24
+ * Peers that failed to answer, by display name. The stderr note above is
25
+ * enough for a printed listing, but the interactive browser repaints over it —
26
+ * so callers rendering a full-screen UI need the outcome as data to tell
27
+ * "that box is asleep" apart from "that box has no matching sessions".
28
+ */
29
+ unreachable: string[];
23
30
  }
24
31
  /**
25
32
  * Gather listing sessions from other machines. With an explicit `hosts` list
@@ -97,9 +97,9 @@ async function fetchByTarget(target, machine, display, forwardedArgs, os) {
97
97
  const { code, stdout } = await sshCapture(target, remoteListCommand(forwardedArgs, os), REMOTE_TIMEOUT_MS);
98
98
  if (code !== 0) {
99
99
  process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
100
- return [];
100
+ return { sessions: [], unreachable: display };
101
101
  }
102
- return parseRemoteList(stdout, machine);
102
+ return { sessions: parseRemoteList(stdout, machine) };
103
103
  }
104
104
  /**
105
105
  * Gather listing sessions from other machines. With an explicit `hosts` list
@@ -122,7 +122,7 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
122
122
  reg = await loadDevices();
123
123
  }
124
124
  catch {
125
- return { sessions: [], deviceCount: 0 };
125
+ return { sessions: [], deviceCount: 0, unreachable: [] };
126
126
  }
127
127
  for (const d of Object.values(reg)) {
128
128
  if (d.tailscale?.online !== true)
@@ -147,7 +147,11 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
147
147
  }
148
148
  }
149
149
  const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, forwardedArgs, t.os)));
150
- return { sessions: results.flat(), deviceCount: targets.length };
150
+ return {
151
+ sessions: results.flatMap((r) => r.sessions),
152
+ deviceCount: targets.length,
153
+ unreachable: results.map((r) => r.unreachable).filter((n) => !!n),
154
+ };
151
155
  }
152
156
  /** Resolve a peer's SSH target (and OS) from the device registry by its
153
157
  * normalized machine id — the same id the fan-out tags rows with. Returns
@@ -119,11 +119,12 @@ export interface StateContext {
119
119
  activeWindowMs?: number;
120
120
  }
121
121
  /**
122
- * Derive live plan progress from a checklist tool call's args. Accepts both
123
- * Claude's `TodoWrite` (`todos: [{content,status,activeForm}]`) and Codex's
124
- * `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the single source
125
- * of checklist state for every agent. Returns undefined when there is no usable
126
- * list, so a session with no plan carries no `todos` field.
122
+ * Derive live plan progress from a checklist tool call's args. Accepts Claude's
123
+ * `TodoWrite` (`todos: [{content,status,activeForm}]`), Kimi's `TodoList`
124
+ * (`todos: [{title,status}]`, where finished is `done` rather than `completed`)
125
+ * and Codex's `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the
126
+ * single source of checklist state for every agent. Returns undefined when there
127
+ * is no usable list, so a session with no plan carries no `todos` field.
127
128
  */
128
129
  export declare function extractTodoProgress(args?: Record<string, any>): TodoProgress | undefined;
129
130
  /** Fold snapshot checklist tools and Claude TaskCreate/TaskUpdate event logs. */
@@ -15,7 +15,7 @@
15
15
  * shape + mtime — same function, driven off the normalized events.
16
16
  */
17
17
  import * as path from 'path';
18
- import { summarizeToolUse } from './parse.js';
18
+ import { isCompletedTodoStatus, SNAPSHOT_TODO_TOOLS, summarizeToolUse } from './parse.js';
19
19
  /**
20
20
  * Detect per-session rate-limit / usage-limit signals in assistant or error
21
21
  * text (RUSH-1523). Matches the same shapes Factory's prewarm detectBlockingPrompt
@@ -48,15 +48,15 @@ const PROSE_QUESTION_FRESH_MS = 30 * 60_000;
48
48
  /** Claude tool names that structurally mean "the agent handed control back to you". */
49
49
  const PLAN_TOOL = 'ExitPlanMode';
50
50
  const ASK_TOOL = 'AskUserQuestion';
51
- const SNAPSHOT_TODO_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan']);
52
51
  const TASK_CREATE_TOOL = 'TaskCreate';
53
52
  const TASK_UPDATE_TOOL = 'TaskUpdate';
54
53
  /**
55
- * Derive live plan progress from a checklist tool call's args. Accepts both
56
- * Claude's `TodoWrite` (`todos: [{content,status,activeForm}]`) and Codex's
57
- * `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the single source
58
- * of checklist state for every agent. Returns undefined when there is no usable
59
- * list, so a session with no plan carries no `todos` field.
54
+ * Derive live plan progress from a checklist tool call's args. Accepts Claude's
55
+ * `TodoWrite` (`todos: [{content,status,activeForm}]`), Kimi's `TodoList`
56
+ * (`todos: [{title,status}]`, where finished is `done` rather than `completed`)
57
+ * and Codex's `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the
58
+ * single source of checklist state for every agent. Returns undefined when there
59
+ * is no usable list, so a session with no plan carries no `todos` field.
60
60
  */
61
61
  export function extractTodoProgress(args) {
62
62
  const input = args?.input && typeof args.input === 'object' ? args.input : args;
@@ -76,10 +76,16 @@ export function extractTodoProgress(args) {
76
76
  ? t.text
77
77
  : typeof t?.step === 'string' && t.step
78
78
  ? t.step
79
- : activeForm ?? '';
79
+ : typeof t?.title === 'string' && t.title
80
+ ? t.title
81
+ : activeForm ?? '';
80
82
  if (!content)
81
83
  continue;
82
- const status = t?.status === 'completed' || t?.status === 'in_progress' ? t.status : 'pending';
84
+ const status = isCompletedTodoStatus(t?.status)
85
+ ? 'completed'
86
+ : t?.status === 'in_progress'
87
+ ? 'in_progress'
88
+ : 'pending';
83
89
  const description = typeof t?.description === 'string' && t.description ? t.description : undefined;
84
90
  items.push({ content, status, ...(description ? { description } : {}), ...(activeForm ? { activeForm } : {}) });
85
91
  }
@@ -195,12 +201,66 @@ const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
195
201
  const GH_ISSUE_CREATE_RE = /\bgh\s+issue\s+create\b/;
196
202
  /** A created GitHub issue URL (…/issues/123) in tool-result output. */
197
203
  const GH_ISSUE_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/issues\/(\d+)/;
204
+ /**
205
+ * Flags of `teams create` / `teams add` that take a value, so the value is not
206
+ * mistaken for the positional team name. Mirrors their value-taking flags in
207
+ * `commands/teams.ts` — most are `.option('… <x>')` registrations, but
208
+ * `--device`/`--host` come from `addHostOption`, so auditing this list against
209
+ * `.option(` alone would wrongly drop them. A flag missing here degrades to "no
210
+ * team detected", never to a wrong one.
211
+ */
212
+ const TEAM_VALUE_FLAGS = [
213
+ '-d', '--description', '--use-worktree', '--devices', '--hosts', '--repo',
214
+ '-n', '--name', '-m', '--mode', '-e', '--effort', '--model', '--env',
215
+ '--cwd', '--worktree', '--after', '--task-type', '--cloud', '--branch',
216
+ '--device', '--host',
217
+ ];
218
+ /**
219
+ * One flag value: a quoted string or a bare token. `-d "sessions lineage"` is the
220
+ * common shape — `--description` is usually a phrase — and a value pattern of
221
+ * `\S+` alone stops at the first space, leaving the rest of the phrase to be read
222
+ * as the positional team name (`… -d "sessions lineage" my-team` detected
223
+ * `lineage`). Quotes are matched as a unit so the whole value is consumed.
224
+ *
225
+ * A value containing an ESCAPED quote (`-d "say \"hi\" now"`) stops the quoted
226
+ * branch early and the match then fails outright — which is the intended failure
227
+ * direction: no team detected rather than a wrong one.
228
+ */
229
+ const FLAG_VALUE = String.raw `(?:"[^"\n]*"|'[^'\n]*'|\S+)`;
198
230
  /**
199
231
  * `agents teams create <name>` / `agents teams add <team> …` (also the `ag` alias).
200
232
  * The team NAME is the first bareword after the sub-verb, skipping any flags. This
201
233
  * is the structural signal that a session SPAWNED a team (vs. was spawned by one).
234
+ *
235
+ * The separators are spaces/tabs, never `\s`: a command string routinely embeds
236
+ * documentation and quoted output, and `\s` let the flag-skip run across newlines
237
+ * to capture a word from a completely different line (a real scan produced
238
+ * `team:installed` from a heredoc). For the same reason the flag-skip is bounded
239
+ * rather than unlimited — a real invocation carries a handful of flags before the
240
+ * name, not dozens.
202
241
  */
203
- const TEAMS_SPAWN_RE = /\bag(?:ents)?\s+teams?\s+(?:create|add)\s+(?:--?[a-z][\w-]*(?:[= ]\S+)?\s+)*([A-Za-z0-9][\w-]*)/;
242
+ const TEAMS_SPAWN_RE = new RegExp(
243
+ // Start of an actually-executed command: string start, a newline, or a shell
244
+ // separator. Without this, a backticked mention inside prose or tool output
245
+ // ("… and `agents teams add --device auto`") reads as a spawn.
246
+ String.raw `(?:^|[\n;&|(]|&&|\|\|)[ \t]*` +
247
+ String.raw `ag(?:ents)?[ \t]+teams?[ \t]+(?:create|add)[ \t]+` +
248
+ // Flags before the positional name. A value-taking flag must swallow its
249
+ // value, or `--device auto` leaves `auto` looking like the team name — and
250
+ // the generic branch must exclude those flags, or it swallows the flag alone
251
+ // and hands the value back as the name.
252
+ String.raw `(?:(?:${TEAM_VALUE_FLAGS.join('|')})[= \t]${FLAG_VALUE}[ \t]+` +
253
+ String.raw `|(?!(?:${TEAM_VALUE_FLAGS.join('|')})[= \t])--?[a-z][\w-]*(?:=\S+)?[ \t]+){0,6}` +
254
+ // A team name may start with a digit — `createTeam` validates nothing, and
255
+ // `2fa-migration` is a legal name — so the class stays [A-Za-z0-9]. The
256
+ // all-digits case is rejected in the guard below instead.
257
+ String.raw `([A-Za-z0-9][\w-]*)`);
258
+ /**
259
+ * Sub-verbs that can follow `teams create|add` in prose ("teams add a teammate")
260
+ * but are never a team name. Guards the common case where the match came from a
261
+ * sentence rather than an executed command.
262
+ */
263
+ const NON_TEAM_WORDS = new Set(['a', 'an', 'the', 'to', 'for', 'with', 'and', 'this', 'your', 'my', 'it']);
204
264
  /** Collapse to a single trimmed line for a one-row preview cell. */
205
265
  function oneLine(s) {
206
266
  return s.replace(/\s+/g, ' ').trim();
@@ -253,7 +313,20 @@ export function detectSpawnedTeam(command) {
253
313
  if (!command)
254
314
  return undefined;
255
315
  const m = command.match(TEAMS_SPAWN_RE);
256
- return m ? m[1] : undefined;
316
+ if (!m)
317
+ return undefined;
318
+ const name = m[1];
319
+ // A single character is a doc placeholder (`agents teams create t --host <box>`)
320
+ // far more often than a real team, and an English article is prose. Both used to
321
+ // land in the index as a team name, and now that the name is rendered on the row
322
+ // a wrong one is worse than none.
323
+ // A single character is a doc placeholder (`agents teams create t --host <name>`)
324
+ // far more often than a real team; an all-digits token is a flag value or a list
325
+ // index that leaked through, never a name someone typed. Both had reached the
326
+ // index, and now that the name is rendered a wrong one is worse than none.
327
+ if (name.length < 2 || /^\d+$/.test(name) || NON_TEAM_WORDS.has(name.toLowerCase()))
328
+ return undefined;
329
+ return name;
257
330
  }
258
331
  /**
259
332
  * True when a tool_use call CREATES a tracker ticket — a Linear MCP `create_issue`
@@ -12,14 +12,33 @@ import type { SessionMeta, TeamOrigin } from './types.js';
12
12
  *
13
13
  * Primary signal is `session.isTeamOrigin`, captured at scan time from the
14
14
  * JSONL `entrypoint` field ('sdk-cli' for team spawns, 'cli' for real CLI).
15
- * When a team meta.json exists we additionally enrich with handle/mode — but
16
- * its absence no longer demotes a session: older team runs whose meta dir
17
- * was cleaned up still get recognized via the entrypoint flag.
15
+ * When a team meta.json exists we additionally enrich with handle/mode/team and
16
+ * the orchestrator that spawned it but its absence no longer demotes a
17
+ * session: older team runs whose meta dir was cleaned up still get recognized
18
+ * via the entrypoint flag.
18
19
  *
19
20
  * Returns the TeamOrigin metadata when the session is team-origin, or null
20
21
  * when it is a normal interactive session.
21
22
  */
22
23
  export declare function classifyTeamSession(session: SessionMeta): TeamOrigin | null;
24
+ /**
25
+ * A team name / handle as it is safe to render: a real string, terminal escapes
26
+ * stripped, or undefined.
27
+ *
28
+ * These values reach the row and the preview pane, and for a peer's row they are
29
+ * whatever JSON that machine sent — `parseRemoteList` copies the object through
30
+ * without inspecting its fields, so neither the type nor the content is ours to
31
+ * assume. A non-string `spawnedTeam` used to throw out of `teamBadge`, which runs
32
+ * on every row, taking down the whole listing rather than one entry.
33
+ */
34
+ export declare function safeTeamText(value: unknown): string | undefined;
35
+ /** Drop the cached teammate index — for tests that rewrite AGENTS_TEAMS_DIR. */
36
+ export declare function _resetTeamOriginIndex(): void;
37
+ /**
38
+ * Attach `teamOrigin` to every team-spawned row in `sessions`, from the shared
39
+ * teammate index rather than a stat per row.
40
+ */
41
+ export declare function enrichTeamOrigins(sessions: SessionMeta[]): SessionMeta[];
23
42
  /** Result of splitting sessions into visible and hidden (team-origin) groups. */
24
43
  export interface FilterResult {
25
44
  visible: SessionMeta[];
@@ -9,6 +9,7 @@
9
9
  import * as fs from 'fs';
10
10
  import * as os from 'os';
11
11
  import * as path from 'path';
12
+ import { sanitizeForTerminal } from './parse.js';
12
13
  import { getTeamsAgentsDir } from '../state.js';
13
14
  const HOME = os.homedir();
14
15
  // Default path; tests can override via AGENTS_TEAMS_DIR env var.
@@ -21,33 +22,121 @@ function teamsAgentsDir() {
21
22
  *
22
23
  * Primary signal is `session.isTeamOrigin`, captured at scan time from the
23
24
  * JSONL `entrypoint` field ('sdk-cli' for team spawns, 'cli' for real CLI).
24
- * When a team meta.json exists we additionally enrich with handle/mode — but
25
- * its absence no longer demotes a session: older team runs whose meta dir
26
- * was cleaned up still get recognized via the entrypoint flag.
25
+ * When a team meta.json exists we additionally enrich with handle/mode/team and
26
+ * the orchestrator that spawned it but its absence no longer demotes a
27
+ * session: older team runs whose meta dir was cleaned up still get recognized
28
+ * via the entrypoint flag.
27
29
  *
28
30
  * Returns the TeamOrigin metadata when the session is team-origin, or null
29
31
  * when it is a normal interactive session.
30
32
  */
31
33
  export function classifyTeamSession(session) {
32
- const metaPath = path.join(teamsAgentsDir(), session.id, 'meta.json');
33
- if (fs.existsSync(metaPath)) {
34
- try {
35
- const raw = fs.readFileSync(metaPath, 'utf-8');
36
- const meta = JSON.parse(raw);
37
- const name = typeof meta.name === 'string' && meta.name ? meta.name : undefined;
38
- const handle = name ?? session.id.slice(0, 8);
39
- const mode = typeof meta.mode === 'string' ? meta.mode : undefined;
40
- return { handle, mode };
41
- }
42
- catch {
43
- return { handle: session.id.slice(0, 8) };
44
- }
45
- }
34
+ const origin = teamOriginIndex().get(session.id);
35
+ if (origin)
36
+ return origin;
46
37
  if (session.isTeamOrigin) {
47
38
  return { handle: session.id.slice(0, 8) };
48
39
  }
49
40
  return null;
50
41
  }
42
+ /**
43
+ * Parse one teammate `meta.json` into a {@link TeamOrigin}. Degrades to a bare
44
+ * handle when the file is unreadable or malformed — a teammate whose record we
45
+ * can't parse is still a teammate.
46
+ */
47
+ function readTeamOrigin(metaPath, agentId) {
48
+ try {
49
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
50
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
51
+ return {
52
+ origin: {
53
+ handle: str(meta.name) ?? agentId.slice(0, 8),
54
+ mode: str(meta.mode),
55
+ team: str(meta.task_name),
56
+ parentSessionId: str(meta.parent_session_id),
57
+ },
58
+ sessionId: str(meta.remote_session_id),
59
+ };
60
+ }
61
+ catch {
62
+ return { origin: { handle: agentId.slice(0, 8) } };
63
+ }
64
+ }
65
+ /**
66
+ * A team name / handle as it is safe to render: a real string, terminal escapes
67
+ * stripped, or undefined.
68
+ *
69
+ * These values reach the row and the preview pane, and for a peer's row they are
70
+ * whatever JSON that machine sent — `parseRemoteList` copies the object through
71
+ * without inspecting its fields, so neither the type nor the content is ours to
72
+ * assume. A non-string `spawnedTeam` used to throw out of `teamBadge`, which runs
73
+ * on every row, taking down the whole listing rather than one entry.
74
+ */
75
+ export function safeTeamText(value) {
76
+ if (typeof value !== 'string' || value === '')
77
+ return undefined;
78
+ return sanitizeForTerminal(value);
79
+ }
80
+ /** Cached teammate index; the directory is small (teams GC it after 7 days). */
81
+ let originIndexCache = null;
82
+ /** Drop the cached teammate index — for tests that rewrite AGENTS_TEAMS_DIR. */
83
+ export function _resetTeamOriginIndex() {
84
+ originIndexCache = null;
85
+ }
86
+ /**
87
+ * Every teammate record, keyed by the session ids it can be reached under.
88
+ *
89
+ * A teammate's directory name is its **agent id**, which is only sometimes the
90
+ * id of the transcript it produced: the harness mints its own session id, and
91
+ * the spawn records it separately as `remote_session_id`. Keying the lookup on
92
+ * the directory name alone therefore missed most teammates — on a live box, 14
93
+ * of 16 records were reachable only by `remote_session_id` — so a teammate row
94
+ * could not name its team however good the record was. Both keys are registered.
95
+ *
96
+ * Read once per process rather than per row: the old per-session `existsSync` +
97
+ * `readFileSync` cost a pair of syscalls for every row in the pool, which the
98
+ * interactive browser re-pays on each hotkey.
99
+ */
100
+ function teamOriginIndex() {
101
+ if (originIndexCache)
102
+ return originIndexCache;
103
+ const dir = teamsAgentsDir();
104
+ const index = new Map();
105
+ let entries;
106
+ try {
107
+ entries = fs.readdirSync(dir);
108
+ }
109
+ catch {
110
+ entries = [];
111
+ }
112
+ for (const agentId of entries) {
113
+ const metaPath = path.join(dir, agentId, 'meta.json');
114
+ if (!fs.existsSync(metaPath))
115
+ continue;
116
+ const { origin, sessionId } = readTeamOrigin(metaPath, agentId);
117
+ index.set(agentId, origin);
118
+ if (sessionId)
119
+ index.set(sessionId, origin);
120
+ }
121
+ originIndexCache = index;
122
+ return index;
123
+ }
124
+ /**
125
+ * Attach `teamOrigin` to every team-spawned row in `sessions`, from the shared
126
+ * teammate index rather than a stat per row.
127
+ */
128
+ export function enrichTeamOrigins(sessions) {
129
+ const index = teamOriginIndex();
130
+ return sessions.map((session) => {
131
+ // A peer's rows are classified on the peer (its meta.json is on its disk, not
132
+ // ours) and ride across in the --json fan-out already populated. Re-deriving
133
+ // here would find no local record and downgrade a named teammate to a bare id.
134
+ if (session.teamOrigin)
135
+ return session;
136
+ const origin = index.get(session.id) ?? (session.isTeamOrigin ? { handle: session.id.slice(0, 8) } : null);
137
+ return origin ? { ...session, teamOrigin: origin } : session;
138
+ });
139
+ }
51
140
  /**
52
141
  * Split `sessions` into visible and hidden (team-origin) groups.
53
142
  * When `showTeams` is true every session is visible and `teamOrigin` is
@@ -88,6 +88,14 @@ export interface TeamOrigin {
88
88
  handle?: string;
89
89
  /** Agent mode: 'plan', 'edit', 'auto', or 'skip' ('full' accepted as legacy alias for 'skip'). */
90
90
  mode?: string;
91
+ /** The team this teammate belongs to (`task_name` in its meta.json). */
92
+ team?: string;
93
+ /**
94
+ * The orchestrator session that spawned this teammate (`parent_session_id`).
95
+ * Absent for a team started outside any agent session, and for teammates whose
96
+ * meta dir has aged past the teams cleanup window.
97
+ */
98
+ parentSessionId?: string;
91
99
  }
92
100
  /** Lightweight metadata for a discovered session, used in listings and pickers. */
93
101
  export interface SessionMeta {
@@ -23,6 +23,23 @@ export declare function loginHint(agentId: AgentId): string;
23
23
  * agents (the ones the feature is for), so the resume's `forceInteractive` flag is
24
24
  * consulted directly.
25
25
  */
26
+ /**
27
+ * Is a Claude run on this box going to authenticate from an ambient
28
+ * `CLAUDE_CODE_OAUTH_TOKEN` rather than a per-version login?
29
+ *
30
+ * `AccountInfo.signedIn` is `!!email` read from a version home's `.claude.json`
31
+ * (agents.ts), so a version with no account written there reports signed-out —
32
+ * even though Claude Code authenticates fine from the env token and the run
33
+ * succeeds. Rendering that as "logged out" sends people hunting a login that is
34
+ * not missing (a real fleet incident: every version on a box read as locked out
35
+ * while all of them answered a live prompt).
36
+ *
37
+ * It is also the more useful warning: an ambient token is ONE account, so every
38
+ * version on the box resolves to it and balanced rotation across them rotates
39
+ * nothing. `env` is a parameter so the branch is testable without mutating the
40
+ * process environment.
41
+ */
42
+ export declare function ambientClaudeToken(agentId: AgentId | string, env?: NodeJS.ProcessEnv): boolean;
26
43
  export declare function shouldCheckLoginBeforeLaunch(o: {
27
44
  interactive?: boolean;
28
45
  forceInteractive?: boolean;
@@ -46,6 +46,25 @@ export function loginHint(agentId) {
46
46
  * agents (the ones the feature is for), so the resume's `forceInteractive` flag is
47
47
  * consulted directly.
48
48
  */
49
+ /**
50
+ * Is a Claude run on this box going to authenticate from an ambient
51
+ * `CLAUDE_CODE_OAUTH_TOKEN` rather than a per-version login?
52
+ *
53
+ * `AccountInfo.signedIn` is `!!email` read from a version home's `.claude.json`
54
+ * (agents.ts), so a version with no account written there reports signed-out —
55
+ * even though Claude Code authenticates fine from the env token and the run
56
+ * succeeds. Rendering that as "logged out" sends people hunting a login that is
57
+ * not missing (a real fleet incident: every version on a box read as locked out
58
+ * while all of them answered a live prompt).
59
+ *
60
+ * It is also the more useful warning: an ambient token is ONE account, so every
61
+ * version on the box resolves to it and balanced rotation across them rotates
62
+ * nothing. `env` is a parameter so the branch is testable without mutating the
63
+ * process environment.
64
+ */
65
+ export function ambientClaudeToken(agentId, env = process.env) {
66
+ return agentId === 'claude' && (env.CLAUDE_CODE_OAUTH_TOKEN ?? '').trim().length > 0;
67
+ }
49
68
  export function shouldCheckLoginBeforeLaunch(o) {
50
69
  if (o.json || o.quiet || o.authCheckDisabled || o.rotated)
51
70
  return false;