@phnx-labs/agents-cli 1.22.45 → 1.22.47

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 (177) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/LICENSE +102 -182
  3. package/README.md +54 -17
  4. package/dist/cli/command-registry.d.ts +2 -1
  5. package/dist/cli/command-registry.js +6 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.d.ts +2 -0
  8. package/dist/commands/auth.js +229 -0
  9. package/dist/commands/browser-sessions-picker.js +9 -7
  10. package/dist/commands/browser.js +266 -15
  11. package/dist/commands/config.js +26 -4
  12. package/dist/commands/cost.d.ts +1 -1
  13. package/dist/commands/detach.d.ts +13 -0
  14. package/dist/commands/detach.js +11 -8
  15. package/dist/commands/exec.js +55 -38
  16. package/dist/commands/feed-watch.d.ts +2 -0
  17. package/dist/commands/feed-watch.js +28 -0
  18. package/dist/commands/feed.js +27 -0
  19. package/dist/commands/feedback.js +3 -19
  20. package/dist/commands/insights.d.ts +1 -1
  21. package/dist/commands/insights.js +2 -2
  22. package/dist/commands/lease.js +3 -3
  23. package/dist/commands/routines.js +1 -1
  24. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  25. package/dist/commands/sessions-picker-factory.js +1 -1
  26. package/dist/commands/sessions-stop.d.ts +24 -0
  27. package/dist/commands/sessions-stop.js +66 -0
  28. package/dist/commands/sessions-trace.d.ts +82 -0
  29. package/dist/commands/sessions-trace.js +387 -0
  30. package/dist/commands/sessions.js +4 -0
  31. package/dist/commands/ssh.d.ts +15 -0
  32. package/dist/commands/ssh.js +194 -16
  33. package/dist/commands/teams.js +34 -2
  34. package/dist/commands/view.d.ts +2 -0
  35. package/dist/commands/view.js +19 -32
  36. package/dist/commands/watchdog.js +9 -14
  37. package/dist/lib/account-capabilities.d.ts +4 -0
  38. package/dist/lib/account-capabilities.js +13 -9
  39. package/dist/lib/account-registry.d.ts +2 -0
  40. package/dist/lib/account-registry.js +30 -2
  41. package/dist/lib/accounting/rotate.d.ts +9 -0
  42. package/dist/lib/accounting/rotate.js +12 -2
  43. package/dist/lib/accounting/usage.d.ts +92 -6
  44. package/dist/lib/accounting/usage.js +329 -75
  45. package/dist/lib/agent-modes.js +29 -1
  46. package/dist/lib/agent-spec/agents.d.ts +4 -3
  47. package/dist/lib/agent-spec/agents.js +20 -7
  48. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  49. package/dist/lib/analytics/mix-commands.js +2 -2
  50. package/dist/lib/auth-health.d.ts +13 -3
  51. package/dist/lib/auth-health.js +50 -8
  52. package/dist/lib/browser/devices.d.ts +19 -0
  53. package/dist/lib/browser/devices.js +23 -0
  54. package/dist/lib/browser/ipc.d.ts +1 -1
  55. package/dist/lib/browser/ipc.js +33 -7
  56. package/dist/lib/browser/profiles.d.ts +106 -1
  57. package/dist/lib/browser/profiles.js +300 -17
  58. package/dist/lib/browser/remote-control.d.ts +26 -2
  59. package/dist/lib/browser/remote-control.js +33 -3
  60. package/dist/lib/browser/runtime-state.d.ts +9 -0
  61. package/dist/lib/browser/runtime-state.js +24 -8
  62. package/dist/lib/browser/service.d.ts +37 -0
  63. package/dist/lib/browser/service.js +78 -20
  64. package/dist/lib/browser/sessions-list.d.ts +1 -3
  65. package/dist/lib/browser/sessions-list.js +3 -16
  66. package/dist/lib/browser/types.d.ts +12 -1
  67. package/dist/lib/cli-resources.js +17 -15
  68. package/dist/lib/codex-policy.d.ts +11 -1
  69. package/dist/lib/codex-policy.js +20 -5
  70. package/dist/lib/config-keys.d.ts +4 -3
  71. package/dist/lib/config-keys.js +17 -7
  72. package/dist/lib/daemon/runner.js +15 -2
  73. package/dist/lib/daemon-ticks.js +2 -2
  74. package/dist/lib/device-config.d.ts +25 -8
  75. package/dist/lib/device-config.js +90 -17
  76. package/dist/lib/devices/config-migration.d.ts +7 -0
  77. package/dist/lib/devices/config-migration.js +67 -3
  78. package/dist/lib/devices/harness-inventory.js +20 -3
  79. package/dist/lib/devices/health.d.ts +27 -2
  80. package/dist/lib/devices/health.js +39 -9
  81. package/dist/lib/devices/interactive-host.d.ts +15 -0
  82. package/dist/lib/devices/interactive-host.js +53 -0
  83. package/dist/lib/devices/registry.d.ts +61 -4
  84. package/dist/lib/devices/registry.js +94 -38
  85. package/dist/lib/devices/stats-cache.d.ts +6 -0
  86. package/dist/lib/devices/stats-cache.js +10 -2
  87. package/dist/lib/exec.d.ts +25 -3
  88. package/dist/lib/exec.js +75 -22
  89. package/dist/lib/feed/answer.d.ts +29 -0
  90. package/dist/lib/feed/answer.js +145 -0
  91. package/dist/lib/feed/attention.d.ts +83 -0
  92. package/dist/lib/feed/attention.js +0 -0
  93. package/dist/lib/feed/feed.d.ts +109 -0
  94. package/dist/lib/feed/feed.js +154 -3
  95. package/dist/lib/feed/pr-status.d.ts +14 -0
  96. package/dist/lib/feed/pr-status.js +47 -0
  97. package/dist/lib/feed/watch.d.ts +59 -0
  98. package/dist/lib/feed/watch.js +180 -0
  99. package/dist/lib/fleet/capture.js +6 -0
  100. package/dist/lib/fleet/remote-login.js +8 -3
  101. package/dist/lib/fleet/types.d.ts +17 -0
  102. package/dist/lib/harness/adapter.d.ts +11 -0
  103. package/dist/lib/harness/adapters/claude.js +38 -24
  104. package/dist/lib/harness/adapters/codex.js +2 -4
  105. package/dist/lib/hooks/cache.js +8 -0
  106. package/dist/lib/hooks/match.d.ts +3 -0
  107. package/dist/lib/hooks/match.js +25 -0
  108. package/dist/lib/hosts/passthrough.js +16 -0
  109. package/dist/lib/hosts/registry.js +7 -0
  110. package/dist/lib/identity/client.d.ts +53 -0
  111. package/dist/lib/identity/client.js +106 -0
  112. package/dist/lib/identity/index.d.ts +115 -0
  113. package/dist/lib/identity/index.js +82 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  116. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  117. package/dist/lib/menubar/install-menubar.js +26 -2
  118. package/dist/lib/open-url.d.ts +60 -2
  119. package/dist/lib/open-url.js +167 -12
  120. package/dist/lib/probe.d.ts +8 -0
  121. package/dist/lib/probe.js +105 -0
  122. package/dist/lib/refresh-coordinator.d.ts +1 -1
  123. package/dist/lib/refresh-coordinator.js +1 -1
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  126. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  127. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  129. package/dist/lib/session/active.d.ts +67 -0
  130. package/dist/lib/session/active.js +69 -1
  131. package/dist/lib/session/db.js +4 -2
  132. package/dist/lib/session/discover.d.ts +2 -0
  133. package/dist/lib/session/discover.js +12 -6
  134. package/dist/lib/session/prompt.d.ts +35 -0
  135. package/dist/lib/session/prompt.js +56 -0
  136. package/dist/lib/session/remote/watch.d.ts +1 -1
  137. package/dist/lib/session/remote/watch.js +6 -1
  138. package/dist/lib/session/render.js +7 -3
  139. package/dist/lib/session/session-cache.js +5 -1
  140. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  141. package/dist/lib/session/trajectory-compare.js +116 -0
  142. package/dist/lib/session/trajectory-html.d.ts +15 -0
  143. package/dist/lib/session/trajectory-html.js +750 -0
  144. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  145. package/dist/lib/session/trajectory-lineage.js +195 -0
  146. package/dist/lib/session/trajectory-text.d.ts +37 -0
  147. package/dist/lib/session/trajectory-text.js +277 -0
  148. package/dist/lib/session/trajectory.d.ts +102 -0
  149. package/dist/lib/session/trajectory.js +335 -0
  150. package/dist/lib/startup/command-registry.d.ts +5 -2
  151. package/dist/lib/startup/command-registry.js +8 -5
  152. package/dist/lib/state.d.ts +1 -1
  153. package/dist/lib/state.js +4 -2
  154. package/dist/lib/teams/agents.js +9 -0
  155. package/dist/lib/terminal/inject.d.ts +19 -0
  156. package/dist/lib/terminal/inject.js +19 -9
  157. package/dist/lib/tmux/session.d.ts +46 -13
  158. package/dist/lib/tmux/session.js +173 -27
  159. package/dist/lib/types.d.ts +16 -0
  160. package/dist/lib/usage-backoff.d.ts +2 -1
  161. package/dist/lib/usage-backoff.js +60 -19
  162. package/dist/lib/usage-refresh.d.ts +17 -3
  163. package/dist/lib/usage-refresh.js +57 -11
  164. package/dist/lib/view-types.d.ts +2 -2
  165. package/dist/lib/watchdog/log.d.ts +1 -1
  166. package/dist/lib/watchdog/log.js +1 -1
  167. package/dist/lib/watchdog/runner.d.ts +36 -45
  168. package/dist/lib/watchdog/runner.js +150 -119
  169. package/dist/lib/watchdog/service.d.ts +0 -1
  170. package/dist/lib/watchdog/service.js +0 -1
  171. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  172. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  173. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  174. package/dist/lib/watchdog/watchdog.js +37 -104
  175. package/package.json +2 -2
  176. package/dist/commands/usage.d.ts +0 -27
  177. package/dist/commands/usage.js +0 -96
