@phnx-labs/agents-cli 1.20.74 → 1.20.76
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 +893 -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 +31 -3
- 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 +15 -2
- package/dist/commands/secrets.js +44 -21
- package/dist/commands/sessions-browser.js +1 -1
- 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.js +16 -1
- package/dist/commands/sessions.d.ts +36 -13
- package/dist/commands/sessions.js +210 -40
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.js +71 -35
- 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 +58 -7
- package/dist/lib/activity.d.ts +14 -4
- package/dist/lib/activity.js +17 -5
- 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.js +18 -2
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +21 -0
- package/dist/lib/devices/fleet.js +26 -0
- 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 +66 -0
- package/dist/lib/exec.js +195 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- 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/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +2 -0
- package/dist/lib/hosts/passthrough.js +2 -0
- 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.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +13 -5
- package/dist/lib/hosts/run-target.js +26 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +37 -2
- 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 +20 -0
- package/dist/lib/menubar/install-menubar.js +45 -1
- 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 +7 -2
- package/dist/lib/overdue.js +18 -11
- 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/routines.d.ts +26 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +104 -7
- package/dist/lib/secrets/agent.d.ts +66 -1
- package/dist/lib/secrets/agent.js +154 -90
- package/dist/lib/secrets/bundles.d.ts +34 -0
- package/dist/lib/secrets/bundles.js +179 -19
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +21 -3
- 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/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -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 +114 -7
- package/dist/lib/session/active.js +323 -52
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +48 -0
- package/dist/lib/session/db.js +238 -14
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +465 -2
- package/dist/lib/session/discover.js +1648 -468
- 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 +130 -30
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +22 -10
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.js +6 -2
- 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 +1 -1
- package/dist/lib/state.js +1 -32
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +37 -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,7 @@ 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 { presenceFromStore } from './detached.js';
|
|
36
38
|
import { mapBounded } from '../concurrency.js';
|
|
37
39
|
const execFileAsync = promisify(execFile);
|
|
38
40
|
/**
|
|
@@ -43,6 +45,15 @@ const execFileAsync = promisify(execFile);
|
|
|
43
45
|
*/
|
|
44
46
|
export const LSOF_CONCURRENCY = 4;
|
|
45
47
|
const LSOF_STAGGER_MS = 10;
|
|
48
|
+
/**
|
|
49
|
+
* Hard ceilings on the two syscalls the status path shells out to. Without them
|
|
50
|
+
* a single hung probe (a wedged NFS `lsof`, an EDR that stalls the `ps` snapshot)
|
|
51
|
+
* pins a bounded worker slot forever and silently drops live sessions to a
|
|
52
|
+
* fallback status. On timeout the call rejects, is caught, and the row degrades
|
|
53
|
+
* honestly (unknown / empty table) instead of the sweep hanging.
|
|
54
|
+
*/
|
|
55
|
+
const LSOF_TIMEOUT_MS = 5_000;
|
|
56
|
+
const PS_SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
46
57
|
export function activeStatusFromCloudStatus(status) {
|
|
47
58
|
switch (status) {
|
|
48
59
|
case 'running':
|
|
@@ -102,16 +113,72 @@ export function agentKindFromComm(commRaw) {
|
|
|
102
113
|
const key = stripped === base ? base : stripped.toLowerCase();
|
|
103
114
|
return AGENT_CLI_NAMES[key];
|
|
104
115
|
}
|
|
105
|
-
|
|
116
|
+
/**
|
|
117
|
+
* A process that began more than this long AFTER a session's recorded
|
|
118
|
+
* `startedAtMs` cannot be that session's process — the OS handed its pid to
|
|
119
|
+
* something newer. The window absorbs clock granularity (`ps -o lstart=` reports
|
|
120
|
+
* whole seconds) and the gap between a process spawning and the SessionStart
|
|
121
|
+
* hook recording `startedAtMs`; it is far below the minutes-to-hours it takes the
|
|
122
|
+
* pid space to wrap and actually recycle a pid, so it never false-kills a live
|
|
123
|
+
* session.
|
|
124
|
+
*/
|
|
125
|
+
const PID_REUSE_TOLERANCE_MS = 60_000;
|
|
126
|
+
/**
|
|
127
|
+
* Epoch-ms start time of the process at `pid`, or null if unknowable.
|
|
128
|
+
*
|
|
129
|
+
* Distinct from teams/agents.ts's `captureProcessStartTime`, which returns an
|
|
130
|
+
* opaque token only meaningful for equality against a prior capture of the SAME
|
|
131
|
+
* pid. Here we need a value comparable to a session's `startedAtMs`, so we read
|
|
132
|
+
* `ps -o lstart=` — a ctime string on both macOS and Linux — and parse it to
|
|
133
|
+
* epoch ms. Windows and any exec/parse failure return null, so the caller falls
|
|
134
|
+
* back to a bare existence check (never worse than before).
|
|
135
|
+
*/
|
|
136
|
+
function processStartMs(pid) {
|
|
137
|
+
if (process.platform === 'win32')
|
|
138
|
+
return null;
|
|
139
|
+
try {
|
|
140
|
+
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
141
|
+
encoding: 'utf-8',
|
|
142
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
143
|
+
}).trim();
|
|
144
|
+
if (!out)
|
|
145
|
+
return null;
|
|
146
|
+
const ms = Date.parse(out);
|
|
147
|
+
return Number.isFinite(ms) ? ms : null;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* True when `pid` names a live process AND — when a session's recorded
|
|
155
|
+
* `startedAtMs` is supplied — that process is plausibly the SAME one, not a later
|
|
156
|
+
* process that recycled the pid. The OS reuses pids, so a bare
|
|
157
|
+
* `process.kill(pid, 0)` existence check reports a dead session as alive (a
|
|
158
|
+
* "zombie") once its pid is handed to an unrelated process. A genuine session
|
|
159
|
+
* process starts at or before its own recorded start, so a process that began
|
|
160
|
+
* meaningfully AFTER `startedAtMs` is a reused pid and the session is dead. When
|
|
161
|
+
* the start time can't be read, we keep the existence answer.
|
|
162
|
+
*/
|
|
163
|
+
export function isPidAlive(pid, startedAtMs) {
|
|
106
164
|
if (!pid || pid < 1)
|
|
107
165
|
return false;
|
|
108
166
|
try {
|
|
109
167
|
process.kill(pid, 0);
|
|
110
|
-
return true;
|
|
111
168
|
}
|
|
112
169
|
catch (err) {
|
|
113
|
-
|
|
170
|
+
// EPERM means the pid exists but is owned by another user — still "alive",
|
|
171
|
+
// fall through to the identity check. Any other error means no such process.
|
|
172
|
+
if (err?.code !== 'EPERM')
|
|
173
|
+
return false;
|
|
114
174
|
}
|
|
175
|
+
if (startedAtMs && startedAtMs > 0) {
|
|
176
|
+
const procStartMs = processStartMs(pid);
|
|
177
|
+
if (procStartMs !== null && procStartMs > startedAtMs + PID_REUSE_TOLERANCE_MS) {
|
|
178
|
+
return false; // pid recycled by a newer process — this session is gone
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
115
182
|
}
|
|
116
183
|
/** Read the live-terminals registry, dedupe by sessionId, keep only pid-alive entries. */
|
|
117
184
|
function readLiveTerminals() {
|
|
@@ -134,7 +201,7 @@ function readLiveTerminals() {
|
|
|
134
201
|
const merged = new Map();
|
|
135
202
|
for (const [windowId, slice] of Object.entries(parsed)) {
|
|
136
203
|
for (const e of (slice?.entries ?? [])) {
|
|
137
|
-
if (!e?.sessionId || !isPidAlive(e.pid))
|
|
204
|
+
if (!e?.sessionId || !isPidAlive(e.pid, e.startedAtMs))
|
|
138
205
|
continue;
|
|
139
206
|
merged.set(e.sessionId, { ...e, windowId });
|
|
140
207
|
}
|
|
@@ -189,19 +256,49 @@ export function pickSessionFile(projectDir, sessionId) {
|
|
|
189
256
|
}
|
|
190
257
|
return best?.path;
|
|
191
258
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
259
|
+
/**
|
|
260
|
+
* One `stat` → the transcript's creation (≈ session start) and last-write (≈ last
|
|
261
|
+
* activity) epochs. Both `undefined` when the file can't be stat'd (vanished /
|
|
262
|
+
* unresolved). `birthtimeMs` can be 0 on filesystems without creation time — coerce
|
|
263
|
+
* that to `undefined` so callers fall through to a real signal instead of epoch 0.
|
|
264
|
+
*/
|
|
265
|
+
export function sessionFileTimes(sessionFile) {
|
|
266
|
+
if (!sessionFile)
|
|
267
|
+
return {};
|
|
268
|
+
try {
|
|
269
|
+
const st = fs.statSync(sessionFile);
|
|
270
|
+
return { birthtimeMs: st.birthtimeMs || undefined, mtimeMs: st.mtimeMs || undefined };
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return {};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The ONE place a fallback status is decided when no rich transcript state is
|
|
278
|
+
* available — a non-Claude/Codex kind we cannot parse, or a Claude/Codex tail
|
|
279
|
+
* that was empty or unreadable. Honest by construction: it never asserts a status
|
|
280
|
+
* it cannot justify from a measured signal.
|
|
281
|
+
*
|
|
282
|
+
* - Resolvable transcript, readable mtime → the MEASURED freshness signal:
|
|
283
|
+
* written within ACTIVE_MTIME_WINDOW_MS ⇒ `running`, else `idle`.
|
|
284
|
+
* - Resolvable transcript whose `stat` throws (file vanished / permission) → we
|
|
285
|
+
* genuinely cannot tell ⇒ `unknown`. (This branch previously returned
|
|
286
|
+
* `running`, which contradicted the `idle` default one branch up.)
|
|
287
|
+
* - No resolvable transcript but the process is alive → alive-but-opaque ⇒
|
|
288
|
+
* `unknown`. This is the truthful answer for a live gemini / droid / cursor /
|
|
289
|
+
* opencode whose format we don't parse — NOT a fabricated `idle` (which the
|
|
290
|
+
* UI reads as "done and waiting"), and it never lies as `running` either.
|
|
291
|
+
* - No transcript and the process is not known alive → nothing to report ⇒ `idle`.
|
|
292
|
+
*/
|
|
293
|
+
export function resolveFallbackStatus(sessionFile, pidAlive) {
|
|
197
294
|
if (!sessionFile)
|
|
198
|
-
return 'idle';
|
|
295
|
+
return pidAlive ? 'unknown' : 'idle';
|
|
199
296
|
try {
|
|
200
297
|
const mtimeMs = fs.statSync(sessionFile).mtimeMs;
|
|
201
298
|
return Date.now() - mtimeMs < ACTIVE_MTIME_WINDOW_MS ? 'running' : 'idle';
|
|
202
299
|
}
|
|
203
300
|
catch {
|
|
204
|
-
return '
|
|
301
|
+
return 'unknown';
|
|
205
302
|
}
|
|
206
303
|
}
|
|
207
304
|
/**
|
|
@@ -255,11 +352,13 @@ function statusFromActivity(activity) {
|
|
|
255
352
|
/**
|
|
256
353
|
* Fold a computed SessionState onto an active-session row: rich status +
|
|
257
354
|
* preview + PR/worktree/ticket badges. With no state (unreadable/non-Claude/
|
|
258
|
-
* Codex file) it degrades to
|
|
355
|
+
* Codex file) it degrades to {@link resolveFallbackStatus}, which needs
|
|
356
|
+
* `pidAlive` to tell an alive-but-opaque process (`unknown`) from a dead one
|
|
357
|
+
* (`idle`).
|
|
259
358
|
*/
|
|
260
|
-
function applyState(base, state, fallbackFile) {
|
|
359
|
+
function applyState(base, state, fallbackFile, pidAlive) {
|
|
261
360
|
if (!state)
|
|
262
|
-
return { ...base, status:
|
|
361
|
+
return { ...base, status: resolveFallbackStatus(fallbackFile, pidAlive) };
|
|
263
362
|
return {
|
|
264
363
|
...base,
|
|
265
364
|
status: statusFromActivity(state.activity),
|
|
@@ -361,7 +460,8 @@ export async function listTeamsActive() {
|
|
|
361
460
|
const sessionId = a.parentSessionId ?? a.remoteSessionId ?? undefined;
|
|
362
461
|
const sessionFile = findSessionFileForKind(a.agentType, a.cwd ?? undefined, sessionId ?? undefined);
|
|
363
462
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
364
|
-
const
|
|
463
|
+
const pidAlive = a.pid ? isPidAlive(a.pid) : true;
|
|
464
|
+
const { state, tokPerSec } = computeLiveSignals(a.agentType, sessionFile, a.cwd ?? undefined, pidAlive);
|
|
365
465
|
return applyState({
|
|
366
466
|
context: 'teams',
|
|
367
467
|
kind: a.agentType,
|
|
@@ -373,9 +473,10 @@ export async function listTeamsActive() {
|
|
|
373
473
|
tokPerSec,
|
|
374
474
|
sessionFile,
|
|
375
475
|
startedAtMs: a.startedAt.getTime(),
|
|
476
|
+
lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
|
|
376
477
|
teamName: a.taskName,
|
|
377
478
|
agentId: a.agentId,
|
|
378
|
-
}, state, sessionFile);
|
|
479
|
+
}, state, sessionFile, pidAlive);
|
|
379
480
|
});
|
|
380
481
|
}
|
|
381
482
|
/** Live editor-terminal agents across every IDE window. */
|
|
@@ -411,7 +512,8 @@ export async function listTerminalsActive() {
|
|
|
411
512
|
const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
|
|
412
513
|
// Extract topic from session file (first meaningful user message)
|
|
413
514
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
414
|
-
const
|
|
515
|
+
const pidAlive = isPidAlive(t.pid, t.startedAtMs);
|
|
516
|
+
const { state, tokPerSec } = computeLiveSignals(t.kind, sessionFile, t.cwd ?? undefined, pidAlive);
|
|
415
517
|
return applyState({
|
|
416
518
|
context: 'terminal',
|
|
417
519
|
kind: t.kind,
|
|
@@ -426,8 +528,9 @@ export async function listTerminalsActive() {
|
|
|
426
528
|
tokPerSec,
|
|
427
529
|
sessionFile,
|
|
428
530
|
startedAtMs: t.startedAtMs,
|
|
531
|
+
lastActivityMs: sessionFileTimes(sessionFile).mtimeMs,
|
|
429
532
|
windowId: t.windowId,
|
|
430
|
-
}, state, sessionFile);
|
|
533
|
+
}, state, sessionFile, pidAlive);
|
|
431
534
|
});
|
|
432
535
|
}
|
|
433
536
|
/** Cloud tasks still in a non-terminal state. `tasks.db` may not exist; that's fine. */
|
|
@@ -485,7 +588,7 @@ async function readProcessTable() {
|
|
|
485
588
|
return readProcessTableWin32();
|
|
486
589
|
let out;
|
|
487
590
|
try {
|
|
488
|
-
({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
|
|
591
|
+
({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
|
|
489
592
|
}
|
|
490
593
|
catch {
|
|
491
594
|
return [];
|
|
@@ -519,7 +622,7 @@ async function readProcessTableWin32() {
|
|
|
519
622
|
({ stdout: out } = await execFileAsync('powershell.exe', [
|
|
520
623
|
'-NoProfile', '-NonInteractive', '-Command',
|
|
521
624
|
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation',
|
|
522
|
-
], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true }));
|
|
625
|
+
], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, windowsHide: true, timeout: PS_SNAPSHOT_TIMEOUT_MS }));
|
|
523
626
|
}
|
|
524
627
|
catch {
|
|
525
628
|
return [];
|
|
@@ -581,6 +684,7 @@ async function getCwdForPid(pid) {
|
|
|
581
684
|
try {
|
|
582
685
|
const res = await execFileAsync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
|
|
583
686
|
encoding: 'utf8',
|
|
687
|
+
timeout: LSOF_TIMEOUT_MS,
|
|
584
688
|
});
|
|
585
689
|
out = res.stdout;
|
|
586
690
|
}
|
|
@@ -763,6 +867,33 @@ export async function listUnattributedActive(attributed) {
|
|
|
763
867
|
// Bounded + staggered lsof probes: same cwds, but a trickle of spawns instead
|
|
764
868
|
// of one simultaneous system-wide burst that behavioral EDR flags as recon.
|
|
765
869
|
const cwds = await resolveCwds(kept.map(c => c.pid));
|
|
870
|
+
// The hook state dir is scanned at most ONCE per active-scan, and the ppid map
|
|
871
|
+
// is inverted at most once — both built lazily on the first candidate that
|
|
872
|
+
// lacks an exact launch-time id, so an all-Claude set does neither. The ~3s
|
|
873
|
+
// poll must not re-read the dir (or re-invert the map) per candidate.
|
|
874
|
+
let hookIndex;
|
|
875
|
+
let childrenByParent;
|
|
876
|
+
// Durable `agents run --name` handles keyed by session id — the same source the
|
|
877
|
+
// terminal path uses to name a row. Headless agents have no live-terminals
|
|
878
|
+
// label and no /rename, so without this a `--name`d headless run would surface
|
|
879
|
+
// with only a topic and no tab title. Built once per scan.
|
|
880
|
+
const runNameMap = buildRunNameMap();
|
|
881
|
+
const ensureChildren = () => {
|
|
882
|
+
if (childrenByParent)
|
|
883
|
+
return childrenByParent;
|
|
884
|
+
const m = new Map();
|
|
885
|
+
// The hook records under the agent pid; a wrapper/shell pid we recorded has
|
|
886
|
+
// the agent as a child, so we resolve via a recorded pid's immediate children.
|
|
887
|
+
for (const [childPid, parentPid] of ppidMap) {
|
|
888
|
+
const arr = m.get(parentPid);
|
|
889
|
+
if (arr)
|
|
890
|
+
arr.push(childPid);
|
|
891
|
+
else
|
|
892
|
+
m.set(parentPid, [childPid]);
|
|
893
|
+
}
|
|
894
|
+
childrenByParent = m;
|
|
895
|
+
return m;
|
|
896
|
+
};
|
|
766
897
|
const out = [];
|
|
767
898
|
for (let i = 0; i < kept.length; i++) {
|
|
768
899
|
const { pid, kind } = kept[i];
|
|
@@ -772,12 +903,45 @@ export async function listUnattributedActive(attributed) {
|
|
|
772
903
|
// .jsonl. The shim's entry may sit on a wrapper ancestor (Windows .cmd
|
|
773
904
|
// path). Absent entirely (direct launch outside agents-cli) → heuristic.
|
|
774
905
|
const entry = readPidSessionEntry(pid) ?? readAncestorSessionEntry(pid, ppidMap, kind);
|
|
906
|
+
// Exact session id, in priority: (1) the id we recorded at launch (Claude,
|
|
907
|
+
// known up front via --session-id); (2) the agent's OWN SessionStart hook,
|
|
908
|
+
// authoritative for non-Claude and for agents we didn't launch, joined by
|
|
909
|
+
// launchId/terminalId/pid and kind-guarded against a stale reused-pid file;
|
|
910
|
+
// (3) the newest-jsonl heuristic (sessionIdFromFile, below).
|
|
911
|
+
let exactId = entry?.sessionId;
|
|
912
|
+
// The hook record (when we fall to it) also carries the SessionStart `ts` — the
|
|
913
|
+
// real session-start epoch. Capture it so terminal/headless rows get a
|
|
914
|
+
// `startedAtMs` instead of rendering "0s ago" (they set none before this).
|
|
915
|
+
let hookRec;
|
|
916
|
+
if (!exactId) {
|
|
917
|
+
hookIndex ??= loadHookSessionIndex();
|
|
918
|
+
hookRec = resolveHookSessionRecord(hookIndex, {
|
|
919
|
+
pid,
|
|
920
|
+
kind,
|
|
921
|
+
launchId: entry?.launchId,
|
|
922
|
+
terminalId: entry?.terminalId,
|
|
923
|
+
childPids: ensureChildren().get(pid),
|
|
924
|
+
});
|
|
925
|
+
exactId = hookRec?.session_id;
|
|
926
|
+
}
|
|
775
927
|
const cwd = cwds[i] ?? entry?.cwd ?? undefined;
|
|
776
|
-
const sessionFile = findSessionFileForKind(kind, cwd,
|
|
928
|
+
const sessionFile = findSessionFileForKind(kind, cwd, exactId);
|
|
777
929
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
778
930
|
const host = detectHost(pid, procByPid);
|
|
779
931
|
const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
|
|
932
|
+
// pidAlive is true by construction: this pid was just enumerated from the
|
|
933
|
+
// live process table, so an opaque (non-parseable) kind resolves to
|
|
934
|
+
// `unknown`, not a fake `idle`.
|
|
780
935
|
const { state, tokPerSec } = computeLiveSignals(kind, sessionFile, cwd, true);
|
|
936
|
+
const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
|
|
937
|
+
// Durable run name from `agents run --name`, resolved by the run's session id
|
|
938
|
+
// — the tab-title handle for a run we launched by name. A headless row carries
|
|
939
|
+
// no /rename label and no live-terminals label, so this handle IS its label
|
|
940
|
+
// (mirrors listTeamsActive `label: a.name`). Absent a `--name`, label stays
|
|
941
|
+
// undefined and the display falls back to the topic on its own — no band-aid.
|
|
942
|
+
const resolvedId = exactId ?? sessionIdFromFile(sessionFile);
|
|
943
|
+
const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
|
|
944
|
+
const label = name;
|
|
781
945
|
out.push(applyState({
|
|
782
946
|
context,
|
|
783
947
|
kind,
|
|
@@ -785,25 +949,73 @@ export async function listUnattributedActive(attributed) {
|
|
|
785
949
|
tty: procByPid.get(pid)?.tty,
|
|
786
950
|
pid,
|
|
787
951
|
cwd,
|
|
788
|
-
sessionId:
|
|
952
|
+
sessionId: resolvedId,
|
|
953
|
+
label,
|
|
954
|
+
name,
|
|
789
955
|
topic,
|
|
790
956
|
tokPerSec,
|
|
791
957
|
sessionFile,
|
|
958
|
+
// Session start: the hook's authoritative SessionStart `ts`, else the
|
|
959
|
+
// transcript's creation time. Last activity: the transcript's last write.
|
|
960
|
+
startedAtMs: hookRec?.ts ?? birthtimeMs,
|
|
961
|
+
lastActivityMs: mtimeMs,
|
|
792
962
|
pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
|
|
793
|
-
}, state, sessionFile));
|
|
963
|
+
}, state, sessionFile, true));
|
|
794
964
|
}
|
|
795
965
|
// Housekeeping: drop registry files for pids that have since died.
|
|
796
966
|
prunePidSessionRegistry(isPidAlive);
|
|
797
967
|
return out;
|
|
798
968
|
}
|
|
969
|
+
/**
|
|
970
|
+
* Attribute a single tmux pane to the agent actually running in it.
|
|
971
|
+
*
|
|
972
|
+
* The launch registry — written per bare-spawn AND per wrap, each stamped with the
|
|
973
|
+
* `tmuxPane` it targeted (see src/lib/exec.ts) — is the EXACT, per-pane source of
|
|
974
|
+
* truth. So an agent spawned into an EXISTING pane (a split, where `$TMUX` is
|
|
975
|
+
* already set so no new session meta is stamped) is attributed to its OWN launch,
|
|
976
|
+
* not the session's original agent — closing the gap where such an agent was
|
|
977
|
+
* dropped by this source and left to the weaker ps-scan fallback. Session-meta
|
|
978
|
+
* labels remain the fallback for the wrapped origin pane of a session whose
|
|
979
|
+
* registry entry is absent (a failed best-effort write, or a legacy session that
|
|
980
|
+
* predates the registry's `tmuxPane` field) — and ONLY for that origin pane
|
|
981
|
+
* (`meta.pane`), so a split shell pane of a labeled session isn't mis-attributed
|
|
982
|
+
* the wrapped agent. When `meta.pane` is unknown (attach-existing sessions), any
|
|
983
|
+
* labeled pane is accepted and the caller's per-session dedupe keeps one.
|
|
984
|
+
* `source: 'teams'` panes are skipped — teammates are surfaced by listTeamsActive.
|
|
985
|
+
* Pure so it is unit-tested without tmux.
|
|
986
|
+
*/
|
|
987
|
+
export function resolvePaneIdentity(pane, meta, liveEntry, getHookIndex) {
|
|
988
|
+
if (meta?.source === 'teams')
|
|
989
|
+
return undefined;
|
|
990
|
+
if (liveEntry) {
|
|
991
|
+
// Exact id: the id recorded at launch (Claude), else the agent's own
|
|
992
|
+
// SessionStart hook joined by launchId/terminalId (non-Claude, or agents we
|
|
993
|
+
// didn't launch) — kind-guarded against a stale reused-pid file.
|
|
994
|
+
const sessionId = liveEntry.sessionId
|
|
995
|
+
?? resolveHookSessionRecord(getHookIndex(), {
|
|
996
|
+
pid: liveEntry.pid,
|
|
997
|
+
kind: liveEntry.agent,
|
|
998
|
+
launchId: liveEntry.launchId,
|
|
999
|
+
terminalId: liveEntry.terminalId,
|
|
1000
|
+
})?.session_id;
|
|
1001
|
+
return { agent: liveEntry.agent, sessionId, pid: liveEntry.pid };
|
|
1002
|
+
}
|
|
1003
|
+
const agent = meta?.labels?.agent;
|
|
1004
|
+
const sessionId = meta?.labels?.sessionId;
|
|
1005
|
+
if (agent && sessionId && (meta?.pane == null || meta.pane === pane))
|
|
1006
|
+
return { agent, sessionId };
|
|
1007
|
+
return undefined;
|
|
1008
|
+
}
|
|
799
1009
|
/**
|
|
800
1010
|
* 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
|
-
*
|
|
1011
|
+
* tmux-hosted interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
|
|
1012
|
+
* every pane on the shared socket and attributes each to the agent running in it
|
|
1013
|
+
* via {@link resolvePaneIdentity}: the per-pane launch registry (exact) first, the
|
|
1014
|
+
* session-meta labels as fallback. Because tmux (not a per-window
|
|
1015
|
+
* `live-terminals.json`) is the source of truth, a tmux-hosted agent is captured
|
|
1016
|
+
* with its exact `%pane` even when the extension registry is stale — INCLUDING an
|
|
1017
|
+
* agent bare-spawned into a split of an existing session, which older logic dropped
|
|
1018
|
+
* (it kept only the first pane per session meta). `source: 'teams'` is skipped.
|
|
807
1019
|
*/
|
|
808
1020
|
export async function listTmuxAgentSessions() {
|
|
809
1021
|
const { getDefaultSocketPath } = await import('../tmux/paths.js');
|
|
@@ -825,6 +1037,21 @@ export async function listTmuxAgentSessions() {
|
|
|
825
1037
|
}
|
|
826
1038
|
if (res.code !== 0)
|
|
827
1039
|
return [];
|
|
1040
|
+
// Index live launches by the pane they target, so a pane we did NOT wrap (a
|
|
1041
|
+
// split) resolves to its own agent. Newest launch wins a pane (pid reuse), and
|
|
1042
|
+
// only live pids count — a dead agent's stale entry can't light up its old pane.
|
|
1043
|
+
const liveByPane = new Map();
|
|
1044
|
+
for (const e of listPidSessionEntries()) {
|
|
1045
|
+
if (!e.tmuxPane || !isPidAlive(e.pid, e.startedAtMs))
|
|
1046
|
+
continue;
|
|
1047
|
+
const prev = liveByPane.get(e.tmuxPane);
|
|
1048
|
+
if (!prev || e.startedAtMs > prev.startedAtMs)
|
|
1049
|
+
liveByPane.set(e.tmuxPane, e);
|
|
1050
|
+
}
|
|
1051
|
+
// Hook index is scanned lazily — only when a live launch lacks a recorded id
|
|
1052
|
+
// (a non-Claude split) and needs the SessionStart-hook join.
|
|
1053
|
+
let hookIndex;
|
|
1054
|
+
const getHookIndex = () => (hookIndex ??= loadHookSessionIndex());
|
|
828
1055
|
const out = [];
|
|
829
1056
|
const seen = new Set();
|
|
830
1057
|
for (const line of res.stdout.split('\n')) {
|
|
@@ -834,20 +1061,26 @@ export async function listTmuxAgentSessions() {
|
|
|
834
1061
|
if (!pane || !sessName)
|
|
835
1062
|
continue;
|
|
836
1063
|
const meta = readSessionMeta(sessName);
|
|
837
|
-
const
|
|
838
|
-
const
|
|
839
|
-
if (!
|
|
840
|
-
continue;
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1064
|
+
const liveEntry = liveByPane.get(pane);
|
|
1065
|
+
const id = resolvePaneIdentity(pane, meta, liveEntry, getHookIndex);
|
|
1066
|
+
if (!id)
|
|
1067
|
+
continue;
|
|
1068
|
+
// Dedupe by resolved session id; an as-yet-unresolved id (a hookless/lagging
|
|
1069
|
+
// split) keys on the unique pane so it still surfaces as its own row.
|
|
1070
|
+
const dedupKey = id.sessionId ?? pane;
|
|
1071
|
+
if (seen.has(dedupKey))
|
|
1072
|
+
continue;
|
|
1073
|
+
seen.add(dedupKey);
|
|
1074
|
+
// Prefer the registry pid (the agent), falling back to the pane leaf pid. A
|
|
1075
|
+
// pid we emit here goes into getActiveSessions' attributed set, so the ps-scan
|
|
1076
|
+
// does NOT also surface this agent as a duplicate headless row.
|
|
1077
|
+
const pid = id.pid ?? (parseInt(pidRaw, 10) || undefined);
|
|
1078
|
+
const cwd = liveEntry?.cwd ?? meta?.cwd ?? (curPath || undefined);
|
|
1079
|
+
const sessionFile = findSessionFileForKind(id.agent, cwd, id.sessionId);
|
|
849
1080
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
850
|
-
const
|
|
1081
|
+
const pidAlive = pid ? isPidAlive(pid, liveEntry?.startedAtMs) : true;
|
|
1082
|
+
const { state, tokPerSec } = computeLiveSignals(id.agent, sessionFile, cwd, pidAlive);
|
|
1083
|
+
const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
|
|
851
1084
|
// Provenance is known exactly here (the pane IS a tmux pane) — set it so
|
|
852
1085
|
// enrichProvenance skips it and the locator/reply rails resolve off the pane.
|
|
853
1086
|
const provenance = {
|
|
@@ -858,16 +1091,20 @@ export async function listTmuxAgentSessions() {
|
|
|
858
1091
|
};
|
|
859
1092
|
out.push(applyState({
|
|
860
1093
|
context: 'terminal',
|
|
861
|
-
kind: agent,
|
|
1094
|
+
kind: id.agent,
|
|
862
1095
|
host: 'tmux',
|
|
863
1096
|
pid,
|
|
864
|
-
sessionId,
|
|
1097
|
+
sessionId: id.sessionId ?? sessionIdFromFile(sessionFile),
|
|
865
1098
|
cwd,
|
|
866
1099
|
topic,
|
|
867
1100
|
tokPerSec,
|
|
868
1101
|
sessionFile,
|
|
1102
|
+
// tmux panes carry no start timestamp; derive both from the transcript
|
|
1103
|
+
// (creation ≈ start, last write ≈ last activity).
|
|
1104
|
+
startedAtMs: birthtimeMs,
|
|
1105
|
+
lastActivityMs: mtimeMs,
|
|
869
1106
|
provenance,
|
|
870
|
-
}, state, sessionFile));
|
|
1107
|
+
}, state, sessionFile, pidAlive));
|
|
871
1108
|
}
|
|
872
1109
|
return out;
|
|
873
1110
|
}
|
|
@@ -898,8 +1135,26 @@ export async function getActiveSessions(opts = {}) {
|
|
|
898
1135
|
const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
|
|
899
1136
|
const merged = dedupeBySession([...tmuxAgents, ...teams, ...terminals, ...cloud, ...unattributed]);
|
|
900
1137
|
await enrichProvenance(merged);
|
|
1138
|
+
foldPresence(merged);
|
|
901
1139
|
return merged;
|
|
902
1140
|
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Fold detach/attach presence onto each row from the detach store. A stored
|
|
1143
|
+
* record wins (`background`/`parked`); otherwise a live terminal session is
|
|
1144
|
+
* `attached`. Ad-hoc headless runs and cloud/team rows stay unmarked — they are
|
|
1145
|
+
* not on the foreground/background axis.
|
|
1146
|
+
*/
|
|
1147
|
+
function foldPresence(rows) {
|
|
1148
|
+
for (const s of rows) {
|
|
1149
|
+
if (!s.sessionId)
|
|
1150
|
+
continue;
|
|
1151
|
+
const stored = presenceFromStore(s.sessionId);
|
|
1152
|
+
if (stored)
|
|
1153
|
+
s.presence = stored;
|
|
1154
|
+
else if (s.context === 'terminal')
|
|
1155
|
+
s.presence = 'attached';
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
903
1158
|
/**
|
|
904
1159
|
* Attach provenance (host / local-vs-SSH / tmux pane / reply rail) to every
|
|
905
1160
|
* session that has a live pid. Mutates in place. Runs after dedupe so we probe
|
|
@@ -914,18 +1169,34 @@ async function enrichProvenance(sessions) {
|
|
|
914
1169
|
s.provenance = await detectProvenance(s.pid);
|
|
915
1170
|
}));
|
|
916
1171
|
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Identity for a row the scan could not tie to a session: a daemon's worker
|
|
1174
|
+
* processes (an OpenClaw gateway spawning `codex`, a supervisor pool) have no
|
|
1175
|
+
* session id, no transcript file, and no cloud/run handle — nothing tells two of
|
|
1176
|
+
* them apart, because nothing distinguishes them. Same binary + same working
|
|
1177
|
+
* directory + same context IS the identity, so N indistinguishable workers
|
|
1178
|
+
* collapse to one row carrying `pidCount: N`.
|
|
1179
|
+
*
|
|
1180
|
+
* Returns undefined with no cwd: without it there is no stable identity, and
|
|
1181
|
+
* keying on kind alone would fold unrelated agents onto one row.
|
|
1182
|
+
*/
|
|
1183
|
+
function anonymousWorkerKey(s) {
|
|
1184
|
+
if (!s.cwd)
|
|
1185
|
+
return undefined;
|
|
1186
|
+
return `anon\0${s.kind}\0${s.context}\0${s.cwd}`;
|
|
1187
|
+
}
|
|
917
1188
|
/**
|
|
918
1189
|
* Collapse rows that resolve to the *same* session — a session with many
|
|
919
1190
|
* subagent/fork PIDs (all matched to one transcript file) would otherwise print
|
|
920
|
-
* dozens of identical rows. Keyed by session id
|
|
921
|
-
*
|
|
922
|
-
*
|
|
1191
|
+
* dozens of identical rows. Keyed by session id, falling back to the transcript
|
|
1192
|
+
* file, then the cloud/run handle, then {@link anonymousWorkerKey}. The first row
|
|
1193
|
+
* wins and carries a `pidCount`.
|
|
923
1194
|
*/
|
|
924
|
-
function dedupeBySession(sessions) {
|
|
1195
|
+
export function dedupeBySession(sessions) {
|
|
925
1196
|
const out = [];
|
|
926
1197
|
const byKey = new Map();
|
|
927
1198
|
for (const s of sessions) {
|
|
928
|
-
const key = s.sessionId || s.sessionFile;
|
|
1199
|
+
const key = s.sessionId || s.sessionFile || s.cloudTaskId || s.agentId || anonymousWorkerKey(s);
|
|
929
1200
|
if (!key) {
|
|
930
1201
|
out.push(s);
|
|
931
1202
|
continue;
|
|
@@ -13,6 +13,7 @@ import * as fs from 'fs';
|
|
|
13
13
|
import * as path from 'path';
|
|
14
14
|
import * as os from 'os';
|
|
15
15
|
import * as yaml from 'yaml';
|
|
16
|
+
import { deriveShortId } from './short-id.js';
|
|
16
17
|
import { getCacheDir } from '../state.js';
|
|
17
18
|
const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
|
|
18
19
|
const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
@@ -94,7 +95,7 @@ export async function discoverCloudSessions(options) {
|
|
|
94
95
|
const filePath = cachePathForExecution(id, agent);
|
|
95
96
|
out.push({
|
|
96
97
|
id,
|
|
97
|
-
shortId: id
|
|
98
|
+
shortId: deriveShortId(id),
|
|
98
99
|
agent,
|
|
99
100
|
timestamp,
|
|
100
101
|
project,
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -109,6 +109,29 @@ export declare function getScanStampByPath(filePath: string): ScanStamp | null;
|
|
|
109
109
|
* This is the fast path used by the incremental scanner — avoids N+1 queries.
|
|
110
110
|
*/
|
|
111
111
|
export declare function getScanStampsForPaths(filePaths: string[]): Map<string, ScanStamp>;
|
|
112
|
+
/**
|
|
113
|
+
* A file's persisted resumable-parse continuation, read back from scan_ledger.
|
|
114
|
+
* `parserState` is the serialized {@link ClaudeParserState} JSON blob (offset +
|
|
115
|
+
* accumulator snapshot); `contentText` is the accumulated user doc. Both are
|
|
116
|
+
* written by the Claude scan (B-2) and consumed on the next scan to decide
|
|
117
|
+
* full-vs-incremental and to hydrate the resume.
|
|
118
|
+
*/
|
|
119
|
+
export interface ParserStateRow {
|
|
120
|
+
parserState: string | null;
|
|
121
|
+
contentText: string | null;
|
|
122
|
+
fileMtimeMs: number;
|
|
123
|
+
fileSize: number;
|
|
124
|
+
scannedAt: number;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Bulk-load the resumable-parse continuation (parser_state + content_text) plus
|
|
128
|
+
* the stamp for a set of file paths in a single chunked query. Mirrors
|
|
129
|
+
* {@link getScanStampsForPaths}: keys by canonical path and fans results back to
|
|
130
|
+
* every original alias so callers can `.get(filePath)` with the path they passed.
|
|
131
|
+
* The Claude incremental scan uses this to fetch each changed file's prior
|
|
132
|
+
* continuation without an N+1 of {@link getScanStampByPath}.
|
|
133
|
+
*/
|
|
134
|
+
export declare function getParserStatesForPaths(filePaths: string[]): Map<string, ParserStateRow>;
|
|
112
135
|
/**
|
|
113
136
|
* Record scan stamps for files we've looked at. Covers both files that produced
|
|
114
137
|
* a session and files we looked at but chose not to index (e.g. malformed).
|
|
@@ -117,6 +140,29 @@ export declare function recordScans(entries: Array<{
|
|
|
117
140
|
filePath: string;
|
|
118
141
|
scan: ScanStamp;
|
|
119
142
|
}>): void;
|
|
143
|
+
/** Snapshot of a leaf transcript directory used to detect create/delete/rename. */
|
|
144
|
+
export interface DirStamp {
|
|
145
|
+
dirMtimeMs: number;
|
|
146
|
+
entryCount: number;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Bulk-load the dir ledger for a set of leaf directories in a single SQL query.
|
|
150
|
+
* Mirrors {@link getScanStampsForPaths}: keys by canonical path (so a dir
|
|
151
|
+
* reachable via a symlinked version home and its realpath collapse to one row)
|
|
152
|
+
* and fans the result back out to every original alias so callers can
|
|
153
|
+
* `.get(dirPath)` with the path they passed in.
|
|
154
|
+
*/
|
|
155
|
+
export declare function getDirLedgerForPaths(dirs: string[]): Map<string, DirStamp>;
|
|
156
|
+
/**
|
|
157
|
+
* Upsert dir-scan stamps. Recorded after a full readdir of a leaf transcript
|
|
158
|
+
* dir so the next scan can skip that dir when its (mtime, entry_count) is
|
|
159
|
+
* unchanged. Mirrors {@link recordScans}.
|
|
160
|
+
*/
|
|
161
|
+
export declare function recordDirScans(entries: Array<{
|
|
162
|
+
dirPath: string;
|
|
163
|
+
dirMtimeMs: number;
|
|
164
|
+
entryCount: number;
|
|
165
|
+
}>): void;
|
|
120
166
|
/**
|
|
121
167
|
* Upsert a session row and replace its FTS5 content in a single transaction.
|
|
122
168
|
* `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
|
|
@@ -127,6 +173,8 @@ export declare function upsertSessionsBatch(entries: Array<{
|
|
|
127
173
|
meta: SessionMeta;
|
|
128
174
|
content: string;
|
|
129
175
|
scan?: ScanStamp;
|
|
176
|
+
parserState?: string;
|
|
177
|
+
contentText?: string;
|
|
130
178
|
}>): void;
|
|
131
179
|
/**
|
|
132
180
|
* Sync labels for a set of sessions. For each id in the map, if the stored
|