@phnx-labs/agents-cli 1.20.86 → 1.20.88

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 (82) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +3 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +0 -19
  5. package/dist/commands/doctor.js +219 -305
  6. package/dist/commands/exec.js +7 -19
  7. package/dist/commands/inspect.js +3 -5
  8. package/dist/commands/routines.js +2 -2
  9. package/dist/commands/sessions-browser.d.ts +18 -0
  10. package/dist/commands/sessions-browser.js +126 -24
  11. package/dist/commands/sessions-picker.d.ts +21 -8
  12. package/dist/commands/sessions-picker.js +83 -7
  13. package/dist/commands/sessions.d.ts +19 -0
  14. package/dist/commands/sessions.js +148 -18
  15. package/dist/commands/ssh.js +62 -4
  16. package/dist/commands/teams-picker.d.ts +2 -0
  17. package/dist/commands/teams-picker.js +2 -1
  18. package/dist/commands/teams.d.ts +4 -1
  19. package/dist/commands/teams.js +106 -70
  20. package/dist/commands/usage.d.ts +3 -2
  21. package/dist/commands/usage.js +2 -9
  22. package/dist/commands/view.js +14 -3
  23. package/dist/index.js +31 -1
  24. package/dist/lib/agents.d.ts +31 -1
  25. package/dist/lib/agents.js +55 -0
  26. package/dist/lib/claude-account-token.d.ts +12 -0
  27. package/dist/lib/claude-account-token.js +63 -0
  28. package/dist/lib/command-skills.d.ts +10 -0
  29. package/dist/lib/command-skills.js +14 -0
  30. package/dist/lib/commands.js +19 -1
  31. package/dist/lib/daemon.js +17 -2
  32. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  33. package/dist/lib/devices/doctor-findings.js +893 -0
  34. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  35. package/dist/lib/devices/fleet-divergence.js +34 -10
  36. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  37. package/dist/lib/devices/fleet-inventory.js +56 -8
  38. package/dist/lib/devices/registry.d.ts +25 -0
  39. package/dist/lib/devices/registry.js +82 -1
  40. package/dist/lib/events.d.ts +8 -1
  41. package/dist/lib/events.js +13 -0
  42. package/dist/lib/exec.d.ts +14 -3
  43. package/dist/lib/exec.js +51 -9
  44. package/dist/lib/format.d.ts +7 -0
  45. package/dist/lib/format.js +11 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  47. package/dist/lib/project-resources.js +34 -20
  48. package/dist/lib/runner.d.ts +14 -1
  49. package/dist/lib/runner.js +37 -8
  50. package/dist/lib/sandbox.d.ts +2 -0
  51. package/dist/lib/sandbox.js +38 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  54. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  55. package/dist/lib/secrets/rc-hygiene.js +0 -24
  56. package/dist/lib/session/active.d.ts +6 -6
  57. package/dist/lib/session/active.js +6 -6
  58. package/dist/lib/session/db.d.ts +21 -0
  59. package/dist/lib/session/db.js +45 -4
  60. package/dist/lib/session/discover.d.ts +5 -0
  61. package/dist/lib/session/discover.js +137 -1
  62. package/dist/lib/session/parse.d.ts +2 -0
  63. package/dist/lib/session/parse.js +76 -37
  64. package/dist/lib/session/remote-list.d.ts +7 -0
  65. package/dist/lib/session/remote-list.js +8 -4
  66. package/dist/lib/session/state.js +69 -2
  67. package/dist/lib/session/sync/agents.js +0 -0
  68. package/dist/lib/session/team-filter.d.ts +22 -3
  69. package/dist/lib/session/team-filter.js +106 -17
  70. package/dist/lib/session/types.d.ts +9 -1
  71. package/dist/lib/session/types.js +1 -1
  72. package/dist/lib/signin-badge.d.ts +17 -0
  73. package/dist/lib/signin-badge.js +19 -0
  74. package/dist/lib/staleness/detectors/commands.js +14 -5
  75. package/dist/lib/staleness/types.d.ts +2 -0
  76. package/dist/lib/staleness/writers/commands.js +13 -7
  77. package/dist/lib/state.d.ts +2 -0
  78. package/dist/lib/state.js +2 -0
  79. package/dist/lib/usage.d.ts +72 -1
  80. package/dist/lib/usage.js +22 -87
  81. package/dist/lib/versions.js +30 -13
  82. package/package.json +1 -1
