@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.
- package/CHANGELOG.md +91 -0
- package/LICENSE +185 -21
- package/README.md +8 -4
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer.js +81 -0
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.js +1 -89
- package/dist/commands/events.js +3 -3
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +277 -11
- package/dist/commands/hosts.js +10 -6
- package/dist/commands/inspect.js +8 -9
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +156 -75
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.js +20 -8
- package/dist/commands/sessions.d.ts +71 -1
- package/dist/commands/sessions.js +365 -37
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +25 -2
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.js +2 -1
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.js +24 -3
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +112 -8
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +57 -0
- package/dist/lib/daemon.js +192 -16
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +31 -13
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +79 -13
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +127 -8
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +39 -6
- package/dist/lib/hosts/logs.js +30 -1
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/ready.d.ts +29 -6
- package/dist/lib/hosts/ready.js +66 -15
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +14 -0
- package/dist/lib/hosts/tasks.js +15 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/picker.d.ts +2 -0
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugins.js +16 -6
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +54 -0
- package/dist/lib/session/active.js +190 -19
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +111 -28
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.js +23 -20
- package/dist/lib/session/pid-registry.d.ts +1 -0
- package/dist/lib/session/pid-registry.js +24 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.js +19 -7
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +213 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +16 -2
- package/dist/lib/session/sync/config.d.ts +1 -15
- package/dist/lib/session/sync/config.js +4 -20
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/shims.d.ts +36 -6
- package/dist/lib/shims.js +91 -29
- package/dist/lib/ssh-exec.js +2 -0
- package/dist/lib/ssh-tunnel.js +2 -1
- package/dist/lib/startup/command-registry.d.ts +6 -0
- package/dist/lib/startup/command-registry.js +13 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +17 -9
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/index.d.ts +2 -1
- package/dist/lib/terminal/backends/index.js +3 -1
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/index.d.ts +4 -1
- package/dist/lib/terminal/index.js +4 -1
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/versions.d.ts +23 -0
- package/dist/lib/versions.js +119 -13
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- 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 & 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) => ({'&':'&','<':'<','>':'>','"':'"'}[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
|
-
|
|
401
|
-
|
|
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
|
-
//
|
|
499
|
-
|
|
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 <
|
|
502
|
-
const { pid, kind } =
|
|
503
|
-
// The per-pid registry (written by `ag run`
|
|
504
|
-
// pid was launched with — so N agents in one cwd
|
|
505
|
-
// sessions instead of all collapsing onto the newest
|
|
506
|
-
//
|
|
507
|
-
|
|
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
|
-
|
|
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
|
}
|