@phnx-labs/agents-cli 1.20.30 → 1.20.32
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 +15 -0
- package/dist/commands/sessions-picker.js +35 -10
- package/dist/commands/sessions.js +9 -1
- package/dist/commands/setup.js +8 -0
- package/dist/commands/ssh.js +145 -14
- package/dist/commands/view.js +11 -1
- package/dist/lib/agents.js +69 -18
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/devices/pending.d.ts +18 -0
- package/dist/lib/devices/pending.js +103 -0
- package/dist/lib/devices/registry.d.ts +11 -0
- package/dist/lib/devices/registry.js +53 -1
- package/dist/lib/devices/sync.d.ts +61 -0
- package/dist/lib/devices/sync.js +101 -0
- package/dist/lib/session/active.d.ts +2 -0
- package/dist/lib/session/active.js +29 -1
- package/dist/lib/session/digest.d.ts +50 -0
- package/dist/lib/session/digest.js +170 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +83 -10
- package/dist/lib/state.d.ts +4 -0
- package/dist/lib/state.js +4 -0
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +14 -0
- package/dist/lib/versions.d.ts +11 -0
- package/dist/lib/versions.js +20 -0
- package/package.json +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Pending device" sentinels.
|
|
3
|
+
*
|
|
4
|
+
* When the daemon's tailscale probe finds a node that is neither registered nor
|
|
5
|
+
* ignored, it drops a sentinel file under ~/.agents/.cache/state/devices-pending/
|
|
6
|
+
* — the same filesystem-signal pattern the attention hook uses for the menu bar.
|
|
7
|
+
* The Swift helper polls that dir every 10s and renders a "NEW DEVICES" section
|
|
8
|
+
* with Register / Ignore. The file NAME is the device name; the file CONTENT is
|
|
9
|
+
* the platform (one line), so the tray can show "zion (macos)" without opening
|
|
10
|
+
* the registry.
|
|
11
|
+
*
|
|
12
|
+
* The daemon owns writes (reconcile to match the current pending set); the CLI
|
|
13
|
+
* `agents devices register|ignore` clears a single sentinel the moment the user
|
|
14
|
+
* acts, so the badge updates immediately instead of waiting for the next probe.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import { getDevicesPendingDir } from '../state.js';
|
|
19
|
+
/** Device-name sentinels must be safe filenames (no path traversal). The device
|
|
20
|
+
* name charset is already the ssh-alias set, but guard defensively. */
|
|
21
|
+
function isSafeName(name) {
|
|
22
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Make the sentinel dir exactly match `pending`: create a file per pending
|
|
26
|
+
* device (content = platform), and delete any leftover sentinel whose device is
|
|
27
|
+
* no longer pending (it got registered, ignored, or left the tailnet). Best-
|
|
28
|
+
* effort — a filesystem error here must never crash the daemon, so callers pass
|
|
29
|
+
* this through their existing try/catch.
|
|
30
|
+
*/
|
|
31
|
+
export function reconcilePendingSentinels(pending) {
|
|
32
|
+
const dir = getDevicesPendingDir();
|
|
33
|
+
const want = new Map(pending.filter((p) => isSafeName(p.name)).map((p) => [p.name, p.platform]));
|
|
34
|
+
// Whole body is best-effort: a filesystem error here must never propagate into
|
|
35
|
+
// the daemon loop or `agents sync`. The top-level mkdir/readdir are guarded
|
|
36
|
+
// too, so no caller needs its own try/catch.
|
|
37
|
+
let existing;
|
|
38
|
+
try {
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
existing = fs.readdirSync(dir).filter((n) => !n.startsWith('.'));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// Remove sentinels that are no longer pending.
|
|
46
|
+
for (const name of existing) {
|
|
47
|
+
if (!want.has(name)) {
|
|
48
|
+
try {
|
|
49
|
+
fs.unlinkSync(path.join(dir, name));
|
|
50
|
+
}
|
|
51
|
+
catch { /* already gone */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Write/refresh the sentinels that should exist.
|
|
55
|
+
for (const [name, platform] of want) {
|
|
56
|
+
const p = path.join(dir, name);
|
|
57
|
+
const body = `${platform}\n`;
|
|
58
|
+
// Only write when missing or changed, to avoid needless mtime churn.
|
|
59
|
+
let current = null;
|
|
60
|
+
try {
|
|
61
|
+
current = fs.readFileSync(p, 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
current = null;
|
|
65
|
+
}
|
|
66
|
+
if (current !== body) {
|
|
67
|
+
try {
|
|
68
|
+
fs.writeFileSync(p, body);
|
|
69
|
+
}
|
|
70
|
+
catch { /* best-effort */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Remove one device's pending sentinel (after the user registers or ignores it).
|
|
75
|
+
* No-op if it doesn't exist. */
|
|
76
|
+
export function clearPendingSentinel(name) {
|
|
77
|
+
if (!isSafeName(name))
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
fs.unlinkSync(path.join(getDevicesPendingDir(), name));
|
|
81
|
+
}
|
|
82
|
+
catch { /* already gone */ }
|
|
83
|
+
}
|
|
84
|
+
/** Read the current pending sentinels (name + platform). Used by tests and any
|
|
85
|
+
* TS-side consumer; the menu-bar helper reads the dir directly in Swift. */
|
|
86
|
+
export function readPendingSentinels() {
|
|
87
|
+
const dir = getDevicesPendingDir();
|
|
88
|
+
let names;
|
|
89
|
+
try {
|
|
90
|
+
names = fs.readdirSync(dir).filter((n) => !n.startsWith('.'));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
return names.map((name) => {
|
|
96
|
+
let platform = 'unknown';
|
|
97
|
+
try {
|
|
98
|
+
platform = fs.readFileSync(path.join(dir, name), 'utf-8').trim() || 'unknown';
|
|
99
|
+
}
|
|
100
|
+
catch { /* keep default */ }
|
|
101
|
+
return { name, platform };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -76,3 +76,14 @@ export interface DeviceInput {
|
|
|
76
76
|
export declare function upsertDevice(name: string, input: DeviceInput): Promise<DeviceProfile>;
|
|
77
77
|
/** Remove a device. Returns false if it was not registered. */
|
|
78
78
|
export declare function removeDevice(name: string): Promise<boolean>;
|
|
79
|
+
/** Load the set of ignored node names. Missing file => empty set. A malformed
|
|
80
|
+
* file is a hard error for the same reason the registry is: silently returning
|
|
81
|
+
* [] would let the next write wipe the user's dismissals. */
|
|
82
|
+
export declare function loadIgnored(): Promise<Set<string>>;
|
|
83
|
+
/** True if `name` is on the ignore-list. */
|
|
84
|
+
export declare function isIgnored(name: string): Promise<boolean>;
|
|
85
|
+
/** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
|
|
86
|
+
export declare function addIgnored(name: string): Promise<Set<string>>;
|
|
87
|
+
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
88
|
+
* not ignored. */
|
|
89
|
+
export declare function removeIgnored(name: string): Promise<boolean>;
|
|
@@ -18,7 +18,7 @@ import * as fsSync from 'fs';
|
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import { randomBytes } from 'crypto';
|
|
20
20
|
import lockfile from 'proper-lockfile';
|
|
21
|
-
import { getDevicesRegistryPath } from '../state.js';
|
|
21
|
+
import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
|
|
22
22
|
function registryPath() {
|
|
23
23
|
return getDevicesRegistryPath();
|
|
24
24
|
}
|
|
@@ -166,3 +166,55 @@ export async function removeDevice(name) {
|
|
|
166
166
|
return true;
|
|
167
167
|
});
|
|
168
168
|
}
|
|
169
|
+
function ignoredPath() {
|
|
170
|
+
return getDevicesIgnoredPath();
|
|
171
|
+
}
|
|
172
|
+
/** Load the set of ignored node names. Missing file => empty set. A malformed
|
|
173
|
+
* file is a hard error for the same reason the registry is: silently returning
|
|
174
|
+
* [] would let the next write wipe the user's dismissals. */
|
|
175
|
+
export async function loadIgnored() {
|
|
176
|
+
const p = ignoredPath();
|
|
177
|
+
let raw;
|
|
178
|
+
try {
|
|
179
|
+
raw = await fs.readFile(p, 'utf-8');
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
if (err && err.code === 'ENOENT')
|
|
183
|
+
return new Set();
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(raw);
|
|
188
|
+
return new Set(Array.isArray(parsed.ignored) ? parsed.ignored : []);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
throw new Error(`Device ignore-list corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** True if `name` is on the ignore-list. */
|
|
195
|
+
export async function isIgnored(name) {
|
|
196
|
+
return (await loadIgnored()).has(name);
|
|
197
|
+
}
|
|
198
|
+
/** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
|
|
199
|
+
export async function addIgnored(name) {
|
|
200
|
+
assertValidDeviceName(name);
|
|
201
|
+
const p = ignoredPath();
|
|
202
|
+
return withRegistryLock(p, async () => {
|
|
203
|
+
const set = await loadIgnored();
|
|
204
|
+
set.add(name);
|
|
205
|
+
await atomicWriteJson(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
|
|
206
|
+
return set;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
210
|
+
* not ignored. */
|
|
211
|
+
export async function removeIgnored(name) {
|
|
212
|
+
const p = ignoredPath();
|
|
213
|
+
return withRegistryLock(p, async () => {
|
|
214
|
+
const set = await loadIgnored();
|
|
215
|
+
if (!set.delete(name))
|
|
216
|
+
return false;
|
|
217
|
+
await atomicWriteJson(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
|
|
218
|
+
return true;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type TailscaleNode } from './tailscale.js';
|
|
2
|
+
import type { PendingDevice } from './pending.js';
|
|
3
|
+
/**
|
|
4
|
+
* bootstrap — register every non-ignored node (opt-out). First-run `agents
|
|
5
|
+
* setup` and manual `agents devices sync`, so the fleet is usable out of box.
|
|
6
|
+
* refresh — only refresh reachability of already-registered nodes; a brand-new
|
|
7
|
+
* node is NOT auto-added, it is surfaced as `pending` for the user to approve
|
|
8
|
+
* (opt-in). Ongoing autosync and the daemon probe use this, so newcomers flow
|
|
9
|
+
* through the menu-bar "NEW DEVICES → Register / Ignore" gate instead of
|
|
10
|
+
* silently landing in the registry.
|
|
11
|
+
*/
|
|
12
|
+
export type DeviceSyncMode = 'bootstrap' | 'refresh';
|
|
13
|
+
export interface DeviceSyncResult {
|
|
14
|
+
/** False when discovery could not run (e.g. tailscale absent) in soft mode. */
|
|
15
|
+
ok: boolean;
|
|
16
|
+
/** Number of tailscale nodes upserted into the registry. */
|
|
17
|
+
synced: number;
|
|
18
|
+
/** Nodes discovered but neither registered-before nor ignored (name+platform). */
|
|
19
|
+
pending: PendingDevice[];
|
|
20
|
+
/** Populated when ok is false: why discovery was skipped. */
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Node names present on the tailnet but neither already in the registry nor on
|
|
25
|
+
* the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
|
|
26
|
+
* flag matrix is unit-testable without a live tailnet.
|
|
27
|
+
*/
|
|
28
|
+
export declare function computePendingDevices(nodes: TailscaleNode[], registered: Iterable<string>, ignored: Iterable<string>): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Which discovered nodes to upsert this run — the mode-defining decision, pure
|
|
31
|
+
* so it is unit-testable without a tailnet. Ignored nodes are always skipped.
|
|
32
|
+
* In `refresh` mode a node that isn't already registered is skipped too (it
|
|
33
|
+
* stays pending for approval); `bootstrap` includes every non-ignored node.
|
|
34
|
+
*/
|
|
35
|
+
export declare function selectNodesToUpsert(nodes: TailscaleNode[], registered: Set<string>, ignored: Set<string>, mode: DeviceSyncMode): TailscaleNode[];
|
|
36
|
+
/**
|
|
37
|
+
* Ingest `tailscale status --json` into the registry. In soft mode a missing
|
|
38
|
+
* tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
|
|
39
|
+
* throwing, so callers wiring this into setup/sync never abort the whole run.
|
|
40
|
+
* The `pending` list is computed against the registry state BEFORE this sync so
|
|
41
|
+
* "new" means "not previously registered and not ignored".
|
|
42
|
+
*/
|
|
43
|
+
export declare function runDeviceSync(opts?: {
|
|
44
|
+
soft?: boolean;
|
|
45
|
+
mode?: DeviceSyncMode;
|
|
46
|
+
}): Promise<DeviceSyncResult>;
|
|
47
|
+
/**
|
|
48
|
+
* The register/remove/ignore decision for the interactive curation picker.
|
|
49
|
+
* Pure so the highest-risk reconcile logic is unit-testable without a tailnet
|
|
50
|
+
* or a live prompt. `keep` is the set the user left checked; everything else is
|
|
51
|
+
* dismissed. Checked => register (and un-ignore if it was ignored). Unchecked
|
|
52
|
+
* => remove from the registry if it was there, and ignore it so auto-sync never
|
|
53
|
+
* re-adds it.
|
|
54
|
+
*/
|
|
55
|
+
export interface DeviceReconciliation {
|
|
56
|
+
toRegister: string[];
|
|
57
|
+
toUnignore: string[];
|
|
58
|
+
toRemove: string[];
|
|
59
|
+
toIgnore: string[];
|
|
60
|
+
}
|
|
61
|
+
export declare function planDeviceReconciliation(allNames: Iterable<string>, keep: Iterable<string>, registered: Iterable<string>, ignored: Iterable<string>): DeviceReconciliation;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable device discovery.
|
|
3
|
+
*
|
|
4
|
+
* `agents devices sync` was the only thing that ever populated the registry,
|
|
5
|
+
* and it was purely user-invoked — so the registry sat empty until someone
|
|
6
|
+
* remembered to run it. This module extracts the ingest so it can be triggered
|
|
7
|
+
* automatically (from `agents sync` and `agents setup`) without duplicating the
|
|
8
|
+
* tailscale-parse-and-upsert loop, and exposes the pure pending-device diff the
|
|
9
|
+
* curation picker and the menu-bar probe both need.
|
|
10
|
+
*
|
|
11
|
+
* Two failure modes, one function:
|
|
12
|
+
* - hard (default): the CLI `agents devices sync` action wants a clear error
|
|
13
|
+
* and a non-zero exit when tailscale is missing.
|
|
14
|
+
* - soft (`soft: true`): auto-callers must never abort setup/sync because a
|
|
15
|
+
* machine has no tailscale — they get a result with `ok: false` instead.
|
|
16
|
+
*/
|
|
17
|
+
import { loadDevices, loadIgnored, upsertDevice, } from './registry.js';
|
|
18
|
+
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from './tailscale.js';
|
|
19
|
+
/**
|
|
20
|
+
* Node names present on the tailnet but neither already in the registry nor on
|
|
21
|
+
* the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
|
|
22
|
+
* flag matrix is unit-testable without a live tailnet.
|
|
23
|
+
*/
|
|
24
|
+
export function computePendingDevices(nodes, registered, ignored) {
|
|
25
|
+
const known = new Set(registered);
|
|
26
|
+
const skip = new Set(ignored);
|
|
27
|
+
return nodes
|
|
28
|
+
.map((n) => n.name)
|
|
29
|
+
.filter((name) => !known.has(name) && !skip.has(name));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Which discovered nodes to upsert this run — the mode-defining decision, pure
|
|
33
|
+
* so it is unit-testable without a tailnet. Ignored nodes are always skipped.
|
|
34
|
+
* In `refresh` mode a node that isn't already registered is skipped too (it
|
|
35
|
+
* stays pending for approval); `bootstrap` includes every non-ignored node.
|
|
36
|
+
*/
|
|
37
|
+
export function selectNodesToUpsert(nodes, registered, ignored, mode) {
|
|
38
|
+
return nodes.filter((n) => {
|
|
39
|
+
if (ignored.has(n.name))
|
|
40
|
+
return false;
|
|
41
|
+
if (mode === 'refresh' && !registered.has(n.name))
|
|
42
|
+
return false;
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Ingest `tailscale status --json` into the registry. In soft mode a missing
|
|
48
|
+
* tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
|
|
49
|
+
* throwing, so callers wiring this into setup/sync never abort the whole run.
|
|
50
|
+
* The `pending` list is computed against the registry state BEFORE this sync so
|
|
51
|
+
* "new" means "not previously registered and not ignored".
|
|
52
|
+
*/
|
|
53
|
+
export async function runDeviceSync(opts = {}) {
|
|
54
|
+
const mode = opts.mode ?? 'bootstrap';
|
|
55
|
+
// Soft mode must be non-fatal for ANY failure, not just a missing tailscale:
|
|
56
|
+
// a corrupted registry/ignore file (both throw by design), a disk error, or
|
|
57
|
+
// registry lock contention (plausible when many agents SessionStart-autosync
|
|
58
|
+
// the same host at once) would otherwise abort the whole `agents sync`. The
|
|
59
|
+
// whole body is inside the guard so the "never a sync failure" promise holds.
|
|
60
|
+
try {
|
|
61
|
+
const nodes = parseTailscaleStatus(tailscaleStatusJson());
|
|
62
|
+
const [registeredBefore, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
|
|
63
|
+
const registered = new Set(Object.keys(registeredBefore));
|
|
64
|
+
const pendingNames = computePendingDevices(nodes, registered, ignored);
|
|
65
|
+
const byName = new Map(nodes.map((n) => [n.name, n]));
|
|
66
|
+
const pending = pendingNames.map((name) => ({
|
|
67
|
+
name,
|
|
68
|
+
platform: byName.get(name)?.platform ?? 'unknown',
|
|
69
|
+
}));
|
|
70
|
+
const toUpsert = selectNodesToUpsert(nodes, registered, ignored, mode);
|
|
71
|
+
for (const node of toUpsert) {
|
|
72
|
+
await upsertDevice(node.name, nodeToDeviceInput(node));
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, synced: toUpsert.length, pending };
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (opts.soft) {
|
|
78
|
+
return { ok: false, synced: 0, pending: [], reason: err?.message ?? String(err) };
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function planDeviceReconciliation(allNames, keep, registered, ignored) {
|
|
84
|
+
const keepSet = new Set(keep);
|
|
85
|
+
const regSet = new Set(registered);
|
|
86
|
+
const ignSet = new Set(ignored);
|
|
87
|
+
const out = { toRegister: [], toUnignore: [], toRemove: [], toIgnore: [] };
|
|
88
|
+
for (const name of allNames) {
|
|
89
|
+
if (keepSet.has(name)) {
|
|
90
|
+
out.toRegister.push(name);
|
|
91
|
+
if (ignSet.has(name))
|
|
92
|
+
out.toUnignore.push(name);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
if (regSet.has(name))
|
|
96
|
+
out.toRemove.push(name);
|
|
97
|
+
out.toIgnore.push(name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
@@ -28,6 +28,8 @@ export interface ActiveSession {
|
|
|
28
28
|
sessionFile?: string;
|
|
29
29
|
startedAtMs?: number;
|
|
30
30
|
status: ActiveStatus;
|
|
31
|
+
/** How many live PIDs resolve to this same session (subagents/forks). 1 unless collapsed. */
|
|
32
|
+
pidCount?: number;
|
|
31
33
|
teamName?: string;
|
|
32
34
|
agentId?: string;
|
|
33
35
|
cloudProvider?: string;
|
|
@@ -536,5 +536,33 @@ export async function getActiveSessions(opts = {}) {
|
|
|
536
536
|
if (s.pid)
|
|
537
537
|
knownPids.add(s.pid);
|
|
538
538
|
const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
|
|
539
|
-
return [...teams, ...terminals, ...cloud, ...unattributed];
|
|
539
|
+
return dedupeBySession([...teams, ...terminals, ...cloud, ...unattributed]);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Collapse rows that resolve to the *same* session — a session with many
|
|
543
|
+
* subagent/fork PIDs (all matched to one transcript file) would otherwise print
|
|
544
|
+
* dozens of identical rows. Keyed by session id (falling back to the file), the
|
|
545
|
+
* first row wins and carries a `pidCount`. Rows with no session identity (cloud,
|
|
546
|
+
* unresolved headless) pass through untouched.
|
|
547
|
+
*/
|
|
548
|
+
function dedupeBySession(sessions) {
|
|
549
|
+
const out = [];
|
|
550
|
+
const byKey = new Map();
|
|
551
|
+
for (const s of sessions) {
|
|
552
|
+
const key = s.sessionId || s.sessionFile;
|
|
553
|
+
if (!key) {
|
|
554
|
+
out.push(s);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
const existing = byKey.get(key);
|
|
558
|
+
if (existing) {
|
|
559
|
+
existing.pidCount = (existing.pidCount ?? 1) + 1;
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
s.pidCount = 1;
|
|
563
|
+
byKey.set(key, s);
|
|
564
|
+
out.push(s);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
540
568
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catch-up digest extractors.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that turn a session's events into the signals a developer needs
|
|
5
|
+
* to reload a task fast when switching between many agents: which files changed
|
|
6
|
+
* and how (created / modified / deleted), which tools dominated the work, and the
|
|
7
|
+
* last test/build result. Consumed by the single-session view and the picker
|
|
8
|
+
* preview. No I/O — fully unit-testable.
|
|
9
|
+
*/
|
|
10
|
+
import type { SessionEvent } from './types.js';
|
|
11
|
+
export type FileOp = 'created' | 'modified' | 'deleted';
|
|
12
|
+
export interface FileChange {
|
|
13
|
+
path: string;
|
|
14
|
+
op: FileOp;
|
|
15
|
+
}
|
|
16
|
+
/** Extract file paths deleted by a shell command (rm / git rm / unlink). Conservative. */
|
|
17
|
+
export declare function extractDeletedPaths(command: string): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Classify every touched file as created / modified / deleted from the event
|
|
20
|
+
* stream. Heuristics: a Write to a path never previously Read and not seen
|
|
21
|
+
* before is a *creation*; an Edit (or a Write to a known/read path) is a
|
|
22
|
+
* *modification*; a path in an `rm`/`git rm` command is a *deletion* (and wins
|
|
23
|
+
* over create/modify — a created-then-deleted file nets to gone). Plan files
|
|
24
|
+
* (`.claude/plans/*.md`) are excluded; they're surfaced by detectPlan.
|
|
25
|
+
*/
|
|
26
|
+
export declare function classifyFileChanges(events: SessionEvent[]): FileChange[];
|
|
27
|
+
/** Net change summary: counts per op. */
|
|
28
|
+
export declare function changeCounts(changes: FileChange[]): {
|
|
29
|
+
created: number;
|
|
30
|
+
modified: number;
|
|
31
|
+
deleted: number;
|
|
32
|
+
};
|
|
33
|
+
/** Tool histogram sorted highest-first, capped to `top` entries. */
|
|
34
|
+
export declare function toolHistogram(toolCounts: Record<string, number>, top?: number): Array<{
|
|
35
|
+
tool: string;
|
|
36
|
+
count: number;
|
|
37
|
+
}>;
|
|
38
|
+
export interface TestResult {
|
|
39
|
+
runner: string;
|
|
40
|
+
passed?: number;
|
|
41
|
+
failed?: number;
|
|
42
|
+
/** True when we could parse a pass/fail verdict. */
|
|
43
|
+
ok: boolean;
|
|
44
|
+
ts: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The most recent test/build run and its verdict. Correlates a runner command
|
|
48
|
+
* (tool_use) with the next tool_result's output. Returns undefined if none ran.
|
|
49
|
+
*/
|
|
50
|
+
export declare function detectTestResult(events: SessionEvent[]): TestResult | undefined;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catch-up digest extractors.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that turn a session's events into the signals a developer needs
|
|
5
|
+
* to reload a task fast when switching between many agents: which files changed
|
|
6
|
+
* and how (created / modified / deleted), which tools dominated the work, and the
|
|
7
|
+
* last test/build result. Consumed by the single-session view and the picker
|
|
8
|
+
* preview. No I/O — fully unit-testable.
|
|
9
|
+
*/
|
|
10
|
+
// Tool vocab mirrors parse.ts / render.ts so classification matches what those
|
|
11
|
+
// modules already recognize across Claude/Codex/others.
|
|
12
|
+
const READ_TOOLS = new Set(['Read', 'read_file', 'view_file', 'cat_file', 'get_file']);
|
|
13
|
+
const WRITE_TOOLS = new Set(['Write', 'write_file', 'create_file']);
|
|
14
|
+
const EDIT_TOOLS = new Set(['Edit', 'edit_file', 'replace', 'patch', 'MultiEdit', 'apply_patch']);
|
|
15
|
+
/** Extract file paths deleted by a shell command (rm / git rm / unlink). Conservative. */
|
|
16
|
+
export function extractDeletedPaths(command) {
|
|
17
|
+
const out = [];
|
|
18
|
+
// Split on && ; | to inspect each simple command separately.
|
|
19
|
+
for (const seg of command.split(/&&|\|\||;|\|/)) {
|
|
20
|
+
const m = seg.trim().match(/^(?:sudo\s+)?(?:git\s+rm|rm|unlink)\s+(.+)$/);
|
|
21
|
+
if (!m)
|
|
22
|
+
continue;
|
|
23
|
+
for (const tok of m[1].split(/\s+/)) {
|
|
24
|
+
if (tok.startsWith('-'))
|
|
25
|
+
continue; // flags (-r, -f, --force)
|
|
26
|
+
if (/[*?{}]/.test(tok))
|
|
27
|
+
continue; // globs — too imprecise to attribute
|
|
28
|
+
out.push(tok.replace(/^['"]|['"]$/g, '')); // unquote
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Classify every touched file as created / modified / deleted from the event
|
|
35
|
+
* stream. Heuristics: a Write to a path never previously Read and not seen
|
|
36
|
+
* before is a *creation*; an Edit (or a Write to a known/read path) is a
|
|
37
|
+
* *modification*; a path in an `rm`/`git rm` command is a *deletion* (and wins
|
|
38
|
+
* over create/modify — a created-then-deleted file nets to gone). Plan files
|
|
39
|
+
* (`.claude/plans/*.md`) are excluded; they're surfaced by detectPlan.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyFileChanges(events) {
|
|
42
|
+
const readBefore = new Set();
|
|
43
|
+
const created = new Set();
|
|
44
|
+
const modified = new Set();
|
|
45
|
+
const deleted = new Set();
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
for (const e of events) {
|
|
48
|
+
if (e.type !== 'tool_use' || e._local)
|
|
49
|
+
continue;
|
|
50
|
+
if (e.command)
|
|
51
|
+
for (const d of extractDeletedPaths(e.command))
|
|
52
|
+
deleted.add(d);
|
|
53
|
+
const tool = e.tool || '';
|
|
54
|
+
const args = e.args || {};
|
|
55
|
+
const p = e.path || args.file_path || args.path || '';
|
|
56
|
+
if (!p)
|
|
57
|
+
continue;
|
|
58
|
+
if (p.includes('.claude/plans/') && p.endsWith('.md'))
|
|
59
|
+
continue;
|
|
60
|
+
if (READ_TOOLS.has(tool)) {
|
|
61
|
+
readBefore.add(p);
|
|
62
|
+
}
|
|
63
|
+
else if (WRITE_TOOLS.has(tool)) {
|
|
64
|
+
if (!seen.has(p) && !readBefore.has(p))
|
|
65
|
+
created.add(p);
|
|
66
|
+
else
|
|
67
|
+
modified.add(p);
|
|
68
|
+
seen.add(p);
|
|
69
|
+
deleted.delete(p); // a write after a delete recreates the file
|
|
70
|
+
}
|
|
71
|
+
else if (EDIT_TOOLS.has(tool)) {
|
|
72
|
+
modified.add(p);
|
|
73
|
+
seen.add(p);
|
|
74
|
+
deleted.delete(p);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const p of created)
|
|
79
|
+
if (!deleted.has(p))
|
|
80
|
+
out.push({ path: p, op: 'created' });
|
|
81
|
+
for (const p of modified)
|
|
82
|
+
if (!created.has(p) && !deleted.has(p))
|
|
83
|
+
out.push({ path: p, op: 'modified' });
|
|
84
|
+
for (const p of deleted)
|
|
85
|
+
out.push({ path: p, op: 'deleted' });
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Net change summary: counts per op. */
|
|
89
|
+
export function changeCounts(changes) {
|
|
90
|
+
const c = { created: 0, modified: 0, deleted: 0 };
|
|
91
|
+
for (const ch of changes)
|
|
92
|
+
c[ch.op]++;
|
|
93
|
+
return c;
|
|
94
|
+
}
|
|
95
|
+
/** Tool histogram sorted highest-first, capped to `top` entries. */
|
|
96
|
+
export function toolHistogram(toolCounts, top = 8) {
|
|
97
|
+
return Object.entries(toolCounts)
|
|
98
|
+
.map(([tool, count]) => ({ tool, count }))
|
|
99
|
+
.sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool))
|
|
100
|
+
.slice(0, top);
|
|
101
|
+
}
|
|
102
|
+
/** Recognized test/build runners → the label we show. */
|
|
103
|
+
const TEST_RUNNERS = [
|
|
104
|
+
{ re: /\b((?:bun|npm|yarn|pnpm)\s+(?:run\s+)?test|vitest|jest)\b/, label: 'tests' },
|
|
105
|
+
{ re: /\bpytest\b/, label: 'pytest' },
|
|
106
|
+
{ re: /\bgo\s+test\b/, label: 'go test' },
|
|
107
|
+
{ re: /\bcargo\s+test\b/, label: 'cargo test' },
|
|
108
|
+
{ re: /\b(tsc|tsc\s+--noEmit)\b/, label: 'tsc' },
|
|
109
|
+
];
|
|
110
|
+
/** Parse pass/fail counts from common runner output. */
|
|
111
|
+
function parseTestOutput(runner, output) {
|
|
112
|
+
// vitest/jest/bun: "N passed", "N failed"; pytest: "N passed, N failed".
|
|
113
|
+
// Take the LAST occurrence — runners print a per-file line first, then the
|
|
114
|
+
// authoritative aggregate ("Tests 4 failed | 294 passed") at the end.
|
|
115
|
+
const lastNum = (re) => {
|
|
116
|
+
let m;
|
|
117
|
+
let val;
|
|
118
|
+
const g = new RegExp(re.source, 'gi');
|
|
119
|
+
while ((m = g.exec(output)) !== null)
|
|
120
|
+
val = +m[1];
|
|
121
|
+
return val;
|
|
122
|
+
};
|
|
123
|
+
const passed = lastNum(/(\d+)\s+pass(?:ed)?/);
|
|
124
|
+
const failed = lastNum(/(\d+)\s+fail(?:ed|ures?)?/);
|
|
125
|
+
if (passed !== undefined || failed !== undefined) {
|
|
126
|
+
return { passed, failed, ok: true };
|
|
127
|
+
}
|
|
128
|
+
// tsc: no news is good news; "error TSxxxx" means failure.
|
|
129
|
+
if (runner === 'tsc') {
|
|
130
|
+
const errs = output.match(/error\s+TS\d+/gi);
|
|
131
|
+
return { failed: errs ? errs.length : 0, ok: true };
|
|
132
|
+
}
|
|
133
|
+
// go test: no pass/fail counts — uses `--- PASS/FAIL:` lines and an ok/FAIL
|
|
134
|
+
// summary. Count the per-test markers; fall back to the summary verdict.
|
|
135
|
+
if (runner === 'go test') {
|
|
136
|
+
const passCount = (output.match(/---\s+PASS/gi) || []).length;
|
|
137
|
+
const failCount = (output.match(/---\s+FAIL/gi) || []).length;
|
|
138
|
+
const sawFail = failCount > 0 || /(^|\s)FAIL($|\s)/.test(output);
|
|
139
|
+
const sawOk = /(^|\s)(ok|PASS)($|\s)/.test(output);
|
|
140
|
+
if (sawFail || sawOk) {
|
|
141
|
+
return { passed: passCount || undefined, failed: sawFail ? failCount || 1 : 0, ok: true };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { ok: false };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The most recent test/build run and its verdict. Correlates a runner command
|
|
148
|
+
* (tool_use) with the next tool_result's output. Returns undefined if none ran.
|
|
149
|
+
*/
|
|
150
|
+
export function detectTestResult(events) {
|
|
151
|
+
let pending = null;
|
|
152
|
+
let last;
|
|
153
|
+
for (const e of events) {
|
|
154
|
+
const ts = new Date(e.timestamp).getTime() || 0;
|
|
155
|
+
if (e.type === 'tool_use' && e.command) {
|
|
156
|
+
const hit = TEST_RUNNERS.find(r => r.re.test(e.command));
|
|
157
|
+
pending = hit ? { runner: hit.label, ts } : pending;
|
|
158
|
+
}
|
|
159
|
+
else if (e.type === 'tool_result' && pending) {
|
|
160
|
+
const parsed = parseTestOutput(pending.runner, e.output || '');
|
|
161
|
+
last = { runner: pending.runner, ts: pending.ts, ...parsed };
|
|
162
|
+
pending = null;
|
|
163
|
+
}
|
|
164
|
+
else if (e.type === 'error' && pending) {
|
|
165
|
+
last = { runner: pending.runner, ts: pending.ts, ok: true, failed: 1 };
|
|
166
|
+
pending = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return last;
|
|
170
|
+
}
|
|
@@ -49,6 +49,8 @@ export interface SessionStats {
|
|
|
49
49
|
userTurns: number;
|
|
50
50
|
assistantTurns: number;
|
|
51
51
|
toolCount: number;
|
|
52
|
+
/** Per-tool call counts (histogram), highest first when rendered. */
|
|
53
|
+
toolCounts: Record<string, number>;
|
|
52
54
|
errorCount: number;
|
|
53
55
|
outputTokens: number;
|
|
54
56
|
cacheReadTokens: number;
|