@@ -23,7 +23,7 @@ import { getConfigSymlinkVersion } from '../shims.js';
23
23
  import { SESSION_AGENTS } from './types.js';
24
24
  import { deriveShortId } from './short-id.js';
25
25
  import { extractSessionTopic } from './prompt.js';
26
- import { parseAntigravity } from './parse.js';
26
+ import { parseAntigravity, parseCursor } from './parse.js';
27
27
  import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
28
28
  import { costOfUsage } from '../pricing/index.js';
29
29
  import { machineForSessionFile } from './origin-machine.js';
@@ -221,6 +221,7 @@ function dispatchAgentScan(agent, onProgress) {
221
221
  case 'kimi': return scanKimiIncremental(onProgress);
222
222
  case 'droid': return scanDroidIncremental(onProgress);
223
223
  case 'grok': return scanGrokIncremental(onProgress);
224
+ case 'cursor': return scanCursorIncremental(onProgress);
224
225
  default: return Promise.resolve();
225
226
  }
226
227
  }
@@ -660,6 +661,7 @@ const SESSION_ROOT_SPECS = [
660
661
  { agent: 'droid', subdir: 'sessions' },
661
662
  { agent: 'kimi', subdir: 'sessions' },
662
663
  { agent: 'grok', subdir: 'sessions' },
664
+ { agent: 'cursor', subdir: 'projects' },
663
665
  ];
664
666
  function sessionRootSubdir(agent) {
665
667
  return SESSION_ROOT_SPECS.find((spec) => spec.agent === agent)?.subdir ?? null;
@@ -752,6 +754,13 @@ async function readRoutineArchiveMeta(agent, filePath) {
752
754
  const result = await readCodexMeta(filePath);
753
755
  return result ? { ...result, meta: decorateRoutineSession(result.meta, info) } : null;
754
756
  }
757
+ if (agent === 'cursor') {
758
+ // PR #1723 archives Cursor routine transcripts; preserve version resolution
759
+ // when this reader consumes those archives.
760
+ const currentVersion = await getCurrentAgentVersion('cursor');
761
+ const result = readCursorMeta(filePath, currentVersion);
762
+ return result ? { ...result, meta: decorateRoutineSession(result.meta, info) } : null;
763
+ }
755
764
  return null;
756
765
  }
