@phnx-labs/agents-cli 1.20.35 → 1.20.36

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 (225) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Self-contained HTML page for `agents serve`. No framework, no external assets
3
+ * — a single inline <style> + <script> that subscribes to /events (SSE) and
4
+ * re-renders the panels on each snapshot. Terminal-coded per the agents-cli
5
+ * brand: #0a0a0a bg, #a3e635 lime accent, JetBrains Mono.
6
+ */
7
+ export function renderPage() {
8
+ return `<!DOCTYPE html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="utf-8" />
12
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
13
+ <title>agents serve</title>
14
+ <style>
15
+ :root {
16
+ --bg: #0a0a0a; --panel: #121212; --border: #262626; --fg: #e5e5e5;
17
+ --dim: #737373; --accent: #a3e635; --add: #4ade80; --del: #f87171; --hunk: #60a5fa;
18
+ }
19
+ * { box-sizing: border-box; }
20
+ body {
21
+ margin: 0; background: var(--bg); color: var(--fg);
22
+ font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
23
+ font-size: 13px; line-height: 1.5;
24
+ }
25
+ header {
26
+ position: sticky; top: 0; z-index: 10; background: var(--bg);
27
+ border-bottom: 1px solid var(--border); padding: 12px 20px;
28
+ display: flex; align-items: center; gap: 12px;
29
+ }
30
+ header .mark { color: var(--accent); font-weight: 700; letter-spacing: .5px; }
31
+ header .status { color: var(--dim); }
32
+ header .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--del); margin-right: 6px; vertical-align: middle; }
33
+ header .dot.live { background: var(--accent); }
34
+ main { padding: 20px; max-width: 1100px; margin: 0 auto; display: grid; gap: 24px; }
35
+ h2 { font-size: 14px; color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 6px; margin: 0 0 12px; text-transform: uppercase; letter-spacing: 1px; }
36
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 14px; margin-bottom: 12px; }
37
+ .row { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
38
+ .muted { color: var(--dim); }
39
+ .badge { font-size: 11px; padding: 1px 7px; border-radius: 10px; border: 1px solid var(--border); }
40
+ .badge.running { color: var(--accent); border-color: var(--accent); }
41
+ .badge.completed { color: var(--add); border-color: var(--add); }
42
+ .badge.failed { color: var(--del); border-color: var(--del); }
43
+ a { color: var(--hunk); text-decoration: none; }
44
+ a:hover { text-decoration: underline; }
45
+ details { margin-top: 8px; }
46
+ summary { cursor: pointer; color: var(--dim); }
47
+ pre.diff { overflow-x: auto; background: #0d0d0d; border: 1px solid var(--border); border-radius: 4px; padding: 10px; margin: 8px 0 0; white-space: pre; }
48
+ pre.diff .a { color: var(--add); } pre.diff .d { color: var(--del); } pre.diff .h { color: var(--hunk); } pre.diff .m { color: var(--dim); }
49
+ .err { color: var(--del); }
50
+ .empty { color: var(--dim); font-style: italic; }
51
+ table { width: 100%; border-collapse: collapse; }
52
+ td, th { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
53
+ th { color: var(--dim); font-weight: 500; }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <header>
58
+ <span class="mark">agents serve</span>
59
+ <span class="status"><span class="dot" id="dot"></span><span id="conn">connecting…</span></span>
60
+ <span class="status" id="ts"></span>
61
+ </header>
62
+ <main>
63
+ <section><h2>Teams &amp; Diffs</h2><div id="teams"></div></section>
64
+ <section><h2>Routines</h2><div id="routines"></div></section>
65
+ <section><h2>Cloud</h2><div id="cloud"></div></section>
66
+ </main>
67
+ <script>
68
+ const $ = (id) => document.getElementById(id);
69
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
70
+ // href allowlist: only http(s) URLs are linkable. esc() blocks attribute
71
+ // breakout, but a javascript:/data: URL would still execute on click — so a
72
+ // non-http(s) pr_url renders as an inert '#' rather than a live link.
73
+ const safeUrl = (u) => { const s = String(u == null ? '' : u); return /^https?:\\/\\//i.test(s) ? s : '#'; };
74
+ function colorDiff(text) {
75
+ return esc(text).split('\\n').map((line) => {
76
+ if (line.startsWith('+')) return '<span class="a">' + line + '</span>';
77
+ if (line.startsWith('-')) return '<span class="d">' + line + '</span>';
78
+ if (line.startsWith('@@')) return '<span class="h">' + line + '</span>';
79
+ if (line.startsWith('diff ') || line.startsWith('index ')) return '<span class="m">' + line + '</span>';
80
+ return line;
81
+ }).join('\\n');
82
+ }
83
+ function panelError(el, error) { el.innerHTML = '<div class="card err">error: ' + esc(error) + '</div>'; }
84
+
85
+ function renderTeams(res) {
86
+ const el = $('teams');
87
+ if (!res.ok) return panelError(el, res.error);
88
+ if (!res.data.length) { el.innerHTML = '<div class="empty">no teams</div>'; return; }
89
+ el.innerHTML = res.data.map((t) => {
90
+ const agents = t.agents.map((a) =>
91
+ '<div class="row"><span>' + esc(a.name || a.agent_id.slice(0,8)) +
92
+ ' <span class="muted">' + esc(a.agent_type) + '</span>' +
93
+ (a.pr_url ? ' <a href="' + esc(safeUrl(a.pr_url)) + '" target="_blank">PR</a>' : '') +
94
+ '</span><span class="badge ' + esc(a.status) + '">' + esc(a.status) + '</span></div>'
95
+ ).join('');
96
+ const worktrees = t.worktrees.map((w) => {
97
+ const body = w.diff
98
+ ? '<details><summary>diff · ' + esc(w.worktree_name || w.worktree_path) + '</summary><pre class="diff">' + colorDiff(w.diff) + '</pre></details>'
99
+ : '<div class="muted">' + esc(w.worktree_name || w.worktree_path) + ' — no uncommitted changes</div>';
100
+ return body;
101
+ }).join('');
102
+ return '<div class="card"><div class="row"><strong>' + esc(t.task_name) + '</strong>' +
103
+ '<span class="muted">' + t.running + ' running · ' + t.completed + ' done · ' + t.failed + ' failed</span></div>' +
104
+ agents + worktrees + '</div>';
105
+ }).join('');
106
+ }
107
+ function renderRoutines(res) {
108
+ const el = $('routines');
109
+ if (!res.ok) return panelError(el, res.error);
110
+ if (!res.data.length) { el.innerHTML = '<div class="empty">no routines</div>'; return; }
111
+ el.innerHTML = '<div class="card"><table><tr><th>name</th><th>schedule</th><th>agent</th><th>enabled</th></tr>' +
112
+ res.data.map((j) => '<tr><td>' + esc(j.name) + '</td><td class="muted">' + esc(j.schedule) + '</td><td>' +
113
+ esc(j.workflow ? 'wf:' + j.workflow : j.agent) + '</td><td>' + (j.enabled ? 'yes' : '<span class="muted">no</span>') + '</td></tr>').join('') +
114
+ '</table></div>';
115
+ }
116
+ function renderCloud(res) {
117
+ const el = $('cloud');
118
+ if (!res.ok) return panelError(el, res.error);
119
+ if (!res.data.length) { el.innerHTML = '<div class="empty">no cloud tasks</div>'; return; }
120
+ el.innerHTML = '<div class="card"><table><tr><th>id</th><th>provider</th><th>status</th><th>repo</th><th></th></tr>' +
121
+ res.data.map((c) => '<tr><td>' + esc(c.id.slice(0,10)) + '</td><td>' + esc(c.provider) + '</td><td><span class="badge ' + esc(c.status) + '">' +
122
+ esc(c.status) + '</span></td><td class="muted">' + esc(c.repo || '') + '</td><td>' +
123
+ (c.prUrl ? '<a href="' + esc(safeUrl(c.prUrl)) + '" target="_blank">PR</a>' : '') + '</td></tr>').join('') +
124
+ '</table></div>';
125
+ }
126
+ function render(state) {
127
+ renderTeams(state.teams); renderRoutines(state.routines); renderCloud(state.cloud);
128
+ $('ts').textContent = 'updated ' + new Date(state.generated_at).toLocaleTimeString();
129
+ }
130
+ function setConn(live) {
131
+ $('dot').className = 'dot' + (live ? ' live' : '');
132
+ $('conn').textContent = live ? 'live' : 'reconnecting…';
133
+ }
134
+ const src = new EventSource('/events');
135
+ src.addEventListener('state', (e) => { setConn(true); render(JSON.parse(e.data)); });
136
+ src.onerror = () => setConn(false);
137
+ </script>
138
+ </body>
139
+ </html>`;
140
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Read-only localhost HTTP + Server-Sent-Events server for `agents serve`.
3
+ *
4
+ * There is deliberately NO framework and NO mutation surface:
5
+ * - binds 127.0.0.1 only (loopback) — never reachable off the machine;
6
+ * - answers GET only (every other method → 405);
7
+ * - routes: `GET /` → HTML page, `GET /api/state` → JSON snapshot,
8
+ * `GET /events` → SSE stream that re-pushes the snapshot on an interval.
9
+ *
10
+ * The whole thing is a viewer over data other commands already own
11
+ * ({@link assembleState}); it writes nothing.
12
+ */
13
+ import http from 'http';
14
+ import type { ServeState } from './data.js';
15
+ /** Loopback address — the server binds here and nowhere else. */
16
+ export declare const SERVE_HOST = "127.0.0.1";
17
+ /** Default port for `agents serve`. */
18
+ export declare const DEFAULT_SERVE_PORT = 4477;
19
+ /** Default SSE push cadence. */
20
+ export declare const DEFAULT_INTERVAL_MS = 3000;
21
+ export interface ServeOptions {
22
+ /** Project root for project-scoped routine discovery. Defaults to process.cwd(). */
23
+ cwd?: string;
24
+ /** SSE push cadence in ms. Defaults to {@link DEFAULT_INTERVAL_MS}. */
25
+ intervalMs?: number;
26
+ /**
27
+ * Snapshot data source. Defaults to {@link assembleState}. Read-only DI seam
28
+ * so tests can drive SSE lifecycle timing deterministically (e.g. gate the
29
+ * first snapshot to reproduce a mid-snapshot disconnect); production never
30
+ * sets it.
31
+ */
32
+ snapshot?: () => Promise<ServeState>;
33
+ }
34
+ /**
35
+ * Create (but do not start) the read-only serve server. Caller invokes
36
+ * `.listen(port, SERVE_HOST)`. Returned so tests can bind an ephemeral port.
37
+ */
38
+ export declare function createServeServer(opts?: ServeOptions): http.Server;
39
+ /**
40
+ * Start the serve server on `port`, bound to loopback. Resolves with the
41
+ * actual bound port (useful when `port === 0` for tests).
42
+ */
43
+ export declare function startServeServer(port?: number, opts?: ServeOptions): Promise<{
44
+ server: http.Server;
45
+ port: number;
46
+ }>;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Read-only localhost HTTP + Server-Sent-Events server for `agents serve`.
3
+ *
4
+ * There is deliberately NO framework and NO mutation surface:
5
+ * - binds 127.0.0.1 only (loopback) — never reachable off the machine;
6
+ * - answers GET only (every other method → 405);
7
+ * - routes: `GET /` → HTML page, `GET /api/state` → JSON snapshot,
8
+ * `GET /events` → SSE stream that re-pushes the snapshot on an interval.
9
+ *
10
+ * The whole thing is a viewer over data other commands already own
11
+ * ({@link assembleState}); it writes nothing.
12
+ */
13
+ import http from 'http';
14
+ import { assembleState } from './data.js';
15
+ import { renderPage } from './page.js';
16
+ /** Loopback address — the server binds here and nowhere else. */
17
+ export const SERVE_HOST = '127.0.0.1';
18
+ /** Default port for `agents serve`. */
19
+ export const DEFAULT_SERVE_PORT = 4477;
20
+ /** Default SSE push cadence. */
21
+ export const DEFAULT_INTERVAL_MS = 3000;
22
+ /**
23
+ * Create (but do not start) the read-only serve server. Caller invokes
24
+ * `.listen(port, SERVE_HOST)`. Returned so tests can bind an ephemeral port.
25
+ */
26
+ export function createServeServer(opts = {}) {
27
+ const cwd = opts.cwd ?? process.cwd();
28
+ const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
29
+ const snapshot = opts.snapshot ?? (() => assembleState(cwd));
30
+ const server = http.createServer(async (req, res) => {
31
+ // Read-only: reject anything that could mutate. Only GET is served.
32
+ if (req.method !== 'GET') {
33
+ res.writeHead(405, { 'content-type': 'text/plain', allow: 'GET' });
34
+ res.end('method not allowed');
35
+ return;
36
+ }
37
+ const url = (req.url || '/').split('?')[0];
38
+ if (url === '/' || url === '/index.html') {
39
+ const html = renderPage();
40
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
41
+ res.end(html);
42
+ return;
43
+ }
44
+ if (url === '/api/state') {
45
+ try {
46
+ const state = await snapshot();
47
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
48
+ res.end(JSON.stringify(state));
49
+ }
50
+ catch (err) {
51
+ res.writeHead(500, { 'content-type': 'application/json' });
52
+ res.end(JSON.stringify({ error: String(err?.message ?? err) }));
53
+ }
54
+ return;
55
+ }
56
+ if (url === '/events') {
57
+ res.writeHead(200, {
58
+ 'content-type': 'text/event-stream',
59
+ 'cache-control': 'no-cache',
60
+ connection: 'keep-alive',
61
+ });
62
+ let closed = false;
63
+ // Declare the timer first and register the disconnect cleanup BEFORE the
64
+ // first `await push()`. `assembleState()` (team scan + git diffs) can be
65
+ // slow; if the client disconnects during it, the one-shot 'close' must
66
+ // already have a listener — otherwise it fires into the void, the interval
67
+ // starts anyway, and we leak a timer writing to a destroyed socket forever.
68
+ let timer;
69
+ req.on('close', () => {
70
+ closed = true;
71
+ if (timer)
72
+ clearInterval(timer);
73
+ });
74
+ const push = async () => {
75
+ if (closed)
76
+ return;
77
+ try {
78
+ const state = await snapshot();
79
+ if (closed)
80
+ return;
81
+ res.write(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
82
+ }
83
+ catch (err) {
84
+ if (!closed)
85
+ res.write(`event: error\ndata: ${JSON.stringify({ error: String(err) })}\n\n`);
86
+ }
87
+ };
88
+ await push(); // immediate first frame
89
+ // Only arm the interval if the client is still connected. If 'close' fired
90
+ // during the first push, `closed` is already true (and the handler ran
91
+ // while `timer` was undefined), so starting an interval here would leak.
92
+ if (!closed)
93
+ timer = setInterval(push, intervalMs);
94
+ return;
95
+ }
96
+ res.writeHead(404, { 'content-type': 'text/plain' });
97
+ res.end('not found');
98
+ });
99
+ return server;
100
+ }
101
+ /**
102
+ * Start the serve server on `port`, bound to loopback. Resolves with the
103
+ * actual bound port (useful when `port === 0` for tests).
104
+ */
105
+ export function startServeServer(port = DEFAULT_SERVE_PORT, opts = {}) {
106
+ const server = createServeServer(opts);
107
+ return new Promise((resolve, reject) => {
108
+ server.once('error', reject);
109
+ server.listen(port, SERVE_HOST, () => {
110
+ const addr = server.address();
111
+ const boundPort = typeof addr === 'object' && addr ? addr.port : port;
112
+ resolve({ server, port: boundPort });
113
+ });
114
+ });
115
+ }
@@ -1,5 +1,13 @@
1
+ import { type PidSessionEntry } from './pid-registry.js';
1
2
  import { type SessionActivity, type AwaitingReason, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
2
3
  import { type SessionProvenance } from './provenance.js';
4
+ /**
5
+ * Per-PID `lsof` probes run bounded and staggered rather than as one parallel
6
+ * fan-out: a simultaneous system-wide `lsof` burst reads to behavioral EDR
7
+ * (CrowdStrike Falcon) as lateral-movement recon. Results are identical — the
8
+ * cwds are just gathered at a bounded spawn rate instead of a single burst.
9
+ */
10
+ export declare const LSOF_CONCURRENCY = 4;
3
11
  export type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
4
12
  export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required';
5
13
  export interface ActiveSession {
@@ -69,6 +77,51 @@ export declare function listTeamsActive(): Promise<ActiveSession[]>;
69
77
  export declare function listTerminalsActive(): Promise<ActiveSession[]>;
70
78
  /** Cloud tasks still in a non-terminal state. `tasks.db` may not exist; that's fine. */
71
79
  export declare function listCloudActive(): ActiveSession[];
80
+ interface ProcRow {
81
+ pid: number;
82
+ ppid: number;
83
+ comm: string;
84
+ kind?: string;
85
+ }
86
+ /** Parse `ConvertTo-Csv` output of Win32_Process rows. Exported for tests. */
87
+ export declare function parseWin32ProcessCsv(out: string): ProcRow[];
88
+ /**
89
+ * Resolve every candidate PID's cwd, bounded and staggered so the probes no
90
+ * longer fan out as one simultaneous system-wide `lsof` burst (a behavioral-EDR
91
+ * recon trigger). Order matches the input `pids`. The `probe` seam is injectable
92
+ * for testing the bound; production always uses the real `lsof`-backed probe.
93
+ */
94
+ export declare function resolveCwds(pids: number[], probe?: (pid: number) => Promise<string | undefined>): Promise<(string | undefined)[]>;
95
+ export interface AgentCandidate {
96
+ pid: number;
97
+ kind: string;
98
+ }
99
+ /**
100
+ * Find the launch registry entry recorded by a WRAPPER of this process. The
101
+ * shim delegate records the pid it spawned, but on Windows the `.cmd` shell
102
+ * path makes that a cmd.exe intermediary whose child is the real agent binary
103
+ * — so the agent pid itself has no entry and the wrapper one ancestor up does.
104
+ * The nearest entry wins, and only if its agent matches the candidate's kind:
105
+ * a claude session shelling out to codex must not hand codex its identity.
106
+ */
107
+ export declare function readAncestorSessionEntry(pid: number, ppidMap: Map<number, number>, kind: string, readEntry?: (pid: number) => PidSessionEntry | undefined): PidSessionEntry | undefined;
108
+ /**
109
+ * Collapse agent processes spawned by another live agent process of the same
110
+ * kind onto their nearest kept ancestor. Claude runs subagents, forks, and
111
+ * even its bundled ripgrep as child `claude` processes — on POSIX those
112
+ * children resolve to the parent's cwd and collapse in dedupeBySession, but
113
+ * where no cwd can be recovered (Windows has no lsof) every fork would print
114
+ * as its own headless row. Two exceptions keep their own row: a candidate with
115
+ * its own registry entry — on its pid OR on a wrapper ancestor strictly below
116
+ * the pid it would fold into (the shim's entry lands on the cmd.exe
117
+ * intermediary on Windows) — and a child of a *different* agent kind (claude
118
+ * shelling out to codex is a real second session, not a fork).
119
+ * Returns the kept roots plus, per root pid, how many descendants folded in.
120
+ */
121
+ export declare function foldSubordinateAgents(candidates: AgentCandidate[], ppidMap: Map<number, number>, readEntry: (pid: number) => PidSessionEntry | undefined): {
122
+ kept: AgentCandidate[];
123
+ foldedByRoot: Map<number, number>;
124
+ };
72
125
  /**
73
126
  * Agent processes not attributed to a team or the runtime registry.
74
127
  * Classified by walking the ppid chain: any recognised UI ancestor (IDE
@@ -82,3 +135,4 @@ export declare function listUnattributedActive(attributed: Set<number>): Promise
82
135
  * already-attributed PIDs removed.
83
136
  */
84
137
  export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
138
+ export {};
@@ -31,7 +31,16 @@ import { extractSessionTopic } from './prompt.js';
31
31
  import { readSessionTail } from './tail.js';
32
32
  import { inferSessionState } from './state.js';
33
33
  import { detectProvenance } from './provenance.js';
34
+ import { mapBounded } from '../concurrency.js';
34
35
  const execFileAsync = promisify(execFile);
36
+ /**
37
+ * Per-PID `lsof` probes run bounded and staggered rather than as one parallel
38
+ * fan-out: a simultaneous system-wide `lsof` burst reads to behavioral EDR
39
+ * (CrowdStrike Falcon) as lateral-movement recon. Results are identical — the
40
+ * cwds are just gathered at a bounded spawn rate instead of a single burst.
41
+ */
42
+ export const LSOF_CONCURRENCY = 4;
43
+ const LSOF_STAGGER_MS = 10;
35
44
  const HOME = os.homedir();
36
45
  const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
37
46
  /**
@@ -49,6 +58,19 @@ const AGENT_CLI_NAMES = {
49
58
  opencode: 'opencode',
50
59
  droid: 'droid',
51
60
  };
61
+ /**
62
+ * Resolve an agent kind from a process's reported executable. `comm` may be an
63
+ * absolute path (shim-launched agents), and Windows image names carry an
64
+ * `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
65
+ */
66
+ function agentKindFromComm(commRaw) {
67
+ const base = path.basename(commRaw);
68
+ const stripped = base.replace(/\.exe$/i, '');
69
+ // Windows image names compare case-insensitively; POSIX comms stay exact —
70
+ // macOS's Claude desktop app process is named 'Claude' and must NOT match.
71
+ const key = stripped === base ? base : stripped.toLowerCase();
72
+ return AGENT_CLI_NAMES[key];
73
+ }
52
74
  function isPidAlive(pid) {
53
75
  if (!pid || pid < 1)
54
76
  return false;
@@ -355,14 +377,14 @@ export function listCloudActive() {
355
377
  * and a terminal-app is preferred over the multiplexer inside it.
356
378
  */
357
379
  const HOST_MATCHERS = [
358
- // IDE renderers (Electron helper processes)
359
- { host: 'code', tokens: ['Code Helper', 'Code - Insiders Helper'] },
360
- { host: 'cursor', tokens: ['Cursor Helper'] },
361
- { host: 'codium', tokens: ['VSCodium Helper'] },
362
- { host: 'windsurf', tokens: ['Windsurf Helper'] },
380
+ // IDE renderers (Electron helper processes on macOS, image names on Windows)
381
+ { host: 'code', tokens: ['Code Helper', 'Code - Insiders Helper', 'Code.exe'] },
382
+ { host: 'cursor', tokens: ['Cursor Helper', 'Cursor.exe'] },
383
+ { host: 'codium', tokens: ['VSCodium Helper', 'VSCodium.exe'] },
384
+ { host: 'windsurf', tokens: ['Windsurf Helper', 'Windsurf.exe'] },
363
385
  // Native terminal apps
364
386
  { host: 'iterm', tokens: ['iTerm2', 'iTermServer', 'iTerm'] },
365
- { host: 'terminal', tokens: ['Terminal.app', '/Applications/Utilities/Terminal.app'] },
387
+ { host: 'terminal', tokens: ['Terminal.app', '/Applications/Utilities/Terminal.app', 'WindowsTerminal.exe'] },
366
388
  { host: 'warp', tokens: ['Warp.app', 'stable_'] },
367
389
  { host: 'alacritty', tokens: ['alacritty', 'Alacritty'] },
368
390
  { host: 'kitty', tokens: ['kitty'] },
@@ -380,6 +402,8 @@ const HOST_MATCHERS = [
380
402
  * matching against AGENT_CLI_NAMES.
381
403
  */
382
404
  async function readProcessTable() {
405
+ if (process.platform === 'win32')
406
+ return readProcessTableWin32();
383
407
  let out;
384
408
  try {
385
409
  ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
@@ -397,8 +421,41 @@ async function readProcessTable() {
397
421
  if (!Number.isFinite(pid) || !Number.isFinite(ppid))
398
422
  continue;
399
423
  const commRaw = m[3].trim();
400
- const kind = AGENT_CLI_NAMES[path.basename(commRaw)];
401
- rows.push({ pid, ppid, comm: commRaw, kind });
424
+ rows.push({ pid, ppid, comm: commRaw, kind: agentKindFromComm(commRaw) });
425
+ }
426
+ return rows;
427
+ }
428
+ /**
429
+ * Windows process table in one CIM query (`wmic` is removed on current
430
+ * Windows 11, so PowerShell is the stable interface). Same pid/ppid/comm
431
+ * shape as the POSIX `ps` snapshot; `Name` is the image name (`claude.exe`).
432
+ */
433
+ async function readProcessTableWin32() {
434
+ let out;
435
+ try {
436
+ ({ stdout: out } = await execFileAsync('powershell.exe', [
437
+ '-NoProfile', '-NonInteractive', '-Command',
438
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation',
439
+ ], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true }));
440
+ }
441
+ catch {
442
+ return [];
443
+ }
444
+ return parseWin32ProcessCsv(out);
445
+ }
446
+ /** Parse `ConvertTo-Csv` output of Win32_Process rows. Exported for tests. */
447
+ export function parseWin32ProcessCsv(out) {
448
+ const rows = [];
449
+ for (const line of out.split(/\r?\n/)) {
450
+ const m = line.trim().match(/^"(\d+)","(\d+)","(.*)"$/);
451
+ if (!m)
452
+ continue;
453
+ const pid = parseInt(m[1], 10);
454
+ const ppid = parseInt(m[2], 10);
455
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid))
456
+ continue;
457
+ const comm = m[3].replace(/""/g, '"');
458
+ rows.push({ pid, ppid, comm, kind: agentKindFromComm(comm) });
402
459
  }
403
460
  return rows;
404
461
  }
@@ -418,12 +475,25 @@ function hasAttributedAncestor(pid, ppidMap, attributed) {
418
475
  }
419
476
  return false;
420
477
  }
478
+ /**
479
+ * Resolve every candidate PID's cwd, bounded and staggered so the probes no
480
+ * longer fan out as one simultaneous system-wide `lsof` burst (a behavioral-EDR
481
+ * recon trigger). Order matches the input `pids`. The `probe` seam is injectable
482
+ * for testing the bound; production always uses the real `lsof`-backed probe.
483
+ */
484
+ export function resolveCwds(pids, probe = getCwdForPid) {
485
+ return mapBounded(pids, probe, { concurrency: LSOF_CONCURRENCY, staggerMs: LSOF_STAGGER_MS });
486
+ }
421
487
  /**
422
488
  * Resolve a process's current working directory via `lsof`. The `-a` flag
423
489
  * ANDs the filters; without it macOS treats `-p` and `-d` as a union and
424
490
  * returns the cwd of every process on the system.
425
491
  */
426
492
  async function getCwdForPid(pid) {
493
+ // No lsof on Windows and no cheap foreign-process cwd API; the pid registry
494
+ // written by `ag run` supplies the cwd for registry-launched agents instead.
495
+ if (process.platform === 'win32')
496
+ return undefined;
427
497
  let out;
428
498
  try {
429
499
  const res = await execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
@@ -470,6 +540,99 @@ const UI_HOSTS = new Set([
470
540
  'iterm', 'terminal', 'warp', 'alacritty', 'kitty', 'hyper', 'wezterm', 'ghostty',
471
541
  'tmux', 'screen',
472
542
  ]);
543
+ /**
544
+ * Find the launch registry entry recorded by a WRAPPER of this process. The
545
+ * shim delegate records the pid it spawned, but on Windows the `.cmd` shell
546
+ * path makes that a cmd.exe intermediary whose child is the real agent binary
547
+ * — so the agent pid itself has no entry and the wrapper one ancestor up does.
548
+ * The nearest entry wins, and only if its agent matches the candidate's kind:
549
+ * a claude session shelling out to codex must not hand codex its identity.
550
+ */
551
+ export function readAncestorSessionEntry(pid, ppidMap, kind, readEntry = readPidSessionEntry) {
552
+ let cur = ppidMap.get(pid);
553
+ const seen = new Set();
554
+ while (cur && cur > 1 && !seen.has(cur)) {
555
+ const entry = readEntry(cur);
556
+ if (entry)
557
+ return entry.agent === kind ? entry : undefined;
558
+ seen.add(cur);
559
+ cur = ppidMap.get(cur);
560
+ }
561
+ return undefined;
562
+ }
563
+ /**
564
+ * Collapse agent processes spawned by another live agent process of the same
565
+ * kind onto their nearest kept ancestor. Claude runs subagents, forks, and
566
+ * even its bundled ripgrep as child `claude` processes — on POSIX those
567
+ * children resolve to the parent's cwd and collapse in dedupeBySession, but
568
+ * where no cwd can be recovered (Windows has no lsof) every fork would print
569
+ * as its own headless row. Two exceptions keep their own row: a candidate with
570
+ * its own registry entry — on its pid OR on a wrapper ancestor strictly below
571
+ * the pid it would fold into (the shim's entry lands on the cmd.exe
572
+ * intermediary on Windows) — and a child of a *different* agent kind (claude
573
+ * shelling out to codex is a real second session, not a fork).
574
+ * Returns the kept roots plus, per root pid, how many descendants folded in.
575
+ */
576
+ export function foldSubordinateAgents(candidates, ppidMap, readEntry) {
577
+ const kindByPid = new Map(candidates.map(c => [c.pid, c.kind]));
578
+ const nearestSameKindAncestor = (pid, kind) => {
579
+ let cur = ppidMap.get(pid);
580
+ const seen = new Set();
581
+ while (cur && cur > 1 && !seen.has(cur)) {
582
+ if (kindByPid.get(cur) === kind)
583
+ return cur;
584
+ seen.add(cur);
585
+ cur = ppidMap.get(cur);
586
+ }
587
+ return undefined;
588
+ };
589
+ // Own launch identity: a matching-kind registry entry on the candidate or on
590
+ // any wrapper between it and the pid it would fold into (exclusive). Entries
591
+ // above the fold target belong to that ancestor's session, not this one.
592
+ const hasOwnSession = (c, stopPid) => {
593
+ if (readEntry(c.pid)?.agent === c.kind)
594
+ return true;
595
+ let cur = ppidMap.get(c.pid);
596
+ const seen = new Set();
597
+ while (cur && cur > 1 && cur !== stopPid && !seen.has(cur)) {
598
+ if (readEntry(cur)?.agent === c.kind)
599
+ return true;
600
+ seen.add(cur);
601
+ cur = ppidMap.get(cur);
602
+ }
603
+ return false;
604
+ };
605
+ const keptPids = new Set();
606
+ for (const c of candidates) {
607
+ const foldTarget = nearestSameKindAncestor(c.pid, c.kind);
608
+ if (foldTarget === undefined || hasOwnSession(c, foldTarget)) {
609
+ keptPids.add(c.pid);
610
+ }
611
+ }
612
+ const kept = [];
613
+ const foldedByRoot = new Map();
614
+ for (const c of candidates) {
615
+ if (keptPids.has(c.pid)) {
616
+ kept.push(c);
617
+ continue;
618
+ }
619
+ // Walk up through folded intermediates to the nearest kept same-kind pid.
620
+ let cur = nearestSameKindAncestor(c.pid, c.kind);
621
+ const seen = new Set();
622
+ while (cur !== undefined && !keptPids.has(cur) && !seen.has(cur)) {
623
+ seen.add(cur);
624
+ cur = nearestSameKindAncestor(cur, c.kind);
625
+ }
626
+ // A fold target must itself be a kept row (a ppid cycle from pid reuse
627
+ // can orphan the whole chain) — otherwise keep the row rather than drop it.
628
+ if (cur === undefined || !keptPids.has(cur)) {
629
+ kept.push(c);
630
+ continue;
631
+ }
632
+ foldedByRoot.set(cur, (foldedByRoot.get(cur) ?? 0) + 1);
633
+ }
634
+ return { kept, foldedByRoot };
635
+ }
473
636
  /**
474
637
  * Agent processes not attributed to a team or the runtime registry.
475
638
  * Classified by walking the ppid chain: any recognised UI ancestor (IDE
@@ -495,16 +658,22 @@ export async function listUnattributedActive(attributed) {
495
658
  continue;
496
659
  candidates.push({ pid, kind });
497
660
  }
498
- // Run lsof probes in parallel so N spawns take one round-trip, not N.
499
- const cwds = await Promise.all(candidates.map(c => getCwdForPid(c.pid)));
661
+ // Forks/subagents of a live agent process collapse onto their root before
662
+ // the cwd probes fewer spawns, and one session stays one row even when
663
+ // cwd-based dedupe is unavailable (Windows).
664
+ const { kept, foldedByRoot } = foldSubordinateAgents(candidates, ppidMap, readPidSessionEntry);
665
+ // Bounded + staggered lsof probes: same cwds, but a trickle of spawns instead
666
+ // of one simultaneous system-wide burst that behavioral EDR flags as recon.
667
+ const cwds = await resolveCwds(kept.map(c => c.pid));
500
668
  const out = [];
501
- for (let i = 0; i < candidates.length; i++) {
502
- const { pid, kind } = candidates[i];
503
- // The per-pid registry (written by `ag run`) gives the EXACT session id this
504
- // pid was launched with — so N agents in one cwd resolve to N distinct
505
- // sessions instead of all collapsing onto the newest .jsonl. Absent (agent
506
- // not launched via `ag run`, or one that takes no session id) → heuristic.
507
- const entry = readPidSessionEntry(pid);
669
+ for (let i = 0; i < kept.length; i++) {
670
+ const { pid, kind } = kept[i];
671
+ // The per-pid registry (written by `ag run` and the shim delegate) gives
672
+ // the EXACT session id this pid was launched with — so N agents in one cwd
673
+ // resolve to N distinct sessions instead of all collapsing onto the newest
674
+ // .jsonl. The shim's entry may sit on a wrapper ancestor (Windows .cmd
675
+ // path). Absent entirely (direct launch outside agents-cli) → heuristic.
676
+ const entry = readPidSessionEntry(pid) ?? readAncestorSessionEntry(pid, ppidMap, kind);
508
677
  const cwd = cwds[i] ?? entry?.cwd ?? undefined;
509
678
  const sessionFile = findSessionFileForKind(kind, cwd, entry?.sessionId);
510
679
  const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
@@ -520,6 +689,7 @@ export async function listUnattributedActive(attributed) {
520
689
  sessionId: entry?.sessionId ?? sessionIdFromFile(sessionFile),
521
690
  topic,
522
691
  sessionFile,
692
+ pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
523
693
  }, state, sessionFile));
524
694
  }
525
695
  // Housekeeping: drop registry files for pids that have since died.
@@ -581,10 +751,11 @@ function dedupeBySession(sessions) {
581
751
  }
582
752
  const existing = byKey.get(key);
583
753
  if (existing) {
584
- existing.pidCount = (existing.pidCount ?? 1) + 1;
754
+ // Carry pre-folded fork counts (headless rows arrive with pidCount set).
755
+ existing.pidCount = (existing.pidCount ?? 1) + (s.pidCount ?? 1);
585
756
  }
586
757
  else {
587
- s.pidCount = 1;
758
+ s.pidCount = s.pidCount ?? 1;
588
759
  byKey.set(key, s);
589
760
  out.push(s);
590
761
  }