@phnx-labs/agents-cli 1.22.21 → 1.22.23
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 +256 -0
- package/README.md +8 -2
- package/dist/commands/doctor.js +15 -7
- package/dist/commands/exec.js +20 -6
- package/dist/commands/focus.d.ts +76 -4
- package/dist/commands/focus.js +219 -40
- package/dist/commands/fork.d.ts +20 -2
- package/dist/commands/fork.js +91 -64
- package/dist/commands/go.d.ts +25 -0
- package/dist/commands/go.js +63 -2
- package/dist/commands/harness-wizard.d.ts +206 -0
- package/dist/commands/harness-wizard.js +403 -0
- package/dist/commands/harness.d.ts +12 -0
- package/dist/commands/harness.js +97 -101
- package/dist/commands/insights.d.ts +32 -0
- package/dist/commands/insights.js +478 -0
- package/dist/commands/resume.js +10 -3
- package/dist/commands/secrets.js +25 -30
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/sessions-resume.d.ts +21 -3
- package/dist/commands/sessions-resume.js +50 -11
- package/dist/commands/sessions.d.ts +81 -5
- package/dist/commands/sessions.js +331 -66
- package/dist/commands/watchdog.js +13 -2
- package/dist/index.js +3 -1
- package/dist/lib/agents.d.ts +1 -1
- package/dist/lib/agents.js +132 -0
- package/dist/lib/codex-policy.d.ts +17 -0
- package/dist/lib/codex-policy.js +48 -0
- package/dist/lib/crabbox/lease.d.ts +25 -0
- package/dist/lib/crabbox/lease.js +62 -0
- package/dist/lib/daemon.js +70 -0
- package/dist/lib/exec.d.ts +4 -0
- package/dist/lib/exec.js +88 -54
- package/dist/lib/feed-broadcast.d.ts +1 -20
- package/dist/lib/feed-broadcast.js +31 -1
- package/dist/lib/hooks.js +12 -2
- package/dist/lib/hosts/passthrough.js +1 -0
- package/dist/lib/mcp.js +44 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
- package/dist/lib/models.d.ts +0 -5
- package/dist/lib/models.js +48 -0
- package/dist/lib/picker.d.ts +45 -0
- package/dist/lib/picker.js +75 -6
- package/dist/lib/plugin-marketplace.js +9 -0
- package/dist/lib/pricing/prices.json +119 -92
- package/dist/lib/pricing/table.js +13 -0
- package/dist/lib/remote-agents-json.d.ts +29 -1
- package/dist/lib/remote-agents-json.js +47 -10
- package/dist/lib/resources/mcp.js +2 -0
- package/dist/lib/resources/permissions.js +3 -0
- package/dist/lib/resources/types.d.ts +2 -1
- package/dist/lib/runner.js +28 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +118 -49
- package/dist/lib/secrets/reaper.d.ts +87 -0
- package/dist/lib/secrets/reaper.js +184 -0
- package/dist/lib/secrets/remote.d.ts +29 -0
- package/dist/lib/secrets/remote.js +37 -1
- package/dist/lib/session/active.d.ts +36 -1
- package/dist/lib/session/active.js +60 -19
- package/dist/lib/session/actor-sidecar.d.ts +14 -0
- package/dist/lib/session/actor-sidecar.js +67 -4
- package/dist/lib/session/db.d.ts +35 -1
- package/dist/lib/session/db.js +132 -1
- package/dist/lib/session/digest.d.ts +3 -0
- package/dist/lib/session/digest.js +3 -3
- package/dist/lib/session/discover.js +168 -0
- package/dist/lib/session/insights.d.ts +126 -0
- package/dist/lib/session/insights.js +330 -0
- package/dist/lib/session/parse.d.ts +29 -2
- package/dist/lib/session/parse.js +111 -5
- package/dist/lib/session/remote-list.d.ts +10 -1
- package/dist/lib/session/remote-list.js +2 -8
- package/dist/lib/session/remote.d.ts +57 -3
- package/dist/lib/session/remote.js +90 -26
- package/dist/lib/session/resume-command.d.ts +6 -0
- package/dist/lib/session/resume-command.js +8 -0
- package/dist/lib/session/session-cache.d.ts +173 -0
- package/dist/lib/session/session-cache.js +399 -0
- package/dist/lib/session/types.d.ts +2 -2
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/width.d.ts +1 -1
- package/dist/lib/session/width.js +12 -2
- package/dist/lib/shims.d.ts +2 -2
- package/dist/lib/shims.js +40 -5
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/usage.d.ts +13 -0
- package/dist/lib/usage.js +254 -3
- package/dist/lib/versions.js +13 -2
- package/package.json +1 -1
- package/dist/bin/agents +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaper for orphaned / wedged keychain-helper processes.
|
|
3
|
+
*
|
|
4
|
+
* macOS keychain-helper calls are synchronous spawnSync invocations. When
|
|
5
|
+
* coreauthd / LocalAuthentication hangs, the helper process (and sometimes its
|
|
6
|
+
* parent `agents` process) can pile up forever. This module detects two classes
|
|
7
|
+
* of stale process and kills them.
|
|
8
|
+
*
|
|
9
|
+
* The design splits cleanly into:
|
|
10
|
+
* - a pure planner ({@link planKeychainReap}) that is unit-testable without a
|
|
11
|
+
* real `ps` shell, and
|
|
12
|
+
* - an impure driver ({@link reapOrphanedKeychainProcesses}) that shells `ps`
|
|
13
|
+
* once per tick and executes kills through {@link killTree}.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'child_process';
|
|
16
|
+
import { killTree, captureProcessStartTime } from '../platform/process.js';
|
|
17
|
+
import { getKeychainHelperPath } from './install-helper.js';
|
|
18
|
+
/** Elapsed grace before a helper whose parent exited is considered an orphan. */
|
|
19
|
+
export const ORPHAN_GRACE_SEC = 30;
|
|
20
|
+
/** Elapsed grace before a helper with a live parent is considered stuck. */
|
|
21
|
+
export const STUCK_GRACE_SEC = 90;
|
|
22
|
+
/**
|
|
23
|
+
* Pure predicate: decide which processes to kill given a `ps`-like snapshot.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors the shape of {@link isExpiredPoolStray} in `lib/crabbox/lease.ts`:
|
|
26
|
+
* a side-effect-free classifier that the impure driver shells `ps` for. Two
|
|
27
|
+
* conservative reap classes:
|
|
28
|
+
*
|
|
29
|
+
* 1. Orphaned helper: PPID == 1, path-matches the helper, alive longer than
|
|
30
|
+
* {@link ORPHAN_GRACE_SEC}.
|
|
31
|
+
* 2. Stuck `agents` parent: helper child alive longer than
|
|
32
|
+
* {@link STUCK_GRACE_SEC}. Recorded on first sight, child killed on the
|
|
33
|
+
* second consecutive sweep with the same PID + startTime, parent killed on
|
|
34
|
+
* the third sweep if the helper child is still present.
|
|
35
|
+
*
|
|
36
|
+
* Never reaps a process whose start time could not be captured, whose path does
|
|
37
|
+
* not match the helper, or whose parent is no longer in the snapshot.
|
|
38
|
+
*/
|
|
39
|
+
export function planKeychainReap(snapshots, now, prevCandidates) {
|
|
40
|
+
const pidMap = new Map(snapshots.map((s) => [s.pid, s]));
|
|
41
|
+
const kill = [];
|
|
42
|
+
const nextCandidates = new Map();
|
|
43
|
+
for (const s of snapshots) {
|
|
44
|
+
if (!s.isHelper)
|
|
45
|
+
continue;
|
|
46
|
+
if (s.ppid === 1) {
|
|
47
|
+
// Orphaned helper: parent is init/launchd. Fail closed if we can't prove
|
|
48
|
+
// the process identity with a start-time fingerprint.
|
|
49
|
+
if (s.elapsedSec <= ORPHAN_GRACE_SEC)
|
|
50
|
+
continue;
|
|
51
|
+
if (s.startTime == null)
|
|
52
|
+
continue;
|
|
53
|
+
kill.push(s.pid);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Stuck agents: a helper with a live parent that has been wedged for longer
|
|
57
|
+
// than the interactive timeout. The parent must still exist in the snapshot.
|
|
58
|
+
if (s.elapsedSec <= STUCK_GRACE_SEC)
|
|
59
|
+
continue;
|
|
60
|
+
const parent = pidMap.get(s.ppid);
|
|
61
|
+
if (!parent)
|
|
62
|
+
continue;
|
|
63
|
+
if (s.startTime == null || parent.startTime == null)
|
|
64
|
+
continue;
|
|
65
|
+
const prev = prevCandidates.get(parent.pid);
|
|
66
|
+
if (prev &&
|
|
67
|
+
prev.helperPid === s.pid &&
|
|
68
|
+
prev.helperStartTime === s.startTime &&
|
|
69
|
+
prev.startTime === parent.startTime) {
|
|
70
|
+
if (prev.stage === 'watch') {
|
|
71
|
+
// Second sweep: kill the child first so the parent's spawnSync returns.
|
|
72
|
+
// Keep the parent candidate at stage 'escalate' for the next sweep.
|
|
73
|
+
kill.push(s.pid);
|
|
74
|
+
nextCandidates.set(parent.pid, { ...prev, stage: 'escalate' });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Third sweep: child did not free the parent, so the parent itself is wedged.
|
|
78
|
+
kill.push(parent.pid);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// First sight of this (parent, helper) pair — record it and wait.
|
|
82
|
+
nextCandidates.set(parent.pid, {
|
|
83
|
+
pid: parent.pid,
|
|
84
|
+
startTime: parent.startTime,
|
|
85
|
+
helperPid: s.pid,
|
|
86
|
+
helperStartTime: s.startTime,
|
|
87
|
+
firstSeenAt: now,
|
|
88
|
+
stage: 'watch',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return { kill, nextCandidates };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Parse one `ps` output line.
|
|
95
|
+
*
|
|
96
|
+
* Expected format from `ps -ax -o pid=,ppid=,etimes=,command=`:
|
|
97
|
+
* "<pid> <ppid> <etimes> <command...>"
|
|
98
|
+
* The command field is the remainder of the line and may contain spaces.
|
|
99
|
+
*/
|
|
100
|
+
function parsePsLine(line) {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
if (!trimmed)
|
|
103
|
+
return null;
|
|
104
|
+
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/);
|
|
105
|
+
if (!m)
|
|
106
|
+
return null;
|
|
107
|
+
const pid = parseInt(m[1], 10);
|
|
108
|
+
const ppid = parseInt(m[2], 10);
|
|
109
|
+
const elapsedSec = parseInt(m[3], 10);
|
|
110
|
+
const command = m[4];
|
|
111
|
+
if (isNaN(pid) || isNaN(ppid) || isNaN(elapsedSec))
|
|
112
|
+
return null;
|
|
113
|
+
return { pid, ppid, elapsedSec, command };
|
|
114
|
+
}
|
|
115
|
+
/** Module-state for the two-sweep stuck-parent debounce. */
|
|
116
|
+
let stuckParentCandidates = new Map();
|
|
117
|
+
/** Test seam: reset the persisted candidate state. */
|
|
118
|
+
export function resetKeychainReaperCandidatesForTest() {
|
|
119
|
+
stuckParentCandidates = new Map();
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Impure driver: snapshot all processes once with `ps`, plan the reap, then
|
|
123
|
+
* execute kills through {@link killTree}.
|
|
124
|
+
*
|
|
125
|
+
* Path-matching uses the full helper path (proc_pidpath-style), not just the
|
|
126
|
+
* executable name, so an unrelated binary named "Agents CLI" is never targeted.
|
|
127
|
+
*
|
|
128
|
+
* Returns on non-darwin platforms without shelling anything — the helper only
|
|
129
|
+
* exists on macOS.
|
|
130
|
+
*/
|
|
131
|
+
export function reapOrphanedKeychainProcesses() {
|
|
132
|
+
const details = [];
|
|
133
|
+
if (process.platform !== 'darwin') {
|
|
134
|
+
return { reaped: 0, details, plan: { kill: [], nextCandidates: new Map() } };
|
|
135
|
+
}
|
|
136
|
+
let helperPath;
|
|
137
|
+
try {
|
|
138
|
+
helperPath = getKeychainHelperPath();
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
return { reaped: 0, details: [`helper path resolution failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
|
|
142
|
+
}
|
|
143
|
+
let out;
|
|
144
|
+
try {
|
|
145
|
+
out = execFileSync('ps', ['-ax', '-o', 'pid=,ppid=,etimes=,command='], {
|
|
146
|
+
encoding: 'utf-8',
|
|
147
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
return { reaped: 0, details: [`ps failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
|
|
152
|
+
}
|
|
153
|
+
const snapshots = [];
|
|
154
|
+
for (const line of out.split('\n')) {
|
|
155
|
+
const parsed = parsePsLine(line);
|
|
156
|
+
if (!parsed)
|
|
157
|
+
continue;
|
|
158
|
+
const { pid, ppid, elapsedSec, command } = parsed;
|
|
159
|
+
// Exact path-match: the helper invocation's command line begins with the
|
|
160
|
+
// absolute helper path, followed by a space and its arguments (or nothing).
|
|
161
|
+
const isHelper = command === helperPath || command.startsWith(`${helperPath} `);
|
|
162
|
+
snapshots.push({
|
|
163
|
+
pid,
|
|
164
|
+
ppid,
|
|
165
|
+
elapsedSec,
|
|
166
|
+
startTime: captureProcessStartTime(pid),
|
|
167
|
+
isHelper,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const plan = planKeychainReap(snapshots, Date.now(), stuckParentCandidates);
|
|
171
|
+
stuckParentCandidates = plan.nextCandidates;
|
|
172
|
+
for (const pid of plan.kill) {
|
|
173
|
+
if (pid === process.pid)
|
|
174
|
+
continue; // never self-terminate the reaper
|
|
175
|
+
try {
|
|
176
|
+
killTree(pid);
|
|
177
|
+
details.push(`killed pid ${pid}`);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
details.push(`failed to kill pid ${pid}: ${err.message}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { reaped: plan.kill.length, details, plan };
|
|
184
|
+
}
|
|
@@ -177,3 +177,32 @@ export declare function keychainWriteFailureMessage(host: string, bundle: string
|
|
|
177
177
|
export declare function verifyRemoteKeychainPush(target: string, bundle: string, pushedKeys: string[], opts?: {
|
|
178
178
|
osLookupName?: string;
|
|
179
179
|
}): RemoteKeychainWriteVerification;
|
|
180
|
+
/**
|
|
181
|
+
* The `bash -lc` command + stdin payload that drives a **file-backed** remote
|
|
182
|
+
* import for `secrets export --host … --remote-backend file`.
|
|
183
|
+
*
|
|
184
|
+
* The file store is passphrase-free by default: with `AGENTS_SECRETS_PASSPHRASE`
|
|
185
|
+
* unset the remote `agents secrets import --backend file` auto-provisions the
|
|
186
|
+
* remote's own machine-local key (0600 under `~/.agents/.secrets-key/`), so its
|
|
187
|
+
* reads are HEADLESS — no passphrase, no Touch ID. A passphrase is therefore
|
|
188
|
+
* OPTIONAL and only forwarded when the operator sets one locally (opt-in, e.g. to
|
|
189
|
+
* key the bundle off-disk under a shared secret):
|
|
190
|
+
*
|
|
191
|
+
* - **No passphrase** → the remote runs `import … --backend file` directly with
|
|
192
|
+
* ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
|
|
193
|
+
* prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
|
|
194
|
+
* machine-local key path → headless reads.
|
|
195
|
+
* - **Passphrase set** → forward it as the FIRST stdin line, consumed by
|
|
196
|
+
* `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
|
|
197
|
+
* then the .env. The remote then keys the bundle under that shared passphrase.
|
|
198
|
+
*
|
|
199
|
+
* Pure — no I/O — so the exact command string and stdin ordering are unit-testable
|
|
200
|
+
* against the SSH boundary the same way `remoteSecretsRaw` is.
|
|
201
|
+
*/
|
|
202
|
+
export declare function buildRemoteFileImportCommand(bundle: string, dotenv: string, opts?: {
|
|
203
|
+
passphrase?: string;
|
|
204
|
+
force?: boolean;
|
|
205
|
+
}): {
|
|
206
|
+
remoteCmd: string;
|
|
207
|
+
input: string;
|
|
208
|
+
};
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* file-backend passphrase travels over ssh stdin (first line) so it never lands
|
|
16
16
|
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
17
|
*/
|
|
18
|
-
import { sshExec, sshStream, assertValidSshTarget } from '../ssh-exec.js';
|
|
18
|
+
import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
19
|
import { resolveHost } from '../hosts/registry.js';
|
|
20
20
|
import { emitSecretAudit } from './audit.js';
|
|
21
21
|
import { sshTargetFor } from '../hosts/types.js';
|
|
@@ -348,3 +348,39 @@ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {})
|
|
|
348
348
|
}
|
|
349
349
|
return evaluateKeychainWriteVerification(pushedKeys, { ok: true, keys });
|
|
350
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* The `bash -lc` command + stdin payload that drives a **file-backed** remote
|
|
353
|
+
* import for `secrets export --host … --remote-backend file`.
|
|
354
|
+
*
|
|
355
|
+
* The file store is passphrase-free by default: with `AGENTS_SECRETS_PASSPHRASE`
|
|
356
|
+
* unset the remote `agents secrets import --backend file` auto-provisions the
|
|
357
|
+
* remote's own machine-local key (0600 under `~/.agents/.secrets-key/`), so its
|
|
358
|
+
* reads are HEADLESS — no passphrase, no Touch ID. A passphrase is therefore
|
|
359
|
+
* OPTIONAL and only forwarded when the operator sets one locally (opt-in, e.g. to
|
|
360
|
+
* key the bundle off-disk under a shared secret):
|
|
361
|
+
*
|
|
362
|
+
* - **No passphrase** → the remote runs `import … --backend file` directly with
|
|
363
|
+
* ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
|
|
364
|
+
* prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
|
|
365
|
+
* machine-local key path → headless reads.
|
|
366
|
+
* - **Passphrase set** → forward it as the FIRST stdin line, consumed by
|
|
367
|
+
* `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
|
|
368
|
+
* then the .env. The remote then keys the bundle under that shared passphrase.
|
|
369
|
+
*
|
|
370
|
+
* Pure — no I/O — so the exact command string and stdin ordering are unit-testable
|
|
371
|
+
* against the SSH boundary the same way `remoteSecretsRaw` is.
|
|
372
|
+
*/
|
|
373
|
+
export function buildRemoteFileImportCommand(bundle, dotenv, opts = {}) {
|
|
374
|
+
const force = opts.force ? ' --force' : '';
|
|
375
|
+
const importCmd = `agents secrets import ${shellQuote(bundle)} --from - --backend file${force}`;
|
|
376
|
+
const passphrase = opts.passphrase ?? '';
|
|
377
|
+
if (passphrase) {
|
|
378
|
+
// Opt-in shared passphrase: read it off the FIRST stdin line, export it, then
|
|
379
|
+
// let `import --from -` read the .env remainder.
|
|
380
|
+
const remoteAgents = `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ${importCmd}`;
|
|
381
|
+
return { remoteCmd: `bash -lc ${shellQuote(remoteAgents)}`, input: `${passphrase}\n${dotenv}` };
|
|
382
|
+
}
|
|
383
|
+
// No passphrase: NO prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the
|
|
384
|
+
// remote, so the file store falls back to its machine-local key (headless).
|
|
385
|
+
return { remoteCmd: `bash -lc ${shellQuote(importCmd)}`, input: dotenv };
|
|
386
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { CloudTaskStatus } from '../cloud/types.js';
|
|
|
2
2
|
import { type PidSessionEntry } from './pid-registry.js';
|
|
3
3
|
import { type HookSessionIndex } from './hook-sessions.js';
|
|
4
4
|
import { type SessionState, type SessionActivity, type AwaitingReason, type StructuredQuestion, type TodoProgress, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
|
|
5
|
-
import { type SessionAttachment } from './types.js';
|
|
5
|
+
import { type SessionAgentId, type SessionAttachment } from './types.js';
|
|
6
6
|
import { type SessionProvenance } from './provenance.js';
|
|
7
7
|
import { type DeviceRegistry } from '../devices/registry.js';
|
|
8
8
|
import { type Presence } from './detached.js';
|
|
@@ -111,6 +111,13 @@ export interface ActiveSession {
|
|
|
111
111
|
attachments?: SessionAttachment[];
|
|
112
112
|
sessionFile?: string;
|
|
113
113
|
startedAtMs?: number;
|
|
114
|
+
/**
|
|
115
|
+
* Agent version (e.g. `2.1.207`) for the row's `agent version` cell. Not a
|
|
116
|
+
* live-scan signal — a running process does not report its own semver — so it
|
|
117
|
+
* is backfilled at render time from the indexed {@link SessionMeta} by session
|
|
118
|
+
* id (RUSH-2205), never asserted by a source.
|
|
119
|
+
*/
|
|
120
|
+
version?: string;
|
|
114
121
|
/**
|
|
115
122
|
* Last-activity epoch — the transcript's last write (mtime). Distinct from
|
|
116
123
|
* {@link startedAtMs} (session START): a session begun 3h ago but last touched
|
|
@@ -294,6 +301,16 @@ export declare const ACTIVE_SESSION_STALE_MS: number;
|
|
|
294
301
|
* concern) — this is the lifecycle threshold, not the freshness window.
|
|
295
302
|
*/
|
|
296
303
|
export declare const ABANDONED_STALE_MS: number;
|
|
304
|
+
/**
|
|
305
|
+
* Every process executable ("comm") name that identifies a given session-agent
|
|
306
|
+
* kind in the headless `ps`-scan. Driven off the AGENTS registry's `cliCommand`
|
|
307
|
+
* (the real executable name — e.g. `agy` for antigravity, `cursor-agent` for
|
|
308
|
+
* cursor) plus {@link EXTRA_SESSION_AGENT_COMMS} for members with no registry
|
|
309
|
+
* row. Exported so the completeness test can assert every {@link SESSION_AGENTS}
|
|
310
|
+
* member resolves — the fix for the harness-parity gap where bare-headless
|
|
311
|
+
* grok/kimi/antigravity/openclaw/hermes/rush were silently dropped.
|
|
312
|
+
*/
|
|
313
|
+
export declare function sessionAgentComms(id: SessionAgentId): string[];
|
|
297
314
|
/**
|
|
298
315
|
* Resolve an agent kind from a process's reported executable. `comm` may be an
|
|
299
316
|
* absolute path (shim-launched agents), and Windows image names carry an
|
|
@@ -600,6 +617,24 @@ export declare function foldHostLink(rows: ActiveSession[]): void;
|
|
|
600
617
|
* renderer shows the short id. Pure over the array; exported for tests.
|
|
601
618
|
*/
|
|
602
619
|
export declare function annotateOrchestratorLabels(sessions: ActiveSession[]): void;
|
|
620
|
+
/**
|
|
621
|
+
* Attach provenance (host / local-vs-SSH / tmux pane / reply rail) to every
|
|
622
|
+
* session that has a live pid. Mutates in place. Runs after dedupe so we probe
|
|
623
|
+
* each session once, not once per fork pid. Probes run in parallel — each is a
|
|
624
|
+
* single /proc read (Linux) or `ps` call (macOS); failures leave `provenance`
|
|
625
|
+
* undefined rather than blocking the listing. The probes use the same bounded
|
|
626
|
+
* concurrency as the adjacent per-PID lsof sweep so large session lists cannot
|
|
627
|
+
* spawn one `ps eww` subprocess per row at once on macOS.
|
|
628
|
+
*
|
|
629
|
+
* A row that already carries provenance (the tmux path, which knows its exact
|
|
630
|
+
* mux/reply from the pane) is not skipped — it is probe-and-MERGED. The tmux
|
|
631
|
+
* path can only stamp a `transport:'local'` placeholder because the pane alone
|
|
632
|
+
* doesn't reveal how the shell above it was reached; the process env does. So we
|
|
633
|
+
* still read the env and fill in the real SSH origin/term, while preserving the
|
|
634
|
+
* authoritative mux/reply the pane already gave us. Skipping this (the old
|
|
635
|
+
* behavior) is exactly why ssh-launched tmux sessions rendered as local.
|
|
636
|
+
*/
|
|
637
|
+
export declare function enrichProvenance(sessions: ActiveSession[], probe?: (pid: number) => Promise<SessionProvenance | undefined>): Promise<void>;
|
|
603
638
|
/**
|
|
604
639
|
* Match an SSH client IP to a registered device (pure — testable with a plain
|
|
605
640
|
* registry object). Returns the device name + ssh login user when the IP is a
|
|
@@ -25,7 +25,7 @@ import { listActiveTasks } from '../cloud/store.js';
|
|
|
25
25
|
import { AgentManager } from '../teams/agents.js';
|
|
26
26
|
import { getTerminalsDir } from '../state.js';
|
|
27
27
|
import { readPidSessionEntry, listPidSessionEntries, prunePidSessionRegistry } from './pid-registry.js';
|
|
28
|
-
import { readSessionActorRecord } from './actor-sidecar.js';
|
|
28
|
+
import { readSessionActorRecord, writeSessionAliasRecord } from './actor-sidecar.js';
|
|
29
29
|
import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord } from './hook-sessions.js';
|
|
30
30
|
import { buildClaudeLabelMap, getAgentSessionDirs } from './discover.js';
|
|
31
31
|
import { buildRunNameMap } from './run-names.js';
|
|
@@ -35,7 +35,8 @@ import { readSessionTailWithRaw } from './tail.js';
|
|
|
35
35
|
import { parseSession } from './parse.js';
|
|
36
36
|
import { computeTokPerSec } from './throughput.js';
|
|
37
37
|
import { inferSessionState } from './state.js';
|
|
38
|
-
import { isSessionTrackedAgent } from './types.js';
|
|
38
|
+
import { isSessionTrackedAgent, SESSION_AGENTS } from './types.js';
|
|
39
|
+
import { AGENTS } from '../agents.js';
|
|
39
40
|
import { detectProvenance } from './provenance.js';
|
|
40
41
|
import { loadDevices } from '../devices/registry.js';
|
|
41
42
|
import { presenceFromStore } from './detached.js';
|
|
@@ -132,15 +133,47 @@ export const ABANDONED_STALE_MS = 2 * 24 * 60 * 60_000;
|
|
|
132
133
|
* just reintroduce the same class of bug with a lower probability.
|
|
133
134
|
*/
|
|
134
135
|
const TMUX_FIELD_SEP = ':';
|
|
135
|
-
/**
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Process comm names that are NOT derivable from the AGENTS registry's
|
|
138
|
+
* `cliCommand`. `rush` is a {@link SESSION_AGENTS} member with no AGENTS registry
|
|
139
|
+
* row (it is the Rush app, not a managed harness), so its executable name is
|
|
140
|
+
* mapped explicitly here. Everything else flows from the registry below.
|
|
141
|
+
*/
|
|
142
|
+
const EXTRA_SESSION_AGENT_COMMS = {
|
|
143
|
+
rush: ['rush'],
|
|
143
144
|
};
|
|
145
|
+
/**
|
|
146
|
+
* Every process executable ("comm") name that identifies a given session-agent
|
|
147
|
+
* kind in the headless `ps`-scan. Driven off the AGENTS registry's `cliCommand`
|
|
148
|
+
* (the real executable name — e.g. `agy` for antigravity, `cursor-agent` for
|
|
149
|
+
* cursor) plus {@link EXTRA_SESSION_AGENT_COMMS} for members with no registry
|
|
150
|
+
* row. Exported so the completeness test can assert every {@link SESSION_AGENTS}
|
|
151
|
+
* member resolves — the fix for the harness-parity gap where bare-headless
|
|
152
|
+
* grok/kimi/antigravity/openclaw/hermes/rush were silently dropped.
|
|
153
|
+
*/
|
|
154
|
+
export function sessionAgentComms(id) {
|
|
155
|
+
const comms = new Set();
|
|
156
|
+
const cli = AGENTS[id]?.cliCommand;
|
|
157
|
+
if (cli)
|
|
158
|
+
comms.add(cli);
|
|
159
|
+
for (const extra of EXTRA_SESSION_AGENT_COMMS[id] ?? [])
|
|
160
|
+
comms.add(extra);
|
|
161
|
+
return [...comms];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Executables we recognize as agent CLIs when scanning the process table. Built
|
|
165
|
+
* once from {@link sessionAgentComms} across {@link SESSION_AGENTS} — a single
|
|
166
|
+
* derived source, not a second hand-maintained allowlist that drifts from the
|
|
167
|
+
* discovery surface (the harness-parity code-review rule).
|
|
168
|
+
*/
|
|
169
|
+
const AGENT_CLI_NAMES = (() => {
|
|
170
|
+
const map = {};
|
|
171
|
+
for (const id of SESSION_AGENTS) {
|
|
172
|
+
for (const comm of sessionAgentComms(id))
|
|
173
|
+
map[comm] = id;
|
|
174
|
+
}
|
|
175
|
+
return map;
|
|
176
|
+
})();
|
|
144
177
|
/**
|
|
145
178
|
* Resolve an agent kind from a process's reported executable. `comm` may be an
|
|
146
179
|
* absolute path (shim-launched agents), and Windows image names carry an
|
|
@@ -1310,7 +1343,7 @@ export async function listTmuxAgentSessions() {
|
|
|
1310
1343
|
try {
|
|
1311
1344
|
res = await runTmux({
|
|
1312
1345
|
socket,
|
|
1313
|
-
args: ['list-panes', '-a', '-F', ['#{pane_id}', '#{session_name}', '#{pane_pid}', '#{pane_current_path}'].join(TMUX_FIELD_SEP)],
|
|
1346
|
+
args: ['list-panes', '-a', '-F', ['#{pane_id}', '#{session_name}', '#{pane_pid}', '#{pane_dead}', '#{pane_current_path}'].join(TMUX_FIELD_SEP)],
|
|
1314
1347
|
throwOnError: false,
|
|
1315
1348
|
// A wedged tmux server must not hang the whole active-session scan. The
|
|
1316
1349
|
// catch below turns a timeout into an empty tmux source (the other sources
|
|
@@ -1351,8 +1384,8 @@ export async function listTmuxAgentSessions() {
|
|
|
1351
1384
|
// The path is the LAST field, so rejoin its tail: a directory containing the
|
|
1352
1385
|
// separator must not truncate it (the earlier fields cannot contain one).
|
|
1353
1386
|
const parts = line.split(TMUX_FIELD_SEP);
|
|
1354
|
-
const [pane, sessName, pidRaw] = parts;
|
|
1355
|
-
const curPath = parts.slice(
|
|
1387
|
+
const [pane, sessName, pidRaw, paneDeadRaw] = parts;
|
|
1388
|
+
const curPath = parts.slice(4).join(TMUX_FIELD_SEP);
|
|
1356
1389
|
if (!pane || !sessName)
|
|
1357
1390
|
continue;
|
|
1358
1391
|
const meta = readSessionMeta(sessName);
|
|
@@ -1378,6 +1411,9 @@ export async function listTmuxAgentSessions() {
|
|
|
1378
1411
|
if (backfilled)
|
|
1379
1412
|
id = { ...id, sessionId: backfilled };
|
|
1380
1413
|
}
|
|
1414
|
+
if (id.sessionId && shortIdFromName(sessName)) {
|
|
1415
|
+
writeSessionAliasRecord(id.sessionId, sessName);
|
|
1416
|
+
}
|
|
1381
1417
|
// Dedupe by resolved session id; an as-yet-unresolved id (a hookless/lagging
|
|
1382
1418
|
// split) keys on the unique pane so it still surfaces as its own row.
|
|
1383
1419
|
const dedupKey = id.sessionId ?? pane;
|
|
@@ -1395,7 +1431,10 @@ export async function listTmuxAgentSessions() {
|
|
|
1395
1431
|
// bug). Refuse to guess: an id-less pane surfaces as its own row instead.
|
|
1396
1432
|
const sessionFile = id.sessionId ? findSessionFileForKind(id.agent, cwd, id.sessionId) : undefined;
|
|
1397
1433
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
1398
|
-
|
|
1434
|
+
// `remain-on-exit` retains the pane after its child exits. pane_dead is the
|
|
1435
|
+
// authoritative signal; a retained pane is diagnostic, not attachable.
|
|
1436
|
+
const paneDead = paneDeadRaw === '1';
|
|
1437
|
+
const pidAlive = !paneDead && (pid ? isPidAlive(pid, liveEntry?.startedAtMs) : true);
|
|
1399
1438
|
const { state, tokPerSec } = computeLiveSignals(id.agent, sessionFile, cwd, pidAlive);
|
|
1400
1439
|
const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
|
|
1401
1440
|
// The mux/reply rails are known exactly here (the pane IS a tmux pane), so we
|
|
@@ -1620,7 +1659,9 @@ function foldPresence(rows) {
|
|
|
1620
1659
|
* session that has a live pid. Mutates in place. Runs after dedupe so we probe
|
|
1621
1660
|
* each session once, not once per fork pid. Probes run in parallel — each is a
|
|
1622
1661
|
* single /proc read (Linux) or `ps` call (macOS); failures leave `provenance`
|
|
1623
|
-
* undefined rather than blocking the listing.
|
|
1662
|
+
* undefined rather than blocking the listing. The probes use the same bounded
|
|
1663
|
+
* concurrency as the adjacent per-PID lsof sweep so large session lists cannot
|
|
1664
|
+
* spawn one `ps eww` subprocess per row at once on macOS.
|
|
1624
1665
|
*
|
|
1625
1666
|
* A row that already carries provenance (the tmux path, which knows its exact
|
|
1626
1667
|
* mux/reply from the pane) is not skipped — it is probe-and-MERGED. The tmux
|
|
@@ -1630,11 +1671,11 @@ function foldPresence(rows) {
|
|
|
1630
1671
|
* authoritative mux/reply the pane already gave us. Skipping this (the old
|
|
1631
1672
|
* behavior) is exactly why ssh-launched tmux sessions rendered as local.
|
|
1632
1673
|
*/
|
|
1633
|
-
async function enrichProvenance(sessions) {
|
|
1634
|
-
await
|
|
1674
|
+
export async function enrichProvenance(sessions, probe = detectProvenance) {
|
|
1675
|
+
await mapBounded(sessions, async (s) => {
|
|
1635
1676
|
if (!s.pid)
|
|
1636
1677
|
return;
|
|
1637
|
-
const probed = await
|
|
1678
|
+
const probed = await probe(s.pid);
|
|
1638
1679
|
if (!probed)
|
|
1639
1680
|
return;
|
|
1640
1681
|
if (!s.provenance) {
|
|
@@ -1649,7 +1690,7 @@ async function enrichProvenance(sessions) {
|
|
|
1649
1690
|
}
|
|
1650
1691
|
if (probed.term && !s.provenance.term)
|
|
1651
1692
|
s.provenance.term = probed.term;
|
|
1652
|
-
})
|
|
1693
|
+
}, { concurrency: LSOF_CONCURRENCY });
|
|
1653
1694
|
}
|
|
1654
1695
|
/**
|
|
1655
1696
|
* Match an SSH client IP to a registered device (pure — testable with a plain
|
|
@@ -7,6 +7,8 @@ export interface SessionActorRecord {
|
|
|
7
7
|
initiatedBy?: 'human' | 'agent';
|
|
8
8
|
/** Effective permissions mode used by the launcher. */
|
|
9
9
|
mode?: SessionRunMode;
|
|
10
|
+
/** Stable wrapper names that resolve to this native session id. */
|
|
11
|
+
aliases?: string[];
|
|
10
12
|
startedAtMs: number;
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
@@ -15,6 +17,18 @@ export interface SessionActorRecord {
|
|
|
15
17
|
* No-ops without a concrete session id (nothing to key on).
|
|
16
18
|
*/
|
|
17
19
|
export declare function writeSessionActorRecord(record: SessionActorRecord): void;
|
|
20
|
+
export declare function writeSessionAliasRecord(sessionId: string, alias: string): void;
|
|
21
|
+
export type SessionAliasResolution = {
|
|
22
|
+
kind: 'resolved';
|
|
23
|
+
sessionId: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'ambiguous';
|
|
26
|
+
sessionIds: string[];
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'not-found';
|
|
29
|
+
};
|
|
30
|
+
/** Resolve an exact alias, or a unique prefix/suffix of at least six chars. */
|
|
31
|
+
export declare function resolveSessionAlias(selector: string): SessionAliasResolution;
|
|
18
32
|
/** Read one session's actor record. Returns undefined if absent/corrupt. */
|
|
19
33
|
export declare function readSessionActorRecord(sessionId: string): SessionActorRecord | undefined;
|
|
20
34
|
/**
|
|
@@ -33,6 +33,25 @@ function isSafeSessionId(sessionId) {
|
|
|
33
33
|
function recordPath(sessionId) {
|
|
34
34
|
return path.join(sidecarDir(), `${sessionId}.json`);
|
|
35
35
|
}
|
|
36
|
+
function isSafeAlias(alias) {
|
|
37
|
+
return /^ag-[a-z][a-z0-9-]*-[0-9a-f]{8}$/i.test(alias);
|
|
38
|
+
}
|
|
39
|
+
function hasRecordData(record) {
|
|
40
|
+
return typeof record.actor === 'string'
|
|
41
|
+
|| typeof record.mode === 'string'
|
|
42
|
+
|| (Array.isArray(record.aliases) && record.aliases.some(alias => typeof alias === 'string'));
|
|
43
|
+
}
|
|
44
|
+
function normalizedAliases(aliases) {
|
|
45
|
+
if (!Array.isArray(aliases))
|
|
46
|
+
return [];
|
|
47
|
+
return [...new Set(aliases
|
|
48
|
+
.filter((alias) => typeof alias === 'string' && isSafeAlias(alias))
|
|
49
|
+
.map(alias => alias.toLowerCase()))];
|
|
50
|
+
}
|
|
51
|
+
function writeRecord(record) {
|
|
52
|
+
fs.mkdirSync(sidecarDir(), { recursive: true });
|
|
53
|
+
fs.writeFileSync(recordPath(record.sessionId), JSON.stringify(record), 'utf8');
|
|
54
|
+
}
|
|
36
55
|
/**
|
|
37
56
|
* Record the actor a session was launched under. Never throws — the sidecar is
|
|
38
57
|
* an attribution optimization; a session with no record simply scans unattributed.
|
|
@@ -42,13 +61,57 @@ export function writeSessionActorRecord(record) {
|
|
|
42
61
|
if (!isSafeSessionId(record.sessionId))
|
|
43
62
|
return;
|
|
44
63
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
64
|
+
const previous = readSessionActorRecord(record.sessionId);
|
|
65
|
+
writeRecord({
|
|
66
|
+
...previous,
|
|
67
|
+
...record,
|
|
68
|
+
aliases: normalizedAliases([...(previous?.aliases ?? []), ...(record.aliases ?? [])]),
|
|
69
|
+
});
|
|
47
70
|
}
|
|
48
71
|
catch {
|
|
49
72
|
/* degrade to an unattributed row */
|
|
50
73
|
}
|
|
51
74
|
}
|
|
75
|
+
export function writeSessionAliasRecord(sessionId, alias) {
|
|
76
|
+
if (!isSafeSessionId(sessionId) || !isSafeAlias(alias))
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
const previous = readSessionActorRecord(sessionId);
|
|
80
|
+
writeRecord({
|
|
81
|
+
sessionId,
|
|
82
|
+
actor: previous?.actor,
|
|
83
|
+
initiatedBy: previous?.initiatedBy,
|
|
84
|
+
mode: previous?.mode,
|
|
85
|
+
aliases: normalizedAliases([...(previous?.aliases ?? []), alias]),
|
|
86
|
+
startedAtMs: previous?.startedAtMs ?? Date.now(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* the native id remains usable */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Resolve an exact alias, or a unique prefix/suffix of at least six chars. */
|
|
94
|
+
export function resolveSessionAlias(selector) {
|
|
95
|
+
const normalized = selector.trim().toLowerCase();
|
|
96
|
+
if (!normalized)
|
|
97
|
+
return { kind: 'not-found' };
|
|
98
|
+
const exact = new Set();
|
|
99
|
+
const fuzzy = new Set();
|
|
100
|
+
for (const record of loadSessionActorIndex().values()) {
|
|
101
|
+
for (const alias of normalizedAliases(record.aliases)) {
|
|
102
|
+
if (alias === normalized)
|
|
103
|
+
exact.add(record.sessionId);
|
|
104
|
+
else if (normalized.length >= 6 && (alias.startsWith(normalized) || alias.endsWith(normalized)))
|
|
105
|
+
fuzzy.add(record.sessionId);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const matches = exact.size > 0 ? [...exact] : [...fuzzy];
|
|
109
|
+
if (matches.length === 0)
|
|
110
|
+
return { kind: 'not-found' };
|
|
111
|
+
if (matches.length > 1)
|
|
112
|
+
return { kind: 'ambiguous', sessionIds: matches.sort() };
|
|
113
|
+
return { kind: 'resolved', sessionId: matches[0] };
|
|
114
|
+
}
|
|
52
115
|
/** Read one session's actor record. Returns undefined if absent/corrupt. */
|
|
53
116
|
export function readSessionActorRecord(sessionId) {
|
|
54
117
|
if (!isSafeSessionId(sessionId))
|
|
@@ -63,7 +126,7 @@ export function readSessionActorRecord(sessionId) {
|
|
|
63
126
|
try {
|
|
64
127
|
const parsed = JSON.parse(raw);
|
|
65
128
|
if (parsed && typeof parsed === 'object' && typeof parsed.sessionId === 'string' &&
|
|
66
|
-
(
|
|
129
|
+
hasRecordData(parsed)) {
|
|
67
130
|
return parsed;
|
|
68
131
|
}
|
|
69
132
|
}
|
|
@@ -91,7 +154,7 @@ export function loadSessionActorIndex() {
|
|
|
91
154
|
try {
|
|
92
155
|
const parsed = JSON.parse(fs.readFileSync(path.join(sidecarDir(), f), 'utf8'));
|
|
93
156
|
if (parsed && typeof parsed === 'object' && typeof parsed.sessionId === 'string' &&
|
|
94
|
-
(
|
|
157
|
+
hasRecordData(parsed)) {
|
|
95
158
|
out.set(parsed.sessionId, parsed);
|
|
96
159
|
}
|
|
97
160
|
}
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { type IndexedToolCall } from './tool-calls.js';
|
|
|
12
12
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
13
13
|
* assert against the constant instead of hardcoding a number that every bump
|
|
14
14
|
* then has to chase (docs/05-sessions.md calls the constant the source of truth). */
|
|
15
|
-
export declare const SCHEMA_VERSION =
|
|
15
|
+
export declare const SCHEMA_VERSION = 34;
|
|
16
16
|
/**
|
|
17
17
|
* Bump to force `agents sessions backfill resources` to re-derive every
|
|
18
18
|
* session's skill/slash-command tallies on its next run (resource_scan_ledger
|
|
@@ -20,6 +20,13 @@ export declare const SCHEMA_VERSION = 33;
|
|
|
20
20
|
* TOOL_INDEX_VERSION gives the tool backfill).
|
|
21
21
|
*/
|
|
22
22
|
export declare const RESOURCE_INDEX_VERSION = 1;
|
|
23
|
+
/**
|
|
24
|
+
* Bumping this invalidates every cached facet row without touching the schema
|
|
25
|
+
* version, so a change to the extraction logic (a new metric, a corrected bucket)
|
|
26
|
+
* re-derives on the next `agents insights` instead of silently reporting stale
|
|
27
|
+
* numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
|
|
28
|
+
*/
|
|
29
|
+
export declare const INSIGHTS_EXTRACTOR_VERSION = 3;
|
|
23
30
|
/** Raw row shape returned from the sessions table. */
|
|
24
31
|
export interface SessionRow {
|
|
25
32
|
id: string;
|
|
@@ -317,6 +324,33 @@ export interface UsageRollupRow {
|
|
|
317
324
|
outputTokens: number;
|
|
318
325
|
}
|
|
319
326
|
/** What to group a usage rollup by. */
|
|
327
|
+
/**
|
|
328
|
+
* Read cached facets for the given sessions, dropping any row that is stale.
|
|
329
|
+
*
|
|
330
|
+
* Staleness is decided in SQL against the session's own `file_mtime_ms` / `file_size`,
|
|
331
|
+
* the same pair the scanner maintains — so the cache cannot disagree with the index,
|
|
332
|
+
* `IS` rather than `=` so a source with no statable file — NULL on both sides — is a
|
|
333
|
+
* cache HIT rather than a permanent miss that re-parses it on every run.
|
|
334
|
+
*/
|
|
335
|
+
export declare function readSessionInsights<T>(ids: string[]): Map<string, T>;
|
|
336
|
+
/**
|
|
337
|
+
* Persist freshly computed facets against the stamp of the bytes actually parsed.
|
|
338
|
+
*
|
|
339
|
+
* The caller passes the stat it observed when it read the file. Re-reading the stamp
|
|
340
|
+
* from the sessions table inside this INSERT would race: a concurrent rescan between
|
|
341
|
+
* the parse and the write (the cold path flushes in batches, so the window is minutes
|
|
342
|
+
* wide, and this module treats concurrent access as a design assumption) stamps NEW
|
|
343
|
+
* bytes onto OLD facets — a permanent false cache hit until the file changes again.
|
|
344
|
+
* tool-index.ts sets the precedent: stat at parse time, carry the stamp into the write.
|
|
345
|
+
*/
|
|
346
|
+
export declare function writeSessionInsights<T>(entries: Array<{
|
|
347
|
+
id: string;
|
|
348
|
+
fileMtimeMs: number | null;
|
|
349
|
+
fileSize: number | null;
|
|
350
|
+
facets: T;
|
|
351
|
+
}>): void;
|
|
352
|
+
/** Drop every cached facet row. Backs `agents insights --refresh`. */
|
|
353
|
+
export declare function clearSessionInsights(): void;
|
|
320
354
|
export type UsageRollupGroup = 'agent' | 'project' | 'day' | 'account';
|
|
321
355
|
/**
|
|
322
356
|
* Smart-launch affinity priors: group sessions by origin machine, harness, or
|