757
766
  async function scanRoutineArchivesIncremental(agent, onProgress) {
@@ -3601,6 +3610,133 @@ function sumKnownNumbers(values) {
3601
3610
  // Time range parsing
3602
3611
  // ---------------------------------------------------------------------------
3603
3612
  // ---------------------------------------------------------------------------
3613
+ // Cursor
3614
+ // ---------------------------------------------------------------------------
3615
+ // Cursor writes the conversation to
3616
+ // projects/<encoded-cwd>/agent-transcripts/<uuid>/<uuid>.jsonl and metadata to
3617
+ // chats/<workspace-hash>/<uuid>/meta.json. Discovery deliberately starts from
3618
+ // transcripts, then joins metadata by UUID: chat directories with no transcript
3619
+ // are empty or abandoned sessions and must not become broken zero-event rows.
3620
+ // Routine archives may contain only the transcript; those remain browsable with
3621
+ // file timestamps and without guessed cwd/title metadata.
3622
+ /** Incrementally re-scan changed Cursor transcript files and upsert into the DB. */
3623
+ async function scanCursorIncremental(onProgress) {
3624
+ const currentVersion = await getCurrentAgentVersion('cursor');
3625
+ const prestat = [];
3626
+ for (const projectsDir of getAgentSessionDirs('cursor', 'projects')) {
3627
+ collectCursorTranscripts(projectsDir, prestat);
3628
+ }
3629
+ const changed = filterChangedEntries(prestat);
3630
+ if (changed.length === 0)
3631
+ return;
3632
+ onProgress?.({ agent: 'cursor', parsed: 0, total: changed.length });
3633
+ const entries = [];
3634
+ const touched = [];
3635
+ const seen = new Set();
3636
+ let parsed = 0;
3637
+ for (const { filePath, scan } of changed) {
3638
+ try {
3639
+ const result = readCursorMeta(filePath, currentVersion);
3640
+ if (result && !seen.has(result.meta.id)) {
3641
+ seen.add(result.meta.id);
3642
+ entries.push({ meta: result.meta, content: result.content, scan });
3643
+ }
3644
+ else {
3645
+ touched.push({ filePath, scan });
3646
+ }
3647
+ }
3648
+ catch {
3649
+ touched.push({ filePath, scan });
3650
+ }
3651
+ parsed++;
3652
+ onProgress?.({ agent: 'cursor', parsed, total: changed.length });
3653
+ }
3654
+ upsertSessionsBatch(entries);
3655
+ recordScans(touched);
3656
+ }
3657
+ function collectCursorTranscripts(projectsDir, out) {
3658
+ let projectNames;
3659
+ try {
3660
+ projectNames = fs.readdirSync(projectsDir);
3661
+ }
3662
+ catch {
3663
+ return;
3664
+ }
3665
+ for (const projectName of projectNames) {
3666
+ const transcriptsDir = path.join(projectsDir, projectName, 'agent-transcripts');
3667
+ for (const f of walkForFilesWithStat(transcriptsDir, '.jsonl', 100_000)) {
3668
+ const sessionId = path.basename(path.dirname(f.path));
3669
+ if (path.basename(f.path) !== `${sessionId}.jsonl`)
3670
+ continue;
3671
+ out.push({ filePath: f.path, fileMtimeMs: f.mtimeMs, fileSize: f.size });
3672
+ }
3673
+ }
3674
+ }
3675
+ function readCursorChatMeta(filePath, sessionId) {
3676
+ const projectDir = path.dirname(path.dirname(path.dirname(filePath)));
3677
+ const projectsDir = path.dirname(projectDir);
3678
+ const chatsDir = path.join(path.dirname(projectsDir), 'chats');
3679
+ let workspaceHashes;
3680
+ try {
3681
+ workspaceHashes = fs.readdirSync(chatsDir);
3682
+ }
3683
+ catch {
3684
+ return undefined;
3685
+ }
3686
+ for (const workspaceHash of workspaceHashes) {
3687
+ const metaPath = path.join(chatsDir, workspaceHash, sessionId, 'meta.json');
3688
+ try {
3689
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
3690
+ }
3691
+ catch {
3692
+ // This workspace hash does not own the session, or its metadata is unreadable.
3693
+ }
3694
+ }
3695
+ return undefined;
3696
+ }
3697
+ /** Parse one Cursor transcript and enrich it with the matching chat meta.json. */
3698
+ export function readCursorMeta(filePath, currentVersion) {
3699
+ const sessionId = path.basename(filePath).replace(/\.jsonl$/, '');
3700
+ if (!sessionId || path.basename(path.dirname(filePath)) !== sessionId)
3701
+ return null;
3702
+ const events = parseCursor(filePath);
3703
+ if (events.length === 0)
3704
+ return null;
3705
+ const chatMeta = readCursorChatMeta(filePath, sessionId);
3706
+ const stat = safeStatSync(filePath);
3707
+ const createdAtMs = typeof chatMeta?.createdAtMs === 'number' ? chatMeta.createdAtMs : undefined;
3708
+ const updatedAtMs = typeof chatMeta?.updatedAtMs === 'number' ? chatMeta.updatedAtMs : undefined;
3709
+ const timestamp = createdAtMs !== undefined
3710
+ ? new Date(createdAtMs).toISOString()
3711
+ : stat ? stat.mtime.toISOString() : new Date().toISOString();
3712
+ const lastActivity = updatedAtMs !== undefined
3713
+ ? new Date(updatedAtMs).toISOString()
3714
+ : stat ? stat.mtime.toISOString() : timestamp;
3715
+ const cwd = normalizeCwd(typeof chatMeta?.cwd === 'string' ? chatMeta.cwd : '');
3716
+ const userTexts = events
3717
+ .filter((event) => event.type === 'message' && event.role === 'user' && event.content)
3718
+ .map((event) => event.content);
3719
+ const firstUserText = userTexts[0];
3720
+ const title = typeof chatMeta?.title === 'string' && chatMeta.title.trim()
3721
+ ? chatMeta.title.trim()
3722
+ : undefined;
3723
+ const meta = {
3724
+ id: sessionId,
3725
+ shortId: deriveShortId(sessionId),
3726
+ agent: 'cursor',
3727
+ timestamp,
3728
+ lastActivity,
3729
+ project: cwd ? path.basename(cwd) : undefined,
3730
+ cwd,
3731
+ filePath,
3732
+ version: resolveSessionVersion('cursor', filePath, undefined, currentVersion),
3733
+ topic: firstUserText ? extractSessionTopic(firstUserText) : undefined,
3734
+ label: title,
3735
+ messageCount: events.filter((event) => event.type === 'message').length,
3736
+ };
3737
+ return { meta, content: userTexts.join('\n') };
3738
+ }
3739
+ // ---------------------------------------------------------------------------
3604
3740
  // Kimi
3605
3741
  // ---------------------------------------------------------------------------
3606
3742
  // Kimi stores sessions under ~/.kimi-code/sessions/<workdir_hash>/session_<uuid>/.
@@ -109,6 +109,8 @@ export declare function parseRush(filePath: string): SessionEvent[];
109
109
  export declare function parseHermes(filePath: string): SessionEvent[];
110
110
  /** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
111
111
  export declare function parseKimi(filePath: string): SessionEvent[];
112
+ /** Parse Cursor's Anthropic-shaped message JSONL transcript. */
113
+ export declare function parseCursor(filePath: string): SessionEvent[];
112
114
  /**
113
115
  * Parse a Droid (Factory) JSONL session file into normalized events. Droid
114
116
  * wraps each turn in a `{type:'message', message:{role, content, modelId}}`
@@ -154,6 +154,9 @@ export function parseSession(filePath, agent) {
154
154
  case 'droid':
155
155
  events = parseDroid(filePath);
156
156
  break;
157
+ case 'cursor':
158
+ events = parseCursor(filePath);
159
+ break;
157
160
  }
158
161
  // Chokepoint: every string field that originated in an untrusted session
159
162
  // file gets stripped of terminal escapes here, so renderers downstream can
@@ -194,6 +197,8 @@ export function detectAgent(filePath) {
194
197
  return 'kimi';
195
198
  if (filePath.includes('/.factory/') || filePath.includes('\\.factory\\'))
196
199
  return 'droid';
200
+ if (filePath.includes('/.cursor/') || filePath.includes('\\.cursor\\'))
201
+ return 'cursor';
197
202
  // Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
198
203
  const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
199
204
  if (cloudMatch)
@@ -1617,20 +1622,45 @@ export function parseKimi(filePath) {
1617
1622
  return events;
1618
1623
  }
1619
1624
  // ---------------------------------------------------------------------------
1620
- // Droid (Factory) parser
1625
+ // Cursor and Droid (Factory) parsers
1621
1626
  // ---------------------------------------------------------------------------
1627
+ /** Parse Cursor's Anthropic-shaped message JSONL transcript. */
1628
+ export function parseCursor(filePath) {
1629
+ return parseAnthropicMessageJsonl(filePath, 'cursor');
1630
+ }
1622
1631
  /**
1623
- * Parse a Droid (Factory) JSONL session file into normalized events. Droid
1624
- * wraps each turn in a `{type:'message', message:{role, content, modelId}}`
1625
- * envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
1626
- * tool_result), so block handling mirrors the Claude parser.
1632
+ * Cursor stamps the first user turn as
1633
+ * `<timestamp>Sunday, Aug 2, 2026, 3:51 AM (UTC-7)</timestamp>` followed by the
1634
+ * real question in `<user_query>`. `Date.parse` silently DISCARDS the `(UTC-7)`
1635
+ * parenthetical and reads the rest as local time, so the offset has to be applied
1636
+ * by hand — otherwise the recovered instant is wrong on every machine whose zone
1637
+ * differs from the one that wrote the transcript.
1627
1638
  */
1628
- export function parseDroid(filePath) {
1639
+ function parseCursorUserText(text) {
1640
+ const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
1641
+ const stamp = text.match(/<timestamp>\s*([\s\S]*?)\s*<\/timestamp>/)?.[1]?.trim();
1642
+ return { text: (query?.[1] ?? text).trim(), timestamp: parseCursorTimestamp(stamp) };
1643
+ }
1644
+ function parseCursorTimestamp(stamp) {
1645
+ if (!stamp)
1646
+ return undefined;
1647
+ const offset = stamp.match(/\(UTC([+-])(\d{1,2})(?::(\d{2}))?\)/);
1648
+ // Without a declared offset there is no instant to recover -- guessing the
1649
+ // local zone would be worse than falling back to the file mtime.
1650
+ if (!offset)
1651
+ return undefined;
1652
+ const wall = Date.parse(`${stamp.replace(/\s*\(UTC[^)]*\)\s*/, " ").trim()} UTC`);
1653
+ if (Number.isNaN(wall))
1654
+ return undefined;
1655
+ const offsetMs = (Number(offset[2]) * 60 + Number(offset[3] ?? 0)) * 60_000;
1656
+ return new Date(offset[1] === "-" ? wall + offsetMs : wall - offsetMs).toISOString();
1657
+ }
1658
+ function parseAnthropicMessageJsonl(filePath, agent) {
1629
1659
  const content = safeReadSessionFile(filePath);
1630
1660
  const lines = content.split('\n').filter(l => l.trim());
1631
1661
  const events = [];
1632
- // Map tool_use id -> {tool, args} for correlating with tool_result.
1633
1662
  const toolUseMap = new Map();
1663
+ const fallbackTimestamp = fs.statSync(filePath).mtime.toISOString();
1634
1664
  for (const line of lines) {
1635
1665
  let raw;
1636
1666
  try {
@@ -1639,33 +1669,42 @@ export function parseDroid(filePath) {
1639
1669
  catch {
1640
1670
  continue;
1641
1671
  }
1642
- if (raw.type !== 'message')
1672
+ if (raw.type === 'turn_ended' || (agent === 'droid' && raw.type !== 'message'))
1643
1673
  continue;
1644
- const message = raw.message || {};
1645
- const role = message.role === 'user' ? 'user' : 'assistant';
1646
- const timestamp = raw.timestamp || new Date().toISOString();
1674
+ const message = raw.message;
1675
+ const wireRole = agent === 'cursor' ? raw.role : message?.role;
1676
+ if (!message || (agent === 'cursor' && !['user', 'assistant'].includes(wireRole)))
1677
+ continue;
1678
+ const role = wireRole === 'user' ? 'user' : 'assistant';
1679
+ let timestamp = raw.timestamp || fallbackTimestamp;
1647
1680
  const blocks = message.content;
1648
- // Plain-string content (rare) renders as a single message.
1649
1681
  if (typeof blocks === 'string') {
1650
- const text = blocks.trim();
1682
+ const parsed = agent === 'cursor' && role === 'user'
1683
+ ? parseCursorUserText(blocks)
1684
+ : { text: blocks.trim(), timestamp: undefined };
1685
+ const text = parsed.text;
1686
+ timestamp = parsed.timestamp || timestamp;
1651
1687
  if (text)
1652
- events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
1688
+ events.push({ type: 'message', agent, timestamp, role, content: text });
1653
1689
  continue;
1654
1690
  }
1655
1691
  if (!Array.isArray(blocks))
1656
1692
  continue;
1657
1693
  for (const block of blocks) {
1658
1694
  if (block.type === 'text') {
1659
- const text = (block.text || '').trim();
1660
- // Skip injected context blocks (date, skills list) on the first user turn.
1695
+ const parsed = agent === 'cursor' && role === 'user'
1696
+ ? parseCursorUserText(block.text || '')
1697
+ : { text: (block.text || '').trim(), timestamp: undefined };
1698
+ const text = parsed.text;
1699
+ timestamp = parsed.timestamp || timestamp;
1661
1700
  if (text && !(role === 'user' && text.startsWith('<system-reminder>'))) {
1662
- events.push({ type: 'message', agent: 'droid', timestamp, role, content: text });
1701
+ events.push({ type: 'message', agent, timestamp, role, content: text });
1663
1702
  }
1664
1703
  }
1665
1704
  else if (block.type === 'thinking') {
1666
1705
  const thinkingText = (block.thinking || '').trim();
1667
1706
  if (thinkingText)
1668
- events.push({ type: 'thinking', agent: 'droid', timestamp, content: thinkingText });
1707
+ events.push({ type: 'thinking', agent, timestamp, content: thinkingText });
1669
1708
  }
1670
1709
  else if (block.type === 'tool_use') {
1671
1710
  const toolName = block.name || 'unknown';
@@ -1674,38 +1713,29 @@ export function parseDroid(filePath) {
1674
1713
  toolUseMap.set(block.id, { tool: toolName, args: toolInput });
1675
1714
  events.push({
1676
1715
  type: 'tool_use',
1677
- agent: 'droid',
1716
+ agent,
1678
1717
  timestamp,
1679
1718
  tool: toolName,
1680
1719
  args: toolInput,
1681
1720
  path: toolInput.file_path || toolInput.path || undefined,
1682
- command: (toolName === 'Bash' || toolName === 'Execute') ? toolInput.command : undefined,
1721
+ command: (toolName === 'Bash' || toolName === 'Execute' || toolName === 'Shell') ? toolInput.command : undefined,
1683
1722
  });
1684
1723
  }
1685
1724
  else if (block.type === 'tool_result') {
1686
1725
  const toolId = block.tool_use_id;
1687
1726
  const toolInfo = toolId ? toolUseMap.get(toolId) : undefined;
1688
1727
  const isError = block.is_error === true;
1689
- let output = '';
1690
- if (typeof block.content === 'string') {
1691
- output = block.content;
1692
- }
1693
- else if (Array.isArray(block.content)) {
1694
- output = block.content
1695
- .filter((c) => c.type === 'text')
1696
- .map((c) => c.text || '')
1697
- .join('\n');
1698
- }
1728
+ const output = typeof block.content === 'string'
1729
+ ? block.content
1730
+ : Array.isArray(block.content)
1731
+ ? block.content.filter((c) => c.type === 'text').map((c) => c.text || '').join('\n')
1732
+ : '';
1699
1733
  if (isError) {
1700
- events.push({ type: 'error', agent: 'droid', timestamp, tool: toolInfo?.tool, content: output || 'Tool execution failed' });
1734
+ events.push({ type: 'error', agent, timestamp, tool: toolInfo?.tool, content: output || 'Tool execution failed' });
1701
1735
  }
1702
1736
  else {
1703
1737
  events.push({
1704
- type: 'tool_result',
1705
- agent: 'droid',
1706
- timestamp,
1707
- tool: toolInfo?.tool,
1708
- success: true,
1738
+ type: 'tool_result', agent, timestamp, tool: toolInfo?.tool, success: true,
1709
1739
  output: output.length > 500 ? output.slice(0, 497) + '...' : output,
1710
1740
  });
1711
1741
  }
@@ -1715,9 +1745,18 @@ export function parseDroid(filePath) {
1715
1745
  else if (block.type === 'image') {
1716
1746
  const source = block.source || {};
1717
1747
  const sizeBytes = source.type === 'base64' ? Math.ceil((source.data?.length || 0) * 0.75) : 0;
1718
- events.push(normalizedAttachmentEvent('droid', timestamp, block, source, 'image/png', sizeBytes));
1748
+ events.push(normalizedAttachmentEvent(agent, timestamp, block, source, 'image/png', sizeBytes));
1719
1749
  }
1720
1750
  }
1721
1751
  }
1722
1752
  return events;
1723
1753
  }
1754
+ /**
1755
+ * Parse a Droid (Factory) JSONL session file into normalized events. Droid
1756
+ * wraps each turn in a `{type:'message', message:{role, content, modelId}}`
1757
+ * envelope; the content blocks are Anthropic-shaped (text/thinking/tool_use/
1758
+ * tool_result), so block handling mirrors the Claude parser.
1759
+ */
1760
+ export function parseDroid(filePath) {
1761
+ return parseAnthropicMessageJsonl(filePath, 'droid');
1762
+ }
@@ -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
@@ -201,12 +201,66 @@ const GH_PR_CREATE_RE = /\bgh\s+pr\s+(?:create|new)\b/;
201
201
  const GH_ISSUE_CREATE_RE = /\bgh\s+issue\s+create\b/;
202
202
  /** A created GitHub issue URL (…/issues/123) in tool-result output. */
203
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+)`;
204
230
  /**
205
231
  * `agents teams create <name>` / `agents teams add <team> …` (also the `ag` alias).
206
232
  * The team NAME is the first bareword after the sub-verb, skipping any flags. This
207
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.
208
241
  */
209
- 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']);
210
264
  /** Collapse to a single trimmed line for a one-row preview cell. */
211
265
  function oneLine(s) {
212
266
  return s.replace(/\s+/g, ' ').trim();
@@ -259,7 +313,20 @@ export function detectSpawnedTeam(command) {
259
313
  if (!command)
260
314
  return undefined;
261
315
  const m = command.match(TEAMS_SPAWN_RE);
262
- 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;
263
330
  }
264
331
  /**
265
332
  * True when a tool_use call CREATES a tracker ticket — a Linear MCP `create_issue`
Binary file
@@ -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[];