@phnx-labs/agents-cli 1.20.74 → 1.20.77
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 +1055 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/commands/exec.js +83 -13
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +38 -8
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +286 -15
- package/dist/commands/secrets.js +102 -37
- package/dist/commands/sessions-browser.js +12 -2
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +184 -16
- package/dist/commands/sessions.d.ts +56 -14
- package/dist/commands/sessions.js +322 -62
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +110 -37
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +15 -6
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +24 -4
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +17 -6
- package/dist/lib/activity.js +466 -40
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +70 -6
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.js +4 -0
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +114 -11
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +37 -1
- package/dist/lib/devices/fleet.js +36 -2
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +82 -0
- package/dist/lib/exec.js +218 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +16 -0
- package/dist/lib/hosts/dispatch.js +26 -4
- package/dist/lib/hosts/passthrough.js +9 -2
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +32 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +17 -5
- package/dist/lib/hosts/run-target.js +30 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +40 -5
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +2 -0
- package/dist/lib/import.js +35 -2
- package/dist/lib/menubar/install-menubar.d.ts +29 -0
- package/dist/lib/menubar/install-menubar.js +59 -1
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +6 -2
- package/dist/lib/overdue.js +10 -36
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +94 -0
- package/dist/lib/routines.js +93 -9
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +242 -20
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +74 -4
- package/dist/lib/secrets/agent.js +181 -107
- package/dist/lib/secrets/bundles.d.ts +54 -6
- package/dist/lib/secrets/bundles.js +230 -34
- package/dist/lib/secrets/index.d.ts +26 -3
- package/dist/lib/secrets/index.js +42 -12
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +124 -7
- package/dist/lib/session/active.js +390 -56
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +54 -0
- package/dist/lib/session/db.js +320 -19
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +478 -3
- package/dist/lib/session/discover.js +1740 -472
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +141 -32
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +40 -26
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +99 -13
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +20 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +8 -4
- package/dist/lib/state.js +21 -37
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +47 -2
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +9 -2
- package/dist/lib/versions.js +37 -8
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
|
@@ -19,12 +19,13 @@
|
|
|
19
19
|
import * as fs from 'fs';
|
|
20
20
|
import * as path from 'path';
|
|
21
21
|
import * as os from 'os';
|
|
22
|
-
import { execFile } from 'child_process';
|
|
22
|
+
import { execFile, execFileSync } from 'child_process';
|
|
23
23
|
import { promisify } from 'util';
|
|
24
24
|
import { listActiveTasks } from '../cloud/store.js';
|
|
25
25
|
import { AgentManager } from '../teams/agents.js';
|
|
26
26
|
import { getTerminalsDir } from '../state.js';
|
|
27
|
-
import { readPidSessionEntry, prunePidSessionRegistry } from './pid-registry.js';
|
|
27
|
+
import { readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry } from './pid-registry.js';
|
|
28
|
+
import { loadHookSessionIndex, resolveHookSessionRecord } from './hook-sessions.js';
|
|
28
29
|
import { buildClaudeLabelMap } from './discover.js';
|
|
29
30
|
import { buildRunNameMap } from './run-names.js';
|
|
30
31
|
import { latestSessionFileForCwd } from './db.js';
|
|
@@ -33,6 +34,8 @@ import { readSessionTailWithRaw } from './tail.js';
|
|
|
33
34
|
import { computeTokPerSec } from './throughput.js';
|
|
34
35
|
import { inferSessionState } from './state.js';
|
|
35
36
|
import { detectProvenance } from './provenance.js';
|
|
37
|
+
import { loadDevices } from '../devices/registry.js';
|
|
38
|
+
import { presenceFromStore } from './detached.js';
|
|
36
39
|
import { mapBounded } from '../concurrency.js';
|
|
37
40
|
const execFileAsync = promisify(execFile);
|
|
38
41
|
/**
|
|
@@ -43,6 +46,15 @@ const execFileAsync = promisify(execFile);
|
|
|
43
46
|
*/
|
|
44
47
|
export const LSOF_CONCURRENCY = 4;
|
|
45
48
|
const LSOF_STAGGER_MS = 10;
|
|
49
|
+
/**
|
|
50
|
+
* Hard ceilings on the two syscalls the status path shells out to. Without them
|
|
51
|
+
* a single hung probe (a wedged NFS `lsof`, an EDR that stalls the `ps` snapshot)
|
|
52
|
+
* pins a bounded worker slot forever and silently drops live sessions to a
|
|
53
|
+
* fallback status. On timeout the call rejects, is caught, and the row degrades
|
|
54
|
+
* honestly (unknown / empty table) instead of the sweep hanging.
|
|
55
|
+
*/
|
|
56
|
+
const LSOF_TIMEOUT_MS = 5_000;
|
|
57
|
+
const PS_SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
46
58
|
export function activeStatusFromCloudStatus(status) {
|
|
47
59
|
switch (status) {
|
|
48
60
|
case 'running':
|
|
@@ -102,16 +114,72 @@ export function agentKindFromComm(commRaw) {
|
|
|
102
114
|
const key = stripped === base ? base : stripped.toLowerCase();
|
|
103
115
|
return AGENT_CLI_NAMES[key];
|
|
104
116
|
}
|
|
105
|
-
|
|
117
|
+
/**
|
|
118
|
+
* A process that began more than this long AFTER a session's recorded
|
|
119
|
+
* `startedAtMs` cannot be that session's process — the OS handed its pid to
|
|
120
|
+
* something newer. The window absorbs clock granularity (`ps -o lstart=` reports
|
|
121
|
+
* whole seconds) and the gap between a process spawning and the SessionStart
|
|
122
|
+
* hook recording `startedAtMs`; it is far below the minutes-to-hours it takes the
|
|
123
|
+
* pid space to wrap and actually recycle a pid, so it never false-kills a live
|
|
124
|
+
* session.
|
|
125
|
+
*/
|
|
126
|
+
const PID_REUSE_TOLERANCE_MS = 60_000;
|
|
127
|
+
/**
|
|
128
|
+
* Epoch-ms start time of the process at `pid`, or null if unknowable.
|
|
129
|
+
*
|
|
130
|
+
* Distinct from teams/agents.ts's `captureProcessStartTime`, which returns an
|
|
131
|
+
* opaque token only meaningful for equality against a prior capture of the SAME
|
|
132
|
+
* pid. Here we need a value comparable to a session's `startedAtMs`, so we read
|
|
133
|
+
* `ps -o lstart=` — a ctime string on both macOS and Linux — and parse it to
|
|
134
|
+
* epoch ms. Windows and any exec/parse failure return null, so the caller falls
|
|
135
|
+
* back to a bare existence check (never worse than before).
|
|
136
|
+
*/
|
|
137
|
+
function processStartMs(pid) {
|
|
138
|
+
if (process.platform === 'win32')
|
|
139
|
+
return null;
|
|
140
|
+
try {
|
|
141
|
+
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
142
|
+
encoding: 'utf-8',
|
|
143
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
144
|
+
}).trim();
|
|
145
|
+
if (!out)
|
|
146
|
+
return null;
|
|
147
|
+
const ms = Date.parse(out);
|
|
148
|
+
return Number.isFinite(ms) ? ms : null;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* True when `pid` names a live process AND — when a session's recorded
|
|
156
|
+
* `startedAtMs` is supplied — that process is plausibly the SAME one, not a later
|
|
157
|
+
* process that recycled the pid. The OS reuses pids, so a bare
|
|
158
|
+
* `process.kill(pid, 0)` existence check reports a dead session as alive (a
|
|
159
|
+
* "zombie") once its pid is handed to an unrelated process. A genuine session
|
|
160
|
+
* process starts at or before its own recorded start, so a process that began
|
|
161
|
+
* meaningfully AFTER `startedAtMs` is a reused pid and the session is dead. When
|
|
162
|
+
* the start time can't be read, we keep the existence answer.
|
|
163
|
+
*/
|
|
164
|
+
export function isPidAlive(pid, startedAtMs) {
|
|
106
165
|
if (!pid || pid < 1)
|
|
107
166
|
return false;
|
|
108
167
|
try {
|
|
109
168
|
process.kill(pid, 0);
|
|
110
|
-
return true;
|
|
111
169
|
}
|
|
112
170
|
catch (err) {
|
|
113
|
-
|
|
171
|
+
// EPERM means the pid exists but is owned by another user — still "alive",
|
|
172
|
+
// fall through to the identity check. Any other error means no such process.
|
|
173
|
+
if (err?.code !== 'EPERM')
|
|
174
|
+
return false;
|
|
114
175
|
}
|
|
176
|
+
if (startedAtMs && startedAtMs > 0) {
|
|
177
|
+
const procStartMs = processStartMs(pid);
|
|
178
|
+
if (procStartMs !== null && procStartMs > startedAtMs + PID_REUSE_TOLERANCE_MS) {
|
|
179
|
+
return false; // pid recycled by a newer process — this session is gone
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
115
183
|
}
|
|
116
184
|
/** Read the live-terminals registry, dedupe by sessionId, keep only pid-alive entries. */
|
|
117
185
|
function readLiveTerminals() {
|
|
@@ -134,7 +202,7 @@ function readLiveTerminals() {
|
|
|
134
202
|
const merged = new Map();
|
|
135
203
|
for (const [windowId, slice] of Object.entries(parsed)) {
|
|
136
204
|
for (const e of (slice?.entries ?? [])) {
|
|
137
|
-
if (!e?.sessionId || !isPidAlive(e.pid))
|
|
205
|
+
if (!e?.sessionId || !isPidAlive(e.pid, e.startedAtMs))
|
|
138
206
|
continue;
|
|
139
207
|
merged.set(e.sessionId, { ...e, windowId });
|
|
140
208
|
}
|
|
@@ -189,19 +257,49 @@ export function pickSessionFile(projectDir, sessionId) {
|
|
|
189
257
|
}
|
|
190
258
|
return best?.path;
|
|
191
259
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
260
|
+
/**
|
|
261
|
+
* One `stat` → the transcript's creation (≈ session start) and last-write (≈ last
|
|
262
|
+
* activity) epochs. Both `undefined` when the file can't be stat'd (vanished /
|
|
263
|
+
* unresolved). `birthtimeMs` can be 0 on filesystems without creation time — coerce
|
|
264
|
+
* that to `undefined` so callers fall through to a real signal instead of epoch 0.
|
|
265
|
+
*/
|
|
266
|
+
export function sessionFileTimes(sessionFile) {
|
|
197
267
|
if (!sessionFile)
|
|
198
|
-
return
|
|
268
|
+
return {};
|
|
269
|
+
try {
|
|
270
|
+
const st = fs.statSync(sessionFile);
|
|
271
|
+
return { birthtimeMs: st.birthtimeMs || undefined, mtimeMs: st.mtimeMs || undefined };
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return {};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The ONE place a fallback status is decided when no rich transcript state is
|
|
279
|
+
* available — a non-Claude/Codex kind we cannot parse, or a Claude/Codex tail
|
|
280
|
+
* that was empty or unreadable. Honest by construction: it never asserts a status
|
|
281
|
+
* it cannot justify from a measured signal.
|
|
282
|
+
*
|
|
283
|
+
* - Resolvable transcript, readable mtime → the MEASURED freshness signal:
|
|
284
|
+
* written within ACTIVE_MTIME_WINDOW_MS ⇒ `running`, else `idle`.
|
|
285
|
+
* - Resolvable transcript whose `stat` throws (file vanished / permission) → we
|
|
286
|
+
* genuinely cannot tell ⇒ `unknown`. (This branch previously returned
|
|
287
|
+
* `running`, which contradicted the `idle` default one branch up.)
|
|
288
|
+
* - No resolvable transcript but the process is alive → alive-but-opaque ⇒
|
|
289
|
+
* `unknown`. This is the truthful answer for a live gemini / droid / cursor /
|
|
290
|
+
* opencode whose format we don't parse — NOT a fabricated `idle` (which the
|
|
291
|
+
* UI reads as "done and waiting"), and it never lies as `running` either.
|
|
292
|
+
* - No transcript and the process is not known alive → nothing to report ⇒ `idle`.
|
|
293
|
+
*/
|
|
294
|
+
export function resolveFallbackStatus(sessionFile, pidAlive) {
|
|
295
|
+
if (!sessionFile)
|
|
296
|
+
return pidAlive ? 'unknown' : 'idle';
|
|
199
297
|
try {
|
|
200
298
|
const mtimeMs = fs.statSync(sessionFile).mtimeMs;
|
|
201
299
|
return Date.now() - mtimeMs < ACTIVE_MTIME_WINDOW_MS ? 'running' : 'idle';
|
|
202
300
|
}
|
|
203
301
|
catch {
|
|
204
|
-
return '
|
|
302
|
+
return 'unknown';
|
|
205
303
|
}
|
|
206
304
|
}
|
|
207
305
|
/**
|
|
@@ -255,11 +353,13 @@ function statusFromActivity(activity) {
|
|
|
255
353
|
/**
|
|
256
354
|
* Fold a computed SessionState onto an active-session row: rich status +
|
|
257
355
|
* preview + PR/worktree/ticket badges. With no state (unreadable/non-Claude/
|
|
258
|
-
* Codex file) it degrades to
|
|
356
|
+
* Codex file) it degrades to {@link resolveFallbackStatus}, which needs
|
|
357
|
+
* `pidAlive` to tell an alive-but-opaque process (`unknown`) from a dead one
|
|
358
|
+
* (`idle`).
|
|
259
359
|
*/
|
|
260
|
-
function applyState(base, state, fallbackFile) {
|
|
360
|
+
function applyState(base, state, fallbackFile, pidAlive) {
|
|
261
361
|
if (!state)
|
|
262
|
-
return { ...base, status:
|
|
362
|
+
return { ...base, status: resolveFallbackStatus(fallbackFile, pidAlive) };
|
|
263
363
|
return {
|
|
264
364
|
...base,
|
|
265
365
|
status: statusFromActivity(state.activity),
|
|
@@ -361,7 +461,8 @@ export async function listTeamsActive() {
|
|
|
361
461
|
const sessionId = a.parentSessionId ?? a.remoteSessionId ?? undefined;
|
|
362
462
|
const sessionFile = findSessionFileForKind(a.agentType, a.cwd ?? undefined, sessionId ?? undefined);
|
|
363
463
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
364
|
-
const
|
|
464
|
+
const pidAlive = a.pid ? isPidAlive(a.pid) : true;
|
|
465
|
+
const { state, tokPerSec } = computeLiveSignals(a.agentType, sessionFile, a.cwd ?? undefined, pidAlive);
|
|
365
466
|
return applyState({
|
|
366
467
|
context: 'teams',
|
|
367
468
|
kind: a.agentType,
|
|
@@ -373,9 +474,10 @@ export async function listTeamsActive() {
|
|
|
373
474
|
tokPerSec,
|
|
374
475
|
sessionFile,
|
|
375
476
|
startedAtMs: a.startedAt.getTime(),
|
|
477
|
+
lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
|
|
376
478
|
teamName: a.taskName,
|
|
377
479
|
agentId: a.agentId,
|
|
378
|
-
}, state, sessionFile);
|
|
480
|
+
}, state, sessionFile, pidAlive);
|
|
379
481
|
});
|
|
380
482
|
}
|
|
381
483
|
/** Live editor-terminal agents across every IDE window. */
|
|
@@ -411,7 +513,8 @@ export async function listTerminalsActive() {
|
|
|
411
513
|
const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
|
|
412
514
|
// Extract topic from session file (first meaningful user message)
|
|
413
515
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
414
|
-
const
|
|
516
|
+
const pidAlive = isPidAlive(t.pid, t.startedAtMs);
|
|
517
|
+
const { state, tokPerSec } = computeLiveSignals(t.kind, sessionFile, t.cwd ?? undefined, pidAlive);
|
|
415
518
|
return applyState({
|
|
416
519
|
context: 'terminal',
|
|
417
520
|
kind: t.kind,
|
|
@@ -426,8 +529,9 @@ export async function listTerminalsActive() {
|
|
|
426
529
|
tokPerSec,
|
|
427
530
|
sessionFile,
|
|
428
531
|
startedAtMs: t.startedAtMs,
|
|
532
|
+
lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
|
|
429
533
|
windowId: t.windowId,
|
|
430
|
-
}, state, sessionFile);
|
|
534
|
+
}, state, sessionFile, pidAlive);
|
|
431
535
|
});
|
|
432
536
|
}
|
|
433
537
|
/** Cloud tasks still in a non-terminal state. `tasks.db` may not exist; that's fine. */
|
|
@@ -485,7 +589,7 @@ async function readProcessTable() {
|
|
|
485
589
|
return readProcessTableWin32();
|
|
486
590
|
let out;
|
|
487
591
|
try {
|
|
488
|
-
({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
|
|
592
|
+
({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
|
|
489
593
|
}
|
|
490
594
|
catch {
|
|
491
595
|
return [];
|
|
@@ -519,7 +623,7 @@ async function readProcessTableWin32() {
|
|
|
519
623
|
({ stdout: out } = await execFileAsync('powershell.exe', [
|
|
520
624
|
'-NoProfile', '-NonInteractive', '-Command',
|
|
521
625
|
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation',
|
|
522
|
-
], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true }));
|
|
626
|
+
], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
|
|
523
627
|
}
|
|
524
628
|
catch {
|
|
525
629
|
return [];
|
|
@@ -581,6 +685,7 @@ async function getCwdForPid(pid) {
|
|
|
581
685
|
try {
|
|
582
686
|
const res = await execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
|
|
583
687
|
encoding: 'utf8',
|
|
688
|
+
timeout: LSOF_TIMEOUT_MS,
|
|
584
689
|
});
|
|
585
690
|
out = res.stdout;
|
|
586
691
|
}
|
|
@@ -763,6 +868,33 @@ export async function listUnattributedActive(attributed) {
|
|
|
763
868
|
// Bounded + staggered lsof probes: same cwds, but a trickle of spawns instead
|
|
764
869
|
// of one simultaneous system-wide burst that behavioral EDR flags as recon.
|
|
765
870
|
const cwds = await resolveCwds(kept.map(c => c.pid));
|
|
871
|
+
// The hook state dir is scanned at most ONCE per active-scan, and the ppid map
|
|
872
|
+
// is inverted at most once — both built lazily on the first candidate that
|
|
873
|
+
// lacks an exact launch-time id, so an all-Claude set does neither. The ~3s
|
|
874
|
+
// poll must not re-read the dir (or re-invert the map) per candidate.
|
|
875
|
+
let hookIndex;
|
|
876
|
+
let childrenByParent;
|
|
877
|
+
// Durable `agents run --name` handles keyed by session id — the same source the
|
|
878
|
+
// terminal path uses to name a row. Headless agents have no live-terminals
|
|
879
|
+
// label and no /rename, so without this a `--name`d headless run would surface
|
|
880
|
+
// with only a topic and no tab title. Built once per scan.
|
|
881
|
+
const runNameMap = buildRunNameMap();
|
|
882
|
+
const ensureChildren = () => {
|
|
883
|
+
if (childrenByParent)
|
|
884
|
+
return childrenByParent;
|
|
885
|
+
const m = new Map();
|
|
886
|
+
// The hook records under the agent pid; a wrapper/shell pid we recorded has
|
|
887
|
+
// the agent as a child, so we resolve via a recorded pid's immediate children.
|
|
888
|
+
for (const [childPid, parentPid] of ppidMap) {
|
|
889
|
+
const arr = m.get(parentPid);
|
|
890
|
+
if (arr)
|
|
891
|
+
arr.push(childPid);
|
|
892
|
+
else
|
|
893
|
+
m.set(parentPid, [childPid]);
|
|
894
|
+
}
|
|
895
|
+
childrenByParent = m;
|
|
896
|
+
return m;
|
|
897
|
+
};
|
|
766
898
|
const out = [];
|
|
767
899
|
for (let i = 0; i < kept.length; i++) {
|
|
768
900
|
const { pid, kind } = kept[i];
|
|
@@ -772,12 +904,45 @@ export async function listUnattributedActive(attributed) {
|
|
|
772
904
|
// .jsonl. The shim's entry may sit on a wrapper ancestor (Windows .cmd
|
|
773
905
|
// path). Absent entirely (direct launch outside agents-cli) → heuristic.
|
|
774
906
|
const entry = readPidSessionEntry(pid) ?? readAncestorSessionEntry(pid, ppidMap, kind);
|
|
907
|
+
// Exact session id, in priority: (1) the id we recorded at launch (Claude,
|
|
908
|
+
// known up front via --session-id); (2) the agent's OWN SessionStart hook,
|
|
909
|
+
// authoritative for non-Claude and for agents we didn't launch, joined by
|
|
910
|
+
// launchId/terminalId/pid and kind-guarded against a stale reused-pid file;
|
|
911
|
+
// (3) the newest-jsonl heuristic (sessionIdFromFile, below).
|
|
912
|
+
let exactId = entry?.sessionId;
|
|
913
|
+
// The hook record (when we fall to it) also carries the SessionStart `ts` — the
|
|
914
|
+
// real session-start epoch. Capture it so terminal/headless rows get a
|
|
915
|
+
// `startedAtMs` instead of rendering "0s ago" (they set none before this).
|
|
916
|
+
let hookRec;
|
|
917
|
+
if (!exactId) {
|
|
918
|
+
hookIndex ??= loadHookSessionIndex();
|
|
919
|
+
hookRec = resolveHookSessionRecord(hookIndex, {
|
|
920
|
+
pid,
|
|
921
|
+
kind,
|
|
922
|
+
launchId: entry?.launchId,
|
|
923
|
+
terminalId: entry?.terminalId,
|
|
924
|
+
childPids: ensureChildren().get(pid),
|
|
925
|
+
});
|
|
926
|
+
exactId = hookRec?.session_id;
|
|
927
|
+
}
|
|
775
928
|
const cwd = cwds[i] ?? entry?.cwd ?? undefined;
|
|
776
|
-
const sessionFile = findSessionFileForKind(kind, cwd,
|
|
929
|
+
const sessionFile = findSessionFileForKind(kind, cwd, exactId);
|
|
777
930
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
778
931
|
const host = detectHost(pid, procByPid);
|
|
779
932
|
const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
|
|
933
|
+
// pidAlive is true by construction: this pid was just enumerated from the
|
|
934
|
+
// live process table, so an opaque (non-parseable) kind resolves to
|
|
935
|
+
// `unknown`, not a fake `idle`.
|
|
780
936
|
const { state, tokPerSec } = computeLiveSignals(kind, sessionFile, cwd, true);
|
|
937
|
+
const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
|
|
938
|
+
// Durable run name from `agents run --name`, resolved by the run's session id
|
|
939
|
+
// — the tab-title handle for a run we launched by name. A headless row carries
|
|
940
|
+
// no /rename label and no live-terminals label, so this handle IS its label
|
|
941
|
+
// (mirrors listTeamsActive `label: a.name`). Absent a `--name`, label stays
|
|
942
|
+
// undefined and the display falls back to the topic on its own — no band-aid.
|
|
943
|
+
const resolvedId = exactId ?? sessionIdFromFile(sessionFile);
|
|
944
|
+
const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
|
|
945
|
+
const label = name;
|
|
781
946
|
out.push(applyState({
|
|
782
947
|
context,
|
|
783
948
|
kind,
|
|
@@ -785,25 +950,73 @@ export async function listUnattributedActive(attributed) {
|
|
|
785
950
|
tty: procByPid.get(pid)?.tty,
|
|
786
951
|
pid,
|
|
787
952
|
cwd,
|
|
788
|
-
sessionId:
|
|
953
|
+
sessionId: resolvedId,
|
|
954
|
+
label,
|
|
955
|
+
name,
|
|
789
956
|
topic,
|
|
790
957
|
tokPerSec,
|
|
791
958
|
sessionFile,
|
|
959
|
+
// Session start: the hook's authoritative SessionStart `ts`, else the
|
|
960
|
+
// transcript's creation time. Last activity: the transcript's last write.
|
|
961
|
+
startedAtMs: hookRec?.ts ?? birthtimeMs,
|
|
962
|
+
lastActivityMs: mtimeMs,
|
|
792
963
|
pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
|
|
793
|
-
}, state, sessionFile));
|
|
964
|
+
}, state, sessionFile, true));
|
|
794
965
|
}
|
|
795
966
|
// Housekeeping: drop registry files for pids that have since died.
|
|
796
967
|
prunePidSessionRegistry(isPidAlive);
|
|
797
968
|
return out;
|
|
798
969
|
}
|
|
970
|
+
/**
|
|
971
|
+
* Attribute a single tmux pane to the agent actually running in it.
|
|
972
|
+
*
|
|
973
|
+
* The launch registry — written per bare-spawn AND per wrap, each stamped with the
|
|
974
|
+
* `tmuxPane` it targeted (see src/lib/exec.ts) — is the EXACT, per-pane source of
|
|
975
|
+
* truth. So an agent spawned into an EXISTING pane (a split, where `$TMUX` is
|
|
976
|
+
* already set so no new session meta is stamped) is attributed to its OWN launch,
|
|
977
|
+
* not the session's original agent — closing the gap where such an agent was
|
|
978
|
+
* dropped by this source and left to the weaker ps-scan fallback. Session-meta
|
|
979
|
+
* labels remain the fallback for the wrapped origin pane of a session whose
|
|
980
|
+
* registry entry is absent (a failed best-effort write, or a legacy session that
|
|
981
|
+
* predates the registry's `tmuxPane` field) — and ONLY for that origin pane
|
|
982
|
+
* (`meta.pane`), so a split shell pane of a labeled session isn't mis-attributed
|
|
983
|
+
* the wrapped agent. When `meta.pane` is unknown (attach-existing sessions), any
|
|
984
|
+
* labeled pane is accepted and the caller's per-session dedupe keeps one.
|
|
985
|
+
* `source: 'teams'` panes are skipped — teammates are surfaced by listTeamsActive.
|
|
986
|
+
* Pure so it is unit-tested without tmux.
|
|
987
|
+
*/
|
|
988
|
+
export function resolvePaneIdentity(pane, meta, liveEntry, getHookIndex) {
|
|
989
|
+
if (meta?.source === 'teams')
|
|
990
|
+
return undefined;
|
|
991
|
+
if (liveEntry) {
|
|
992
|
+
// Exact id: the id recorded at launch (Claude), else the agent's own
|
|
993
|
+
// SessionStart hook joined by launchId/terminalId (non-Claude, or agents we
|
|
994
|
+
// didn't launch) — kind-guarded against a stale reused-pid file.
|
|
995
|
+
const sessionId = liveEntry.sessionId
|
|
996
|
+
?? resolveHookSessionRecord(getHookIndex(), {
|
|
997
|
+
pid: liveEntry.pid,
|
|
998
|
+
kind: liveEntry.agent,
|
|
999
|
+
launchId: liveEntry.launchId,
|
|
1000
|
+
terminalId: liveEntry.terminalId,
|
|
1001
|
+
})?.session_id;
|
|
1002
|
+
return { agent: liveEntry.agent, sessionId, pid: liveEntry.pid };
|
|
1003
|
+
}
|
|
1004
|
+
const agent = meta?.labels?.agent;
|
|
1005
|
+
const sessionId = meta?.labels?.sessionId;
|
|
1006
|
+
if (agent && sessionId && (meta?.pane == null || meta.pane === pane))
|
|
1007
|
+
return { agent, sessionId };
|
|
1008
|
+
return undefined;
|
|
1009
|
+
}
|
|
799
1010
|
/**
|
|
800
1011
|
* Agents hosted in the shared-socket tmux server — the authoritative source for
|
|
801
|
-
* tmux-
|
|
802
|
-
* every pane on the shared socket and
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
1012
|
+
* tmux-hosted interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
|
|
1013
|
+
* every pane on the shared socket and attributes each to the agent running in it
|
|
1014
|
+
* via {@link resolvePaneIdentity}: the per-pane launch registry (exact) first, the
|
|
1015
|
+
* session-meta labels as fallback. Because tmux (not a per-window
|
|
1016
|
+
* `live-terminals.json`) is the source of truth, a tmux-hosted agent is captured
|
|
1017
|
+
* with its exact `%pane` even when the extension registry is stale — INCLUDING an
|
|
1018
|
+
* agent bare-spawned into a split of an existing session, which older logic dropped
|
|
1019
|
+
* (it kept only the first pane per session meta). `source: 'teams'` is skipped.
|
|
807
1020
|
*/
|
|
808
1021
|
export async function listTmuxAgentSessions() {
|
|
809
1022
|
const { getDefaultSocketPath } = await import('../tmux/paths.js');
|
|
@@ -825,6 +1038,21 @@ export async function listTmuxAgentSessions() {
|
|
|
825
1038
|
}
|
|
826
1039
|
if (res.code !== 0)
|
|
827
1040
|
return [];
|
|
1041
|
+
// Index live launches by the pane they target, so a pane we did NOT wrap (a
|
|
1042
|
+
// split) resolves to its own agent. Newest launch wins a pane (pid reuse), and
|
|
1043
|
+
// only live pids count — a dead agent's stale entry can't light up its old pane.
|
|
1044
|
+
const liveByPane = new Map();
|
|
1045
|
+
for (const e of listPidSessionEntries()) {
|
|
1046
|
+
if (!e.tmuxPane || !isPidAlive(e.pid, e.startedAtMs))
|
|
1047
|
+
continue;
|
|
1048
|
+
const prev = liveByPane.get(e.tmuxPane);
|
|
1049
|
+
if (!prev || e.startedAtMs > prev.startedAtMs)
|
|
1050
|
+
liveByPane.set(e.tmuxPane, e);
|
|
1051
|
+
}
|
|
1052
|
+
// Hook index is scanned lazily — only when a live launch lacks a recorded id
|
|
1053
|
+
// (a non-Claude split) and needs the SessionStart-hook join.
|
|
1054
|
+
let hookIndex;
|
|
1055
|
+
const getHookIndex = () => (hookIndex ??= loadHookSessionIndex());
|
|
828
1056
|
const out = [];
|
|
829
1057
|
const seen = new Set();
|
|
830
1058
|
for (const line of res.stdout.split('\n')) {
|
|
@@ -834,22 +1062,31 @@ export async function listTmuxAgentSessions() {
|
|
|
834
1062
|
if (!pane || !sessName)
|
|
835
1063
|
continue;
|
|
836
1064
|
const meta = readSessionMeta(sessName);
|
|
837
|
-
const
|
|
838
|
-
const
|
|
839
|
-
if (!
|
|
840
|
-
continue;
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1065
|
+
const liveEntry = liveByPane.get(pane);
|
|
1066
|
+
const id = resolvePaneIdentity(pane, meta, liveEntry, getHookIndex);
|
|
1067
|
+
if (!id)
|
|
1068
|
+
continue;
|
|
1069
|
+
// Dedupe by resolved session id; an as-yet-unresolved id (a hookless/lagging
|
|
1070
|
+
// split) keys on the unique pane so it still surfaces as its own row.
|
|
1071
|
+
const dedupKey = id.sessionId ?? pane;
|
|
1072
|
+
if (seen.has(dedupKey))
|
|
1073
|
+
continue;
|
|
1074
|
+
seen.add(dedupKey);
|
|
1075
|
+
// Prefer the registry pid (the agent), falling back to the pane leaf pid. A
|
|
1076
|
+
// pid we emit here goes into getActiveSessions' attributed set, so the ps-scan
|
|
1077
|
+
// does NOT also surface this agent as a duplicate headless row.
|
|
1078
|
+
const pid = id.pid ?? (parseInt(pidRaw, 10) || undefined);
|
|
1079
|
+
const cwd = liveEntry?.cwd ?? meta?.cwd ?? (curPath || undefined);
|
|
1080
|
+
const sessionFile = findSessionFileForKind(id.agent, cwd, id.sessionId);
|
|
849
1081
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
1082
|
+
const pidAlive = pid ? isPidAlive(pid, liveEntry?.startedAtMs) : true;
|
|
1083
|
+
const { state, tokPerSec } = computeLiveSignals(id.agent, sessionFile, cwd, pidAlive);
|
|
1084
|
+
const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
|
|
1085
|
+
// The mux/reply rails are known exactly here (the pane IS a tmux pane), so we
|
|
1086
|
+
// stamp them off the pane. `transport:'local'` is only a placeholder: the pane
|
|
1087
|
+
// can't reveal how the shell above it was reached. enrichProvenance later reads
|
|
1088
|
+
// the pane process's env and upgrades this to 'ssh' (with the real origin) when
|
|
1089
|
+
// SSH_CONNECTION is present, while preserving this mux/reply.
|
|
853
1090
|
const provenance = {
|
|
854
1091
|
host: os.hostname(),
|
|
855
1092
|
transport: 'local',
|
|
@@ -858,16 +1095,20 @@ export async function listTmuxAgentSessions() {
|
|
|
858
1095
|
};
|
|
859
1096
|
out.push(applyState({
|
|
860
1097
|
context: 'terminal',
|
|
861
|
-
kind: agent,
|
|
1098
|
+
kind: id.agent,
|
|
862
1099
|
host: 'tmux',
|
|
863
1100
|
pid,
|
|
864
|
-
sessionId,
|
|
1101
|
+
sessionId: id.sessionId ?? sessionIdFromFile(sessionFile),
|
|
865
1102
|
cwd,
|
|
866
1103
|
topic,
|
|
867
1104
|
tokPerSec,
|
|
868
1105
|
sessionFile,
|
|
1106
|
+
// tmux panes carry no start timestamp; derive both from the transcript
|
|
1107
|
+
// (creation ≈ start, last write ≈ last activity).
|
|
1108
|
+
startedAtMs: birthtimeMs,
|
|
1109
|
+
lastActivityMs: mtimeMs,
|
|
869
1110
|
provenance,
|
|
870
|
-
}, state, sessionFile));
|
|
1111
|
+
}, state, sessionFile, pidAlive));
|
|
871
1112
|
}
|
|
872
1113
|
return out;
|
|
873
1114
|
}
|
|
@@ -898,34 +1139,127 @@ export async function getActiveSessions(opts = {}) {
|
|
|
898
1139
|
const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
|
|
899
1140
|
const merged = dedupeBySession([...tmuxAgents, ...teams, ...terminals, ...cloud, ...unattributed]);
|
|
900
1141
|
await enrichProvenance(merged);
|
|
1142
|
+
await resolveOrigins(merged);
|
|
1143
|
+
foldPresence(merged);
|
|
901
1144
|
return merged;
|
|
902
1145
|
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Fold detach/attach presence onto each row from the detach store. A stored
|
|
1148
|
+
* record wins (`background`/`parked`); otherwise a live terminal session is
|
|
1149
|
+
* `attached`. Ad-hoc headless runs and cloud/team rows stay unmarked — they are
|
|
1150
|
+
* not on the foreground/background axis.
|
|
1151
|
+
*/
|
|
1152
|
+
function foldPresence(rows) {
|
|
1153
|
+
for (const s of rows) {
|
|
1154
|
+
if (!s.sessionId)
|
|
1155
|
+
continue;
|
|
1156
|
+
const stored = presenceFromStore(s.sessionId);
|
|
1157
|
+
if (stored)
|
|
1158
|
+
s.presence = stored;
|
|
1159
|
+
else if (s.context === 'terminal')
|
|
1160
|
+
s.presence = 'attached';
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
903
1163
|
/**
|
|
904
1164
|
* Attach provenance (host / local-vs-SSH / tmux pane / reply rail) to every
|
|
905
1165
|
* session that has a live pid. Mutates in place. Runs after dedupe so we probe
|
|
906
1166
|
* each session once, not once per fork pid. Probes run in parallel — each is a
|
|
907
1167
|
* single /proc read (Linux) or `ps` call (macOS); failures leave `provenance`
|
|
908
1168
|
* undefined rather than blocking the listing.
|
|
1169
|
+
*
|
|
1170
|
+
* A row that already carries provenance (the tmux path, which knows its exact
|
|
1171
|
+
* mux/reply from the pane) is not skipped — it is probe-and-MERGED. The tmux
|
|
1172
|
+
* path can only stamp a `transport:'local'` placeholder because the pane alone
|
|
1173
|
+
* doesn't reveal how the shell above it was reached; the process env does. So we
|
|
1174
|
+
* still read the env and fill in the real SSH origin/term, while preserving the
|
|
1175
|
+
* authoritative mux/reply the pane already gave us. Skipping this (the old
|
|
1176
|
+
* behavior) is exactly why ssh-launched tmux sessions rendered as local.
|
|
909
1177
|
*/
|
|
910
1178
|
async function enrichProvenance(sessions) {
|
|
911
1179
|
await Promise.all(sessions.map(async (s) => {
|
|
912
|
-
if (
|
|
1180
|
+
if (!s.pid)
|
|
913
1181
|
return;
|
|
914
|
-
|
|
1182
|
+
const probed = await detectProvenance(s.pid);
|
|
1183
|
+
if (!probed)
|
|
1184
|
+
return;
|
|
1185
|
+
if (!s.provenance) {
|
|
1186
|
+
s.provenance = probed;
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
// Row already carries exact mux/reply (the tmux path). Fill only what a
|
|
1190
|
+
// pre-set provenance can't know from the pane alone: the real launch origin.
|
|
1191
|
+
if (probed.transport === 'ssh' && !s.provenance.ssh) {
|
|
1192
|
+
s.provenance.transport = 'ssh';
|
|
1193
|
+
s.provenance.ssh = probed.ssh;
|
|
1194
|
+
}
|
|
1195
|
+
if (probed.term && !s.provenance.term)
|
|
1196
|
+
s.provenance.term = probed.term;
|
|
915
1197
|
}));
|
|
916
1198
|
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Match an SSH client IP to a registered device (pure — testable with a plain
|
|
1201
|
+
* registry object). Returns the device name + ssh login user when the IP is a
|
|
1202
|
+
* known device address.
|
|
1203
|
+
*/
|
|
1204
|
+
export function matchOriginDevice(clientIp, reg) {
|
|
1205
|
+
for (const d of Object.values(reg)) {
|
|
1206
|
+
if (d.address?.ip && d.address.ip === clientIp) {
|
|
1207
|
+
return { device: d.name, ...(d.user ? { user: d.user } : {}) };
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return undefined;
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Resolve the initiating device for every ssh-transport session by matching its
|
|
1214
|
+
* `ssh.clientIp` against the device registry. Read-only and best-effort: a
|
|
1215
|
+
* registry that can't be loaded, or an IP that matches no device, leaves
|
|
1216
|
+
* `origin` undefined (the raw client IP is still on `ssh`). Mutates in place.
|
|
1217
|
+
*/
|
|
1218
|
+
async function resolveOrigins(sessions) {
|
|
1219
|
+
const needing = sessions.filter((s) => s.provenance?.ssh && !s.provenance.origin);
|
|
1220
|
+
if (needing.length === 0)
|
|
1221
|
+
return;
|
|
1222
|
+
let reg;
|
|
1223
|
+
try {
|
|
1224
|
+
reg = await loadDevices();
|
|
1225
|
+
}
|
|
1226
|
+
catch {
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
for (const s of needing) {
|
|
1230
|
+
const match = matchOriginDevice(s.provenance.ssh.clientIp, reg);
|
|
1231
|
+
if (match)
|
|
1232
|
+
s.provenance.origin = match;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Identity for a row the scan could not tie to a session: a daemon's worker
|
|
1237
|
+
* processes (an OpenClaw gateway spawning `codex`, a supervisor pool) have no
|
|
1238
|
+
* session id, no transcript file, and no cloud/run handle — nothing tells two of
|
|
1239
|
+
* them apart, because nothing distinguishes them. Same binary + same working
|
|
1240
|
+
* directory + same context IS the identity, so N indistinguishable workers
|
|
1241
|
+
* collapse to one row carrying `pidCount: N`.
|
|
1242
|
+
*
|
|
1243
|
+
* Returns undefined with no cwd: without it there is no stable identity, and
|
|
1244
|
+
* keying on kind alone would fold unrelated agents onto one row.
|
|
1245
|
+
*/
|
|
1246
|
+
function anonymousWorkerKey(s) {
|
|
1247
|
+
if (!s.cwd)
|
|
1248
|
+
return undefined;
|
|
1249
|
+
return `anon\0${s.kind}\0${s.context}\0${s.cwd}`;
|
|
1250
|
+
}
|
|
917
1251
|
/**
|
|
918
1252
|
* Collapse rows that resolve to the *same* session — a session with many
|
|
919
1253
|
* subagent/fork PIDs (all matched to one transcript file) would otherwise print
|
|
920
|
-
* dozens of identical rows. Keyed by session id
|
|
921
|
-
*
|
|
922
|
-
*
|
|
1254
|
+
* dozens of identical rows. Keyed by session id, falling back to the transcript
|
|
1255
|
+
* file, then the cloud/run handle, then {@link anonymousWorkerKey}. The first row
|
|
1256
|
+
* wins and carries a `pidCount`.
|
|
923
1257
|
*/
|
|
924
|
-
function dedupeBySession(sessions) {
|
|
1258
|
+
export function dedupeBySession(sessions) {
|
|
925
1259
|
const out = [];
|
|
926
1260
|
const byKey = new Map();
|
|
927
1261
|
for (const s of sessions) {
|
|
928
|
-
const key = s.sessionId || s.sessionFile;
|
|
1262
|
+
const key = s.sessionId || s.sessionFile || s.cloudTaskId || s.agentId || anonymousWorkerKey(s);
|
|
929
1263
|
if (!key) {
|
|
930
1264
|
out.push(s);
|
|
931
1265
|
continue;
|