@@ -0,0 +1,92 @@
1
+ import type { SessionMeta } from './types.js';
2
+ /**
3
+ * What a node is in the graph, decided from real signals only:
4
+ * - `orchestrator` — it spawned at least one other session in this graph.
5
+ * - `teammate` — an `agents teams` teammate (a `meta.json` record backs it).
6
+ * - `subagent` — an SDK/`sdk-cli` spawn with no teammate record.
7
+ * - `session` — an ordinary session: no children, not team-spawned.
8
+ */
9
+ export type LineageRole = 'orchestrator' | 'teammate' | 'subagent' | 'session';
10
+ /**
11
+ * How recently the node's session did anything, relative to `now`. This is a
12
+ * RECENCY signal, not a verdict: nothing on `SessionMeta` records whether the
13
+ * work succeeded, so the graph never claims "merged" or "crashed". A node that
14
+ * opened a PR carries {@link LineageNode.prNumber}, which is a fact.
15
+ */
16
+ export type LineageActivity = 'active' | 'idle' | 'stale';
17
+ /** One session in the lineage graph. */
18
+ export interface LineageNode {
19
+ session: SessionMeta;
20
+ id: string;
21
+ shortId: string;
22
+ agent: string;
23
+ /** Teammate handle from its team record, when it has one. */
24
+ handle?: string;
25
+ /** Team name (`task_name`) from its team record, when it has one. */
26
+ team?: string;
27
+ /** Teammate launch mode (plan/edit/auto/skip) from its team record. */
28
+ mode?: string;
29
+ role: LineageRole;
30
+ /** Hops from the root: 0 for the root itself. */
31
+ depth: number;
32
+ /** Indexed tool calls (`SessionMeta.toolCallCount`), 0 when unrecorded. */
33
+ toolCount: number;
34
+ /** Wall-clock span in ms (`SessionMeta.durationMs`), 0 when unrecorded. */
35
+ durationMs: number;
36
+ activity: LineageActivity;
37
+ /** PR the session opened, when it opened one. */
38
+ prNumber?: number;
39
+ /** How many children this node has in this graph. */
40
+ childCount: number;
41
+ }
42
+ /** A parent → child delegation edge, and which record established it. */
43
+ export interface LineageEdge {
44
+ parent: string;
45
+ child: string;
46
+ /**
47
+ * `parentSessionId` — the child's own team record named this orchestrator.
48
+ * `teamSpawner` — the child's record named none, but its whole team agreed
49
+ * on one spawner (`groupSessionsByTeam.spawnerSessionId`).
50
+ */
51
+ source: 'parentSessionId' | 'teamSpawner';
52
+ }
53
+ /** The resolved delegation graph rooted at one session. */
54
+ export interface SessionLineage {
55
+ rootId: string;
56
+ /** The root first, then breadth-first; siblings ordered by spawn time. */
57
+ nodes: LineageNode[];
58
+ edges: LineageEdge[];
59
+ /** Distinct team names across the graph, for the header line. */
60
+ teams: string[];
61
+ /**
62
+ * Parent session ids a node pointed at that are not in the pool (the
63
+ * orchestrator's transcript was never indexed, or the scan window cut it off).
64
+ * Surfaced rather than dropped so a missing edge is never silent.
65
+ */
66
+ unresolvedParentIds: string[];
67
+ }
68
+ export interface BuildLineageOptions {
69
+ /**
70
+ * Which session the graph is about. The graph is always rooted at that
71
+ * session's TOPMOST ancestor in the pool, so passing a child still renders its
72
+ * whole team. Defaults to the first session in `sessions`.
73
+ */
74
+ rootId?: string;
75
+ /** Epoch ms used to classify {@link LineageActivity}. Defaults to `Date.now()`. */
76
+ now?: number;
77
+ /** Last activity within this many ms is `active`. Default 5 minutes. */
78
+ activeWithinMs?: number;
79
+ /** Last activity within this many ms is `idle`; older is `stale`. Default 24 hours. */
80
+ idleWithinMs?: number;
81
+ }
82
+ /**
83
+ * Build the delegation graph rooted at one session.
84
+ *
85
+ * `sessions` is the discovery pool WITH team sessions included — team-origin
86
+ * rows are hidden from the ordinary listing by default (`AGENTS.md` invariant 7),
87
+ * so a caller that filters them out first would resolve an empty graph. It MUST
88
+ * contain the `rootId` row (a root outside the pool throws rather than rooting
89
+ * the graph somewhere else). The returned graph is the root's subtree: unrelated
90
+ * sessions in the pool supply edges but never become nodes.
91
+ */
92
+ export declare function buildLineage(sessions: SessionMeta[], options?: BuildLineageOptions): SessionLineage;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Team lineage for `agents sessions trace <parent> --tree` — the third and last
3
+ * layout of the trace surface (PR3/3), after the single trajectory and the
4
+ * two-session compare.
5
+ *
6
+ * A lineage is the delegation graph of an orchestrator and the sessions it
7
+ * spawned. The edges are not invented here: they already exist in the session
8
+ * index as `teamOrigin.parentSessionId` (the orchestrator recorded in a
9
+ * teammate's `meta.json`, `team-filter.ts:151`), with a second pass over
10
+ * `groupSessionsByTeam`'s agreed-on `spawnerSessionId` (`team-filter.ts:317`)
11
+ * for teammates whose own record carries no parent. This module only resolves
12
+ * and orders them.
13
+ *
14
+ * **A node is a SESSION, never an inline sub-agent.** A `Task`/`Agent` tool call
15
+ * is a step inside ONE transcript (`trajectory.ts` marks it
16
+ * `delegation: 'inline-task'`) and produces no session row, so it can never
17
+ * become a node here — the graph is built strictly from `SessionMeta` rows that
18
+ * `discoverSessions` returned.
19
+ *
20
+ * Pure and framework-free like `trajectory.ts` / `trajectory-compare.ts`: it
21
+ * takes session metadata (no transcript parsing, no clock of its own — `now` is
22
+ * an option) and returns a plain object the HTML/text/JSON renderers project.
23
+ */
24
+ import { enrichTeamOrigins, groupSessionsByTeam, teamRowKind } from './team-filter.js';
25
+ const DEFAULT_ACTIVE_WITHIN_MS = 5 * 60_000;
26
+ const DEFAULT_IDLE_WITHIN_MS = 24 * 60 * 60_000;
27
+ /** Recency signal for a row: last activity, else the creation timestamp. */
28
+ function rowTs(session) {
29
+ return session.lastActivity ?? session.timestamp;
30
+ }
31
+ /** Spawn time for ordering siblings: the team record's `started_at`, else creation. */
32
+ function spawnTs(session) {
33
+ return session.teamOrigin?.startedAt ?? session.timestamp;
34
+ }
35
+ function classifyActivity(session, now, activeWithin, idleWithin) {
36
+ const ts = new Date(rowTs(session)).getTime();
37
+ if (Number.isNaN(ts))
38
+ return 'stale';
39
+ const age = now - ts;
40
+ if (age <= activeWithin)
41
+ return 'active';
42
+ if (age <= idleWithin)
43
+ return 'idle';
44
+ return 'stale';
45
+ }
46
+ /**
47
+ * How far outside a run's own spawn window a parentless teammate may still be
48
+ * adopted by that run's spawner.
49
+ *
50
+ * `groupSessionsByTeam` buckets by team NAME alone (`team-filter.ts:292`), and
51
+ * `--tree` scans the pool all-time, so two separate runs of a team called
52
+ * `fleet-resume` land in one group — without a window, the older run's
53
+ * parentless teammates get adopted by the newer run's orchestrator and render as
54
+ * its children. A teams run spawns its teammates within minutes of each other,
55
+ * so an hour is generous for the first or last of them and still far tighter
56
+ * than the all-time scan that caused the misattribution.
57
+ */
58
+ const SPAWNER_ADOPTION_SLACK_MS = 60 * 60_000;
59
+ function spawnMs(session) {
60
+ return new Date(spawnTs(session)).getTime();
61
+ }
62
+ /**
63
+ * Resolve every parent → child edge the session index knows about.
64
+ *
65
+ * Two passes, in priority order: a teammate's own `parentSessionId` wins, and
66
+ * only a teammate that carries none inherits its team's agreed-on spawner —
67
+ * and then only if it was spawned inside that run's own window (see
68
+ * {@link SPAWNER_ADOPTION_SLACK_MS}). A self-edge (a record naming its own
69
+ * session) is dropped: it would make the root its own child and hang the walk.
70
+ */
71
+ function resolveParents(sessions) {
72
+ const parentOf = new Map();
73
+ for (const session of sessions) {
74
+ const parent = session.teamOrigin?.parentSessionId;
75
+ if (parent && parent !== session.id) {
76
+ parentOf.set(session.id, { parent, child: session.id, source: 'parentSessionId' });
77
+ }
78
+ }
79
+ for (const group of groupSessionsByTeam(sessions)) {
80
+ const spawner = group.spawnerSessionId;
81
+ if (!spawner)
82
+ continue;
83
+ // The window of the teammates that actually named this spawner — the run.
84
+ const named = group.sessions.filter((r) => r.teamOrigin.parentSessionId === spawner).map(spawnMs).filter((t) => !Number.isNaN(t));
85
+ if (named.length === 0)
86
+ continue;
87
+ const from = Math.min(...named) - SPAWNER_ADOPTION_SLACK_MS;
88
+ const to = Math.max(...named) + SPAWNER_ADOPTION_SLACK_MS;
89
+ for (const row of group.sessions) {
90
+ if (parentOf.has(row.id) || row.id === spawner)
91
+ continue;
92
+ const ts = spawnMs(row);
93
+ if (Number.isNaN(ts) || ts < from || ts > to)
94
+ continue;
95
+ parentOf.set(row.id, { parent: spawner, child: row.id, source: 'teamSpawner' });
96
+ }
97
+ }
98
+ return parentOf;
99
+ }
100
+ /** Walk up from `startId` to the topmost ancestor present in the pool. */
101
+ function topmostAncestor(startId, parentOf, byId) {
102
+ let current = startId;
103
+ const seen = new Set([current]);
104
+ for (;;) {
105
+ const edge = parentOf.get(current);
106
+ if (!edge || !byId.has(edge.parent) || seen.has(edge.parent))
107
+ return current;
108
+ current = edge.parent;
109
+ seen.add(current);
110
+ }
111
+ }
112
+ function roleFor(session, childCount) {
113
+ if (childCount > 0)
114
+ return 'orchestrator';
115
+ if (session.teamOrigin)
116
+ return teamRowKind(session.teamOrigin);
117
+ return 'session';
118
+ }
119
+ /**
120
+ * Build the delegation graph rooted at one session.
121
+ *
122
+ * `sessions` is the discovery pool WITH team sessions included — team-origin
123
+ * rows are hidden from the ordinary listing by default (`AGENTS.md` invariant 7),
124
+ * so a caller that filters them out first would resolve an empty graph. It MUST
125
+ * contain the `rootId` row (a root outside the pool throws rather than rooting
126
+ * the graph somewhere else). The returned graph is the root's subtree: unrelated
127
+ * sessions in the pool supply edges but never become nodes.
128
+ */
129
+ export function buildLineage(sessions, options = {}) {
130
+ const now = options.now ?? Date.now();
131
+ const activeWithin = options.activeWithinMs ?? DEFAULT_ACTIVE_WITHIN_MS;
132
+ const idleWithin = options.idleWithinMs ?? DEFAULT_IDLE_WITHIN_MS;
133
+ const enriched = enrichTeamOrigins(sessions);
134
+ const byId = new Map(enriched.map((s) => [s.id, s]));
135
+ const parentOf = resolveParents(enriched);
136
+ const childrenOf = new Map();
137
+ const unresolved = new Set();
138
+ for (const edge of parentOf.values()) {
139
+ if (!byId.has(edge.parent)) {
140
+ unresolved.add(edge.parent);
141
+ continue;
142
+ }
143
+ const child = byId.get(edge.child);
144
+ (childrenOf.get(edge.parent) ?? childrenOf.set(edge.parent, []).get(edge.parent)).push(child);
145
+ }
146
+ for (const kids of childrenOf.values()) {
147
+ kids.sort((a, b) => (spawnTs(a) < spawnTs(b) ? -1 : spawnTs(a) > spawnTs(b) ? 1 : a.id.localeCompare(b.id)));
148
+ }
149
+ // A rootId the pool does not contain is a CALLER bug — silently rooting the
150
+ // graph at some unrelated first row is how a lineage renders the wrong team.
151
+ if (options.rootId && !byId.has(options.rootId)) {
152
+ throw new Error(`buildLineage: root session ${options.rootId} is not in the pool — the caller must include the root row.`);
153
+ }
154
+ const requested = options.rootId ?? enriched[0]?.id;
155
+ const rootId = requested ? topmostAncestor(requested, parentOf, byId) : '';
156
+ const root = rootId ? byId.get(rootId) : undefined;
157
+ if (!root) {
158
+ return { rootId: '', nodes: [], edges: [], teams: [], unresolvedParentIds: [...unresolved] };
159
+ }
160
+ // Breadth-first from the root, so the render order IS the drawing order:
161
+ // depth 0 on top, each level below it. `seen` guards a cyclic record.
162
+ const nodes = [];
163
+ const seen = new Set([rootId]);
164
+ const queue = [{ session: root, depth: 0 }];
165
+ const subtreeEdges = [];
166
+ while (queue.length > 0) {
167
+ const { session, depth } = queue.shift();
168
+ const kids = (childrenOf.get(session.id) ?? []).filter((k) => !seen.has(k.id));
169
+ nodes.push({
170
+ session,
171
+ id: session.id,
172
+ shortId: session.shortId || session.id.slice(0, 8),
173
+ agent: session.agent,
174
+ handle: session.teamOrigin?.handle,
175
+ team: session.teamOrigin?.team,
176
+ mode: session.teamOrigin?.mode ?? session.mode,
177
+ role: roleFor(session, kids.length),
178
+ depth,
179
+ toolCount: session.toolCallCount ?? 0,
180
+ durationMs: session.durationMs ?? 0,
181
+ activity: classifyActivity(session, now, activeWithin, idleWithin),
182
+ prNumber: session.prNumber,
183
+ childCount: kids.length,
184
+ });
185
+ for (const kid of kids) {
186
+ seen.add(kid.id);
187
+ subtreeEdges.push(parentOf.get(kid.id));
188
+ queue.push({ session: kid, depth: depth + 1 });
189
+ }
190
+ }
191
+ const teams = [...new Set(nodes.map((n) => n.team).filter((t) => !!t))].sort();
192
+ const nodeIds = new Set(nodes.map((n) => n.id));
193
+ const unresolvedForSubtree = [...unresolved].filter((parent) => enriched.some((s) => nodeIds.has(s.id) && parentOf.get(s.id)?.parent === parent));
194
+ return { rootId, nodes, edges: subtreeEdges, teams, unresolvedParentIds: unresolvedForSubtree };
195
+ }
@@ -0,0 +1,37 @@
1
+ import type { SessionTrajectory } from './trajectory.js';
2
+ import type { TrajectoryComparison } from './trajectory-compare.js';
3
+ import type { SessionLineage } from './trajectory-lineage.js';
4
+ export interface RenderTrajectoryTextOptions {
5
+ /** Collapse to error steps and their immediate neighbours. */
6
+ errorsOnly?: boolean;
7
+ /** Cap on the step lines emitted; the remainder is collapsed with a count. */
8
+ maxSteps?: number;
9
+ }
10
+ /** Render one session's trajectory as compact, ANSI-free text. */
11
+ export declare function renderTrajectoryText(model: SessionTrajectory, options?: RenderTrajectoryTextOptions): string;
12
+ export interface RenderTrajectoryCompareTextOptions {
13
+ /** Cap on step lines listed per diff column; the remainder is collapsed with a count. Default 15. */
14
+ maxDiffLines?: number;
15
+ }
16
+ /**
17
+ * Render a two-session {@link TrajectoryComparison} as compact, ANSI-free text —
18
+ * both sessions' headline stats, the first divergence point, and the step-level
19
+ * diff (only-in-first / only-in-second), each capped so a triaging agent pays a
20
+ * bounded token cost regardless of how far the two runs diverge.
21
+ */
22
+ export declare function renderTrajectoryCompareText(cmp: TrajectoryComparison, options?: RenderTrajectoryCompareTextOptions): string;
23
+ /**
24
+ * Render a {@link SessionLineage} as a tree — the `--tree` text rendering an
25
+ * agent reads when it asks "what did this orchestrator spawn, and where did the
26
+ * fan-out go?".
27
+ *
28
+ * Drawn from the EDGES, not from each node's depth. Indenting by depth alone
29
+ * puts every depth-2 node under whichever depth-1 node happened to print last,
30
+ * so a nested teammate reads as a child of its aunt and its real parent is
31
+ * unrecoverable from the output — a lie an agent has no way to detect.
32
+ *
33
+ * One line per session: its handle/short id, harness, role, indexed tool count,
34
+ * span, recency and PR when it opened one. No per-step detail — lineage answers
35
+ * the shape of the fan-out; `agents sessions trace <child>` answers one child.
36
+ */
37
+ export declare function renderLineageText(lineage: SessionLineage): string;
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Render a {@link SessionTrajectory} as a compact, ANSI-free, token-bounded text
3
+ * trajectory — the rendering an AGENT reads in-context when it asks "where did
4
+ * session X stall?" (the non-TTY / `--text` audience of `agents sessions trace`).
5
+ *
6
+ * Deliberately terse: a header line, the idle stalls, one line per step (tool,
7
+ * redacted label, duration, outcome), a "where the time went" line, and a bounded
8
+ * error-detail tail. No color, no box-drawing, no cursor codes — safe to splice
9
+ * straight into another agent's prompt. `errorsOnly` collapses the run to the
10
+ * error steps and their neighbours so a triaging agent pays only for the failures.
11
+ */
12
+ import { formatTokenCount } from './render.js';
13
+ const DEFAULT_MAX_STEPS = 80;
14
+ const LABEL_COL = 40;
15
+ /** Precise, compact per-step duration: "8m04s", "1.6s", "320ms", "—" for zero. */
16
+ function dur(ms, estimated) {
17
+ if (ms <= 0)
18
+ return estimated ? '~0s' : '0s';
19
+ let s;
20
+ if (ms < 1000)
21
+ s = `${Math.round(ms)}ms`;
22
+ else if (ms < 60_000)
23
+ s = `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
24
+ else {
25
+ const totalSec = Math.round(ms / 1000);
26
+ s = `${Math.floor(totalSec / 60)}m${String(totalSec % 60).padStart(2, '0')}s`;
27
+ }
28
+ return estimated ? `~${s}` : s;
29
+ }
30
+ function outcomeMark(step) {
31
+ if (step.outcome === 'error')
32
+ return '✗';
33
+ if (step.outcome === 'ok')
34
+ return 'ok';
35
+ return '';
36
+ }
37
+ function pad(text, width) {
38
+ return text.length >= width ? text : text + ' '.repeat(width - text.length);
39
+ }
40
+ function clipLabel(label) {
41
+ const oneLine = label.replace(/\s+/g, ' ').trim();
42
+ return oneLine.length <= LABEL_COL ? oneLine : `${oneLine.slice(0, LABEL_COL - 1)}…`;
43
+ }
44
+ function shareLine(model) {
45
+ // Program-keyed, mirroring the HTML panel — a Bash-heavy run reads as
46
+ // `git 56% gh 33% agents 11%`, never a single opaque `Bash 100%`.
47
+ const entries = Object.entries(model.programTimeShare)
48
+ .sort((a, b) => b[1] - a[1])
49
+ .filter(([, share]) => share >= 0.02)
50
+ .slice(0, 5)
51
+ .map(([program, share]) => `${program} ${Math.round(share * 100)}%`);
52
+ return entries.length > 0 ? `where the time went: ${entries.join(' ')}` : undefined;
53
+ }
54
+ /** The display tag for a step — the effective program for a shell call, else the tool. */
55
+ function stepTag(step) {
56
+ return step.program ?? step.tool ?? step.kind;
57
+ }
58
+ /**
59
+ * Break the tool mix down BY PROGRAM — a Bash-heavy session reads as
60
+ * `git 92 agents 80 gh 69` instead of a single "Bash". Count-based.
61
+ */
62
+ function programMixLine(model) {
63
+ const counts = new Map();
64
+ for (const step of model.steps) {
65
+ if (step.kind !== 'tool')
66
+ continue;
67
+ const tag = stepTag(step);
68
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
69
+ }
70
+ const entries = [...counts.entries()]
71
+ .sort((a, b) => b[1] - a[1])
72
+ .slice(0, 8)
73
+ .map(([tag, n]) => `${tag} ${n}`);
74
+ return entries.length > 0 ? `commands: ${entries.join(' ')}` : undefined;
75
+ }
76
+ /** Select the steps to print: all of them, or (errorsOnly) errors + neighbours. */
77
+ function selectSteps(steps, errorsOnly) {
78
+ if (!errorsOnly)
79
+ return steps;
80
+ const keep = new Set();
81
+ steps.forEach((step, i) => {
82
+ if (step.outcome === 'error') {
83
+ keep.add(i);
84
+ if (i > 0)
85
+ keep.add(i - 1);
86
+ if (i < steps.length - 1)
87
+ keep.add(i + 1);
88
+ }
89
+ });
90
+ const out = [];
91
+ let prevKept = -1;
92
+ const firstKept = steps.findIndex((_, i) => keep.has(i));
93
+ if (firstKept > 0)
94
+ out.push('gap'); // steps dropped before the first error neighbourhood
95
+ steps.forEach((step, i) => {
96
+ if (!keep.has(i))
97
+ return;
98
+ if (prevKept >= 0 && i - prevKept > 1)
99
+ out.push('gap');
100
+ out.push(step);
101
+ prevKept = i;
102
+ });
103
+ if (prevKept >= 0 && prevKept < steps.length - 1)
104
+ out.push('gap'); // steps dropped after the last
105
+ return out;
106
+ }
107
+ /** Render one session's trajectory as compact, ANSI-free text. */
108
+ export function renderTrajectoryText(model, options = {}) {
109
+ const { session, stats } = model;
110
+ const errorsOnly = options.errorsOnly === true;
111
+ const maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
112
+ const lines = [];
113
+ // Header.
114
+ const spanMin = model.spanMs > 0 ? `${Math.max(1, Math.round(model.spanMs / 60_000))}m` : '0m';
115
+ const errPart = model.errorCount > 0 ? ` · ${model.errorCount}✗` : '';
116
+ const tokPart = stats.outputTokens > 0 ? ` · ${formatTokenCount(stats.outputTokens)} out` : '';
117
+ const modelPart = session.model ? `·${session.model}` : '';
118
+ lines.push(`${session.shortId || session.id} ${session.agent}${modelPart} · ${spanMin} · ${stats.toolCount} tools${errPart}${tokPart}`);
119
+ if (model.steps.length === 0) {
120
+ lines.push('(no events to visualize)');
121
+ return lines.join('\n') + '\n';
122
+ }
123
+ // Idle stalls, most notable first.
124
+ for (const gap of [...model.gaps].sort((a, b) => b.durationMs - a.durationMs).slice(0, 4)) {
125
+ lines.push(`idle ${dur(gap.durationMs, false)} after step ${gap.afterOrdinal} (stall)`);
126
+ }
127
+ // Steps.
128
+ const selected = selectSteps(model.steps, errorsOnly);
129
+ let printed = 0;
130
+ let omitted = 0;
131
+ for (const entry of selected) {
132
+ if (entry === 'gap') {
133
+ lines.push(' …');
134
+ continue;
135
+ }
136
+ if (printed >= maxSteps) {
137
+ omitted++;
138
+ continue;
139
+ }
140
+ const ord = pad(String(entry.ordinal).padStart(2, '0'), 3);
141
+ const tag = pad(stepTag(entry), 8);
142
+ const label = pad(clipLabel(entry.label), LABEL_COL);
143
+ const mark = outcomeMark(entry);
144
+ const exit = entry.exitCode !== undefined && entry.exitCode !== 0 ? ` exit ${entry.exitCode}` : '';
145
+ lines.push(`${ord} ${tag} ${label} ${dur(entry.durationMs, entry.durationEstimated)}${exit}${mark ? ' ' + mark : ''}`.trimEnd());
146
+ // A short, indented evidence line for an error step.
147
+ if (entry.outcome === 'error' && entry.detail) {
148
+ lines.push(` ${clipLabel(entry.detail)}`);
149
+ }
150
+ printed++;
151
+ }
152
+ if (omitted > 0)
153
+ lines.push(` … ${omitted} more step${omitted === 1 ? '' : 's'} (raise --json for the full model)`);
154
+ if (model.truncatedSteps > 0)
155
+ lines.push(` … ${model.truncatedSteps} step${model.truncatedSteps === 1 ? '' : 's'} collapsed by the render cap`);
156
+ // Where the time went, then the command/program mix.
157
+ const share = shareLine(model);
158
+ if (share)
159
+ lines.push(share);
160
+ const mix = programMixLine(model);
161
+ if (mix)
162
+ lines.push(mix);
163
+ return lines.join('\n') + '\n';
164
+ }
165
+ const DEFAULT_MAX_DIFF_LINES = 15;
166
+ function sessionLabel(session) {
167
+ return `${session.agent} ${session.shortId || session.id}`;
168
+ }
169
+ function summaryLine(summary) {
170
+ const spanMin = summary.spanMs > 0 ? `${Math.max(1, Math.round(summary.spanMs / 60_000))}m` : '0m';
171
+ const errPart = summary.errorCount > 0 ? ` · ${summary.errorCount}✗` : '';
172
+ const tokPart = summary.outputTokens > 0 ? ` · ${formatTokenCount(summary.outputTokens)} out` : '';
173
+ return ` ${sessionLabel(summary.session)} · ${spanMin} · ${summary.toolCount} tools${errPart}${tokPart}`;
174
+ }
175
+ function diffColumn(heading, steps, maxLines) {
176
+ if (steps.length === 0)
177
+ return [`${heading} (0): none`];
178
+ const lines = [`${heading} (${steps.length}):`];
179
+ const shown = steps.slice(0, maxLines);
180
+ for (const step of shown) {
181
+ const mark = outcomeMark(step);
182
+ lines.push(` ${pad(stepTag(step), 8)} ${pad(clipLabel(step.label), LABEL_COL)} ${dur(step.durationMs, step.durationEstimated)}${mark ? ' ' + mark : ''}`.trimEnd());
183
+ }
184
+ const omitted = steps.length - shown.length;
185
+ if (omitted > 0)
186
+ lines.push(` … ${omitted} more`);
187
+ return lines;
188
+ }
189
+ /**
190
+ * Render a two-session {@link TrajectoryComparison} as compact, ANSI-free text —
191
+ * both sessions' headline stats, the first divergence point, and the step-level
192
+ * diff (only-in-first / only-in-second), each capped so a triaging agent pays a
193
+ * bounded token cost regardless of how far the two runs diverge.
194
+ */
195
+ export function renderTrajectoryCompareText(cmp, options = {}) {
196
+ const maxDiffLines = options.maxDiffLines ?? DEFAULT_MAX_DIFF_LINES;
197
+ const labelA = sessionLabel(cmp.a.session);
198
+ const labelB = sessionLabel(cmp.b.session);
199
+ const lines = [];
200
+ lines.push(`compare: ${labelA} vs ${labelB}`);
201
+ lines.push(summaryLine(cmp.summaryA));
202
+ lines.push(summaryLine(cmp.summaryB));
203
+ if (cmp.divergence) {
204
+ lines.push(`diverge after step ${cmp.divergence.afterOrdinalA}/${cmp.divergence.afterOrdinalB}: ${cmp.divergence.detail}`);
205
+ }
206
+ else {
207
+ lines.push('no divergence — tool sequences match');
208
+ }
209
+ if (cmp.truncatedA > 0 || cmp.truncatedB > 0) {
210
+ lines.push(`diff capped — ${cmp.truncatedA} step(s) from the first and ${cmp.truncatedB} from the second were not compared`);
211
+ }
212
+ lines.push(...diffColumn(`only in ${labelA}`, cmp.removed, maxDiffLines));
213
+ lines.push(...diffColumn(`only in ${labelB}`, cmp.added, maxDiffLines));
214
+ return lines.join('\n') + '\n';
215
+ }
216
+ /**
217
+ * Render a {@link SessionLineage} as a tree — the `--tree` text rendering an
218
+ * agent reads when it asks "what did this orchestrator spawn, and where did the
219
+ * fan-out go?".
220
+ *
221
+ * Drawn from the EDGES, not from each node's depth. Indenting by depth alone
222
+ * puts every depth-2 node under whichever depth-1 node happened to print last,
223
+ * so a nested teammate reads as a child of its aunt and its real parent is
224
+ * unrecoverable from the output — a lie an agent has no way to detect.
225
+ *
226
+ * One line per session: its handle/short id, harness, role, indexed tool count,
227
+ * span, recency and PR when it opened one. No per-step detail — lineage answers
228
+ * the shape of the fan-out; `agents sessions trace <child>` answers one child.
229
+ */
230
+ export function renderLineageText(lineage) {
231
+ const lines = [];
232
+ const root = lineage.nodes[0];
233
+ if (!root) {
234
+ lines.push('(no lineage — the selected session spawned nothing indexed)');
235
+ return lines.join('\n') + '\n';
236
+ }
237
+ const teamPart = lineage.teams.length > 0 ? ` · team ${lineage.teams.map((t) => `"${t}"`).join(', ')}` : '';
238
+ const spawned = lineage.nodes.length - 1;
239
+ lines.push(`lineage: ${root.agent} ${root.shortId}${teamPart} · ${spawned} spawned session${spawned === 1 ? '' : 's'}`);
240
+ const byId = new Map(lineage.nodes.map((n) => [n.id, n]));
241
+ const childrenOf = new Map();
242
+ for (const edge of lineage.edges) {
243
+ (childrenOf.get(edge.parent) ?? childrenOf.set(edge.parent, []).get(edge.parent)).push(edge.child);
244
+ }
245
+ const describe = (node) => {
246
+ const name = node.handle && node.handle !== node.shortId ? `${node.handle} · ${node.shortId}` : node.shortId;
247
+ const bits = [node.agent, node.role, `${node.toolCount} tools`];
248
+ if (node.durationMs > 0)
249
+ bits.push(dur(node.durationMs, false));
250
+ bits.push(node.activity);
251
+ if (node.mode)
252
+ bits.push(node.mode);
253
+ if (node.prNumber)
254
+ bits.push(`PR #${node.prNumber}`);
255
+ return `${name} · ${bits.join(' · ')}`;
256
+ };
257
+ // Depth-first from the root so a child always prints directly under its own
258
+ // parent; `prefix` carries the ancestors' continuation bars.
259
+ const walk = (id, prefix) => {
260
+ const kids = childrenOf.get(id) ?? [];
261
+ kids.forEach((childId, i) => {
262
+ const node = byId.get(childId);
263
+ if (!node)
264
+ return;
265
+ const last = i === kids.length - 1;
266
+ lines.push(`${prefix}${last ? '└─ ' : '├─ '}${describe(node)}`);
267
+ walk(childId, `${prefix}${last ? ' ' : '│ '}`);
268
+ });
269
+ };
270
+ lines.push(describe(root));
271
+ walk(root.id, '');
272
+ if (lineage.unresolvedParentIds.length > 0) {
273
+ lines.push(`unresolved parent${lineage.unresolvedParentIds.length === 1 ? '' : 's'} (not in the scanned pool): ` +
274
+ lineage.unresolvedParentIds.join(', '));
275
+ }
276
+ return lines.join('\n') + '\n';
277
+ }