@phnx-labs/agents-cli 1.20.89 → 1.20.91
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 +361 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +7 -1
- package/dist/commands/feed.js +77 -4
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/routines.d.ts +19 -0
- package/dist/commands/routines.js +58 -30
- package/dist/commands/secrets.d.ts +52 -4
- package/dist/commands/secrets.js +234 -37
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-browser.d.ts +4 -0
- package/dist/commands/sessions-browser.js +51 -9
- package/dist/commands/sessions-favorite.d.ts +20 -0
- package/dist/commands/sessions-favorite.js +120 -0
- package/dist/commands/sessions-picker.js +70 -1
- package/dist/commands/sessions.d.ts +103 -20
- package/dist/commands/sessions.js +356 -62
- package/dist/commands/setup-secrets.d.ts +7 -0
- package/dist/commands/setup-secrets.js +12 -9
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.d.ts +14 -1
- package/dist/commands/view.js +103 -128
- package/dist/index.js +18 -3
- package/dist/lib/activity.d.ts +11 -1
- package/dist/lib/activity.js +1 -0
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +1 -1
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- package/dist/lib/hosts/dispatch.js +19 -1
- package/dist/lib/hq/floor.js +12 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -0
- package/dist/lib/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- package/dist/lib/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -0
- package/dist/lib/session/active.d.ts +109 -3
- package/dist/lib/session/active.js +269 -13
- package/dist/lib/session/db.d.ts +14 -0
- package/dist/lib/session/db.js +35 -0
- package/dist/lib/session/digest.d.ts +7 -0
- package/dist/lib/session/digest.js +29 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/relative-time.d.ts +14 -0
- package/dist/lib/session/relative-time.js +36 -0
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/session/render.d.ts +7 -0
- package/dist/lib/session/render.js +87 -17
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +9 -0
- package/dist/lib/state.js +11 -0
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/usage-backoff.d.ts +29 -0
- package/dist/lib/usage-backoff.js +165 -0
- package/dist/lib/usage.d.ts +112 -5
- package/dist/lib/usage.js +464 -46
- package/dist/lib/watchdog/runner.d.ts +13 -0
- package/dist/lib/watchdog/runner.js +16 -1
- package/package.json +3 -1
|
@@ -93,7 +93,25 @@ export function remoteCdPrefix(remoteCwd, opts = {}) {
|
|
|
93
93
|
* collision, mirroring `buildExecEnv`'s `...options.env` precedence (exec.ts).
|
|
94
94
|
*/
|
|
95
95
|
export function withActorEnv(env) {
|
|
96
|
-
return { ...actorEnv(resolveActor()), ...(env ?? {}) };
|
|
96
|
+
return { ...actorEnv(resolveActor()), ...terminalIdEnv(), ...(env ?? {}) };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Forward the launching editor tab's `AGENT_TERMINAL_ID` across the SSH hop.
|
|
100
|
+
*
|
|
101
|
+
* Factory stamps it on every terminal it spawns, and the remote `agents run`
|
|
102
|
+
* records it in its pid registry (`writePidSessionEntry`) — which is what lets a
|
|
103
|
+
* tab ask the device "which session is MY terminal running?" instead of guessing
|
|
104
|
+
* from local state. Without the forward the remote registry has no terminal id,
|
|
105
|
+
* that question is unanswerable, and the tab is stuck with its spawn-time id even
|
|
106
|
+
* after the agent has moved to a different session (a `/clear`, or an exit and
|
|
107
|
+
* rerun in the same tab).
|
|
108
|
+
*
|
|
109
|
+
* Same shape as the actor provenance above: absent when the launch did not come
|
|
110
|
+
* from a tracked terminal, never fabricated.
|
|
111
|
+
*/
|
|
112
|
+
function terminalIdEnv() {
|
|
113
|
+
const terminalId = process.env.AGENT_TERMINAL_ID?.trim();
|
|
114
|
+
return terminalId ? { AGENT_TERMINAL_ID: terminalId } : {};
|
|
97
115
|
}
|
|
98
116
|
/**
|
|
99
117
|
* Launch a detached login-shell command in its own Unix session/process group.
|
package/dist/lib/hq/floor.js
CHANGED
|
@@ -30,6 +30,18 @@ function teammateName(session, teammatesById) {
|
|
|
30
30
|
function moodForSession(session, hasOpenBlock) {
|
|
31
31
|
if (session.status === 'abandoned')
|
|
32
32
|
return 'blocked';
|
|
33
|
+
// A crashed session is NOT `done` — it stopped without finishing, and its last
|
|
34
|
+
// parsed turn often still says `working`, which would otherwise reach the Floor
|
|
35
|
+
// as a happily-running agent. Both lost-host states need a human.
|
|
36
|
+
if (session.status === 'crashed')
|
|
37
|
+
return 'blocked';
|
|
38
|
+
// An orphaned session that is genuinely mid-question needs an answer; one that
|
|
39
|
+
// is merely idle-and-unwatched needs someone to reattach or clean it up. Same
|
|
40
|
+
// distinction `isAwaitingUser` draws — giving both the high-intensity "needs
|
|
41
|
+
// input" alert would train the operator to ignore it.
|
|
42
|
+
if (session.status === 'orphaned') {
|
|
43
|
+
return session.activity === 'waiting_input' ? 'waiting' : 'blocked';
|
|
44
|
+
}
|
|
33
45
|
if (session.status === 'closed')
|
|
34
46
|
return 'done';
|
|
35
47
|
if (session.status === 'input_required' || session.activity === 'waiting_input' || hasOpenBlock)
|
|
Binary file
|
package/dist/lib/overdue.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Surfaced two ways: a desktop notification on daemon startup, and a
|
|
12
12
|
* `agents routines catchup` command that runs them on demand.
|
|
13
13
|
*/
|
|
14
|
+
import { type JobConfig } from './routines.js';
|
|
14
15
|
export interface OverdueJob {
|
|
15
16
|
name: string;
|
|
16
17
|
/** Most recent expected fire time per the cron expression. */
|
|
@@ -18,6 +19,19 @@ export interface OverdueJob {
|
|
|
18
19
|
/** Start time of the most recent recorded run, or null if never run. */
|
|
19
20
|
lastRanAt: Date | null;
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* When a routine started existing, and therefore the earliest fire it can
|
|
24
|
+
* sensibly be judged against.
|
|
25
|
+
*
|
|
26
|
+
* `createdAt` is stamped by `writeJob`. Routines written before that field
|
|
27
|
+
* existed have none, so the file's own mtime stands in — it is the closest
|
|
28
|
+
* honest answer available on disk, and it only ever moves the floor later,
|
|
29
|
+
* never earlier, so it cannot manufacture a false "overdue".
|
|
30
|
+
*
|
|
31
|
+
* Returns null when neither is available, which leaves the routine unfloored
|
|
32
|
+
* (previous behaviour) rather than silently skipping it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function routineEffectiveStart(job: JobConfig): Date | null;
|
|
21
35
|
/** Return every enabled, recurring job whose most recent expected fire was
|
|
22
36
|
* missed. One-shot jobs are excluded — they fire at most once. */
|
|
23
37
|
export declare function detectOverdueJobs(now?: Date): OverdueJob[];
|
package/dist/lib/overdue.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* Surfaced two ways: a desktop notification on daemon startup, and a
|
|
12
12
|
* `agents routines catchup` command that runs them on demand.
|
|
13
13
|
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
14
15
|
import { Cron } from 'croner';
|
|
15
|
-
import { listJobs, getLatestRun, jobRunsOnThisDevice } from './routines.js';
|
|
16
|
+
import { listJobs, getLatestRun, getJobPath, jobRunsOnThisDevice } from './routines.js';
|
|
16
17
|
import { notifyDesktop } from './menubar/notify-desktop.js';
|
|
17
18
|
// Tolerance between "expected fire" and "recorded run start" — accounts for
|
|
18
19
|
// the small gap between the cron tick and when the runner writes meta.json.
|
|
@@ -36,6 +37,34 @@ function previousExpectedFire(cron, now) {
|
|
|
36
37
|
}
|
|
37
38
|
return last;
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* When a routine started existing, and therefore the earliest fire it can
|
|
42
|
+
* sensibly be judged against.
|
|
43
|
+
*
|
|
44
|
+
* `createdAt` is stamped by `writeJob`. Routines written before that field
|
|
45
|
+
* existed have none, so the file's own mtime stands in — it is the closest
|
|
46
|
+
* honest answer available on disk, and it only ever moves the floor later,
|
|
47
|
+
* never earlier, so it cannot manufacture a false "overdue".
|
|
48
|
+
*
|
|
49
|
+
* Returns null when neither is available, which leaves the routine unfloored
|
|
50
|
+
* (previous behaviour) rather than silently skipping it.
|
|
51
|
+
*/
|
|
52
|
+
export function routineEffectiveStart(job) {
|
|
53
|
+
if (job.createdAt) {
|
|
54
|
+
const stamped = new Date(job.createdAt);
|
|
55
|
+
if (!isNaN(stamped.getTime()))
|
|
56
|
+
return stamped;
|
|
57
|
+
}
|
|
58
|
+
const path = getJobPath(job.name);
|
|
59
|
+
if (!path)
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
return new Date(fs.statSync(path).mtimeMs);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
39
68
|
/** Return every enabled, recurring job whose most recent expected fire was
|
|
40
69
|
* missed. One-shot jobs are excluded — they fire at most once. */
|
|
41
70
|
export function detectOverdueJobs(now = new Date()) {
|
|
@@ -66,6 +95,13 @@ export function detectOverdueJobs(now = new Date()) {
|
|
|
66
95
|
}
|
|
67
96
|
if (!expected)
|
|
68
97
|
continue;
|
|
98
|
+
// A fire that predates the routine never could have happened, so it is not
|
|
99
|
+
// a miss. Without this, any newly created routine on a daily/weekly cron is
|
|
100
|
+
// instantly "overdue" for the previous occurrence — and with auto-catchup
|
|
101
|
+
// that means `agents routines add` runs the routine once, immediately.
|
|
102
|
+
const start = routineEffectiveStart(job);
|
|
103
|
+
if (start && expected.getTime() < start.getTime())
|
|
104
|
+
continue;
|
|
69
105
|
const latest = getLatestRun(job.name);
|
|
70
106
|
const lastRanAt = latest ? new Date(latest.startedAt) : null;
|
|
71
107
|
const isOverdue = !lastRanAt || lastRanAt.getTime() < expected.getTime() - GRACE_MS;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disposable performance warehouse — SQLite under ~/.agents/.cache/perf/.
|
|
3
|
+
*
|
|
4
|
+
* Opened only by `agents perf` / `hooks profile` (read path). Writers use
|
|
5
|
+
* {@link recordSample} in `./spool.ts` (NDJSON, no SQLite).
|
|
6
|
+
*/
|
|
7
|
+
import Database from '../sqlite.js';
|
|
8
|
+
import type { AggregateOptions, PerfAggregateRow } from './types.js';
|
|
9
|
+
export type { AggregateOptions, PerfAggregateRow, PerfSample } from './types.js';
|
|
10
|
+
export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
|
|
11
|
+
export declare const PERF_SCHEMA_VERSION = 1;
|
|
12
|
+
export declare const DEFAULT_RETENTION_DAYS = 30;
|
|
13
|
+
/** Test seam — redirect the warehouse path (like AGENTS_EVENTS_PATH). */
|
|
14
|
+
export declare function _resetPerfDbForTest(overridePath?: string | null): void;
|
|
15
|
+
/** Drain the NDJSON spool into samples. Idempotent; truncates on success. */
|
|
16
|
+
export declare function drainSpool(db?: Database.Database): number;
|
|
17
|
+
/** Percentile of a sorted-ascending array. p in [0,100]. */
|
|
18
|
+
export declare function percentile(sorted: number[], p: number): number;
|
|
19
|
+
/**
|
|
20
|
+
* Aggregate samples by (kind, label) with p50/p99. Drains the spool first.
|
|
21
|
+
*/
|
|
22
|
+
export declare function aggregateSamples(opts?: AggregateOptions): PerfAggregateRow[];
|
|
23
|
+
export declare function perfDbPath(): string;
|
|
24
|
+
export declare function perfSpoolPath(): string;
|
|
25
|
+
export declare function ensurePerfDir(): string;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disposable performance warehouse — SQLite under ~/.agents/.cache/perf/.
|
|
3
|
+
*
|
|
4
|
+
* Opened only by `agents perf` / `hooks profile` (read path). Writers use
|
|
5
|
+
* {@link recordSample} in `./spool.ts` (NDJSON, no SQLite).
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import Database from '../sqlite.js';
|
|
10
|
+
import { getPerfDbPath, getPerfDir } from '../state.js';
|
|
11
|
+
import { localMachineId } from '../session/origin-machine.js';
|
|
12
|
+
import { resolveSpoolPath, shortSessionId, _resetPerfSpoolForTest } from './spool.js';
|
|
13
|
+
export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
|
|
14
|
+
export const PERF_SCHEMA_VERSION = 1;
|
|
15
|
+
export const DEFAULT_RETENTION_DAYS = 30;
|
|
16
|
+
const SCHEMA = `
|
|
17
|
+
CREATE TABLE IF NOT EXISTS samples (
|
|
18
|
+
id INTEGER PRIMARY KEY,
|
|
19
|
+
ts_ms INTEGER NOT NULL,
|
|
20
|
+
kind TEXT NOT NULL,
|
|
21
|
+
label TEXT NOT NULL,
|
|
22
|
+
duration_ms REAL NOT NULL,
|
|
23
|
+
session_id TEXT,
|
|
24
|
+
session_short TEXT,
|
|
25
|
+
agent TEXT,
|
|
26
|
+
agent_version TEXT,
|
|
27
|
+
machine TEXT,
|
|
28
|
+
hostname TEXT,
|
|
29
|
+
actor TEXT,
|
|
30
|
+
cwd TEXT,
|
|
31
|
+
cache TEXT,
|
|
32
|
+
exit_code INTEGER,
|
|
33
|
+
status TEXT,
|
|
34
|
+
meta_json TEXT
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_perf_ts ON samples(ts_ms);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_perf_kind_ts ON samples(kind, ts_ms);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_perf_label_ts ON samples(label, ts_ms);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_perf_machine_ts ON samples(machine, ts_ms);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_perf_session ON samples(session_id);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
42
|
+
key TEXT PRIMARY KEY,
|
|
43
|
+
value TEXT NOT NULL
|
|
44
|
+
);
|
|
45
|
+
`;
|
|
46
|
+
let _db = null;
|
|
47
|
+
let _dbPath = null;
|
|
48
|
+
let _disabled = false;
|
|
49
|
+
/** Test seam — redirect the warehouse path (like AGENTS_EVENTS_PATH). */
|
|
50
|
+
export function _resetPerfDbForTest(overridePath) {
|
|
51
|
+
if (_db) {
|
|
52
|
+
try {
|
|
53
|
+
_db.close();
|
|
54
|
+
}
|
|
55
|
+
catch { /* ignore */ }
|
|
56
|
+
}
|
|
57
|
+
_db = null;
|
|
58
|
+
_dbPath = overridePath === undefined ? null : overridePath;
|
|
59
|
+
_disabled = false;
|
|
60
|
+
if (overridePath) {
|
|
61
|
+
_resetPerfSpoolForTest(path.join(path.dirname(overridePath), 'spool.jsonl'));
|
|
62
|
+
}
|
|
63
|
+
else if (overridePath === null) {
|
|
64
|
+
_resetPerfSpoolForTest(null);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function resolveDbPath() {
|
|
68
|
+
return process.env.AGENTS_PERF_DB || _dbPath || getPerfDbPath();
|
|
69
|
+
}
|
|
70
|
+
function isDisabled() {
|
|
71
|
+
if (_disabled)
|
|
72
|
+
return true;
|
|
73
|
+
const v = process.env.AGENTS_DISABLE_PERF;
|
|
74
|
+
return v === '1' || v === 'true';
|
|
75
|
+
}
|
|
76
|
+
function openDb() {
|
|
77
|
+
if (isDisabled())
|
|
78
|
+
return null;
|
|
79
|
+
const dbPath = resolveDbPath();
|
|
80
|
+
if (_db && _dbPath === dbPath)
|
|
81
|
+
return _db;
|
|
82
|
+
if (_db) {
|
|
83
|
+
try {
|
|
84
|
+
_db.close();
|
|
85
|
+
}
|
|
86
|
+
catch { /* ignore */ }
|
|
87
|
+
_db = null;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o700 });
|
|
91
|
+
const db = new Database(dbPath);
|
|
92
|
+
db.pragma('journal_mode = WAL');
|
|
93
|
+
db.pragma('synchronous = NORMAL');
|
|
94
|
+
db.pragma('busy_timeout = 2000');
|
|
95
|
+
db.exec(SCHEMA);
|
|
96
|
+
const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
|
|
97
|
+
if (!row) {
|
|
98
|
+
db.prepare(`INSERT INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(PERF_SCHEMA_VERSION));
|
|
99
|
+
}
|
|
100
|
+
_db = db;
|
|
101
|
+
_dbPath = dbPath;
|
|
102
|
+
drainSpool(db);
|
|
103
|
+
maybeRetain(db);
|
|
104
|
+
return db;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
_disabled = true;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Drain the NDJSON spool into samples. Idempotent; truncates on success. */
|
|
112
|
+
export function drainSpool(db) {
|
|
113
|
+
const spool = resolveSpoolPath();
|
|
114
|
+
if (!fs.existsSync(spool))
|
|
115
|
+
return 0;
|
|
116
|
+
let raw;
|
|
117
|
+
try {
|
|
118
|
+
raw = fs.readFileSync(spool, 'utf-8');
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
if (!raw.trim()) {
|
|
124
|
+
try {
|
|
125
|
+
fs.writeFileSync(spool, '');
|
|
126
|
+
}
|
|
127
|
+
catch { /* ignore */ }
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
const target = db ?? openDb();
|
|
131
|
+
if (!target)
|
|
132
|
+
return 0;
|
|
133
|
+
let n = 0;
|
|
134
|
+
const insert = target.prepare(`
|
|
135
|
+
INSERT INTO samples (
|
|
136
|
+
ts_ms, kind, label, duration_ms,
|
|
137
|
+
session_id, session_short, agent, agent_version,
|
|
138
|
+
machine, hostname, actor, cwd,
|
|
139
|
+
cache, exit_code, status, meta_json
|
|
140
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
141
|
+
`);
|
|
142
|
+
const txn = target.transaction((lines) => {
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
if (!line.trim())
|
|
145
|
+
continue;
|
|
146
|
+
let o;
|
|
147
|
+
try {
|
|
148
|
+
o = JSON.parse(line);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const label = String(o.label ?? o.hook ?? '');
|
|
154
|
+
const durationMs = Number(o.duration_ms ?? o.durationMs ?? o.ms);
|
|
155
|
+
if (!label || !Number.isFinite(durationMs))
|
|
156
|
+
continue;
|
|
157
|
+
const sessionId = o.session_id != null ? String(o.session_id)
|
|
158
|
+
: o.sessionId != null ? String(o.sessionId) : null;
|
|
159
|
+
const tsMs = Number(o.ts_ms ?? o.tsMs) || (typeof o.ts === 'string' ? Date.parse(o.ts) : Date.now());
|
|
160
|
+
insert.run(Number.isFinite(tsMs) ? tsMs : Date.now(), String(o.kind ?? 'hook.fire'), label, durationMs, sessionId, o.session_short != null ? String(o.session_short) : shortSessionId(sessionId) ?? null, o.agent != null ? String(o.agent) : null, o.agent_version != null ? String(o.agent_version) : o.agentVersion != null ? String(o.agentVersion) : null, o.machine != null ? String(o.machine) : localMachineId(), o.hostname != null ? String(o.hostname) : null, o.actor != null ? String(o.actor) : null, o.cwd != null ? String(o.cwd) : null, o.cache != null ? String(o.cache) : null, o.exit_code != null ? Number(o.exit_code) : o.exit != null ? Number(o.exit) : null, o.status != null ? String(o.status) : null, o.meta_json != null ? String(o.meta_json) : null);
|
|
161
|
+
n++;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
txn(raw.split('\n'));
|
|
166
|
+
fs.writeFileSync(spool, '');
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
return n;
|
|
172
|
+
}
|
|
173
|
+
function maybeRetain(db) {
|
|
174
|
+
try {
|
|
175
|
+
const last = db.prepare(`SELECT value FROM meta WHERE key = 'last_retain_ms'`).get();
|
|
176
|
+
const lastMs = last ? parseInt(last.value, 10) : 0;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
if (now - lastMs < 3_600_000)
|
|
179
|
+
return;
|
|
180
|
+
const cutoff = now - DEFAULT_RETENTION_DAYS * 86_400_000;
|
|
181
|
+
db.prepare(`DELETE FROM samples WHERE ts_ms < ?`).run(cutoff);
|
|
182
|
+
db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('last_retain_ms', ?)`).run(String(now));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// ignore
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Percentile of a sorted-ascending array. p in [0,100]. */
|
|
189
|
+
export function percentile(sorted, p) {
|
|
190
|
+
if (sorted.length === 0)
|
|
191
|
+
return 0;
|
|
192
|
+
if (sorted.length === 1)
|
|
193
|
+
return sorted[0];
|
|
194
|
+
const rank = (p / 100) * (sorted.length - 1);
|
|
195
|
+
const lo = Math.floor(rank);
|
|
196
|
+
const hi = Math.ceil(rank);
|
|
197
|
+
if (lo === hi)
|
|
198
|
+
return sorted[lo];
|
|
199
|
+
const frac = rank - lo;
|
|
200
|
+
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Aggregate samples by (kind, label) with p50/p99. Drains the spool first.
|
|
204
|
+
*/
|
|
205
|
+
export function aggregateSamples(opts = {}) {
|
|
206
|
+
const db = openDb();
|
|
207
|
+
if (!db)
|
|
208
|
+
return [];
|
|
209
|
+
drainSpool(db);
|
|
210
|
+
const days = opts.days ?? 7;
|
|
211
|
+
const sinceMs = Date.now() - days * 86_400_000;
|
|
212
|
+
const minN = opts.minN ?? 1;
|
|
213
|
+
const clauses = ['ts_ms >= ?'];
|
|
214
|
+
const params = [sinceMs];
|
|
215
|
+
if (opts.kinds && opts.kinds.length > 0) {
|
|
216
|
+
clauses.push(`kind IN (${opts.kinds.map(() => '?').join(',')})`);
|
|
217
|
+
params.push(...opts.kinds);
|
|
218
|
+
}
|
|
219
|
+
if (opts.label) {
|
|
220
|
+
clauses.push('label = ?');
|
|
221
|
+
params.push(opts.label);
|
|
222
|
+
}
|
|
223
|
+
if (opts.machine) {
|
|
224
|
+
clauses.push('machine = ?');
|
|
225
|
+
params.push(opts.machine);
|
|
226
|
+
}
|
|
227
|
+
if (opts.agent) {
|
|
228
|
+
clauses.push('agent = ?');
|
|
229
|
+
params.push(opts.agent);
|
|
230
|
+
}
|
|
231
|
+
const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code
|
|
232
|
+
FROM samples WHERE ${clauses.join(' AND ')}`).all(...params);
|
|
233
|
+
const map = new Map();
|
|
234
|
+
for (const r of rows) {
|
|
235
|
+
const key = `${r.kind}\0${r.label}`;
|
|
236
|
+
let b = map.get(key);
|
|
237
|
+
if (!b) {
|
|
238
|
+
b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0 };
|
|
239
|
+
map.set(key, b);
|
|
240
|
+
}
|
|
241
|
+
b.durations.push(Number(r.duration_ms));
|
|
242
|
+
if (r.cache === 'hit')
|
|
243
|
+
b.hits++;
|
|
244
|
+
else if (r.cache === 'stale-prefetch')
|
|
245
|
+
b.stale++;
|
|
246
|
+
else if (r.cache === 'miss' || r.cache === 'none')
|
|
247
|
+
b.misses++;
|
|
248
|
+
if (typeof r.exit_code === 'number' && r.exit_code !== 0)
|
|
249
|
+
b.errors++;
|
|
250
|
+
}
|
|
251
|
+
const out = [];
|
|
252
|
+
for (const b of map.values()) {
|
|
253
|
+
if (b.durations.length < minN)
|
|
254
|
+
continue;
|
|
255
|
+
const sorted = b.durations.slice().sort((a, c) => a - c);
|
|
256
|
+
const n = sorted.length;
|
|
257
|
+
const sum = sorted.reduce((a, c) => a + c, 0);
|
|
258
|
+
const row = {
|
|
259
|
+
kind: b.kind,
|
|
260
|
+
label: b.label,
|
|
261
|
+
n,
|
|
262
|
+
p50Ms: Math.round(percentile(sorted, 50)),
|
|
263
|
+
p99Ms: Math.round(percentile(sorted, 99)),
|
|
264
|
+
meanMs: Math.round(sum / n),
|
|
265
|
+
maxMs: sorted[n - 1],
|
|
266
|
+
minMs: sorted[0],
|
|
267
|
+
};
|
|
268
|
+
if (b.hits + b.stale + b.misses > 0) {
|
|
269
|
+
row.cacheHitPct = Math.round((b.hits / n) * 100);
|
|
270
|
+
row.cacheStalePct = Math.round((b.stale / n) * 100);
|
|
271
|
+
row.cacheMissPct = Math.round((b.misses / n) * 100);
|
|
272
|
+
}
|
|
273
|
+
if (b.errors > 0)
|
|
274
|
+
row.errorCount = b.errors;
|
|
275
|
+
out.push(row);
|
|
276
|
+
}
|
|
277
|
+
out.sort((a, b) => b.p99Ms - a.p99Ms);
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
export function perfDbPath() {
|
|
281
|
+
return resolveDbPath();
|
|
282
|
+
}
|
|
283
|
+
export function perfSpoolPath() {
|
|
284
|
+
return resolveSpoolPath();
|
|
285
|
+
}
|
|
286
|
+
export function ensurePerfDir() {
|
|
287
|
+
const dir = process.env.AGENTS_PERF_DIR || (_dbPath ? path.dirname(_dbPath) : getPerfDir());
|
|
288
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
289
|
+
return dir;
|
|
290
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot-path perf writers — append-only NDJSON spool, no SQLite.
|
|
3
|
+
*
|
|
4
|
+
* Loaded from the CLI root `postAction` and from `events.ts` timing helpers.
|
|
5
|
+
* Must stay free of `../sqlite.js` so ordinary commands never load node:sqlite
|
|
6
|
+
* (which emits ExperimentalWarning on stderr).
|
|
7
|
+
*/
|
|
8
|
+
import type { PerfSample } from './types.js';
|
|
9
|
+
export type { PerfSample } from './types.js';
|
|
10
|
+
/** Test seam — pair with db._resetPerfDbForTest. */
|
|
11
|
+
export declare function _resetPerfSpoolForTest(spoolPath?: string | null): void;
|
|
12
|
+
export declare function resolveSpoolPath(): string;
|
|
13
|
+
/** Short session id: first 8 chars (sessions.short_id shape). */
|
|
14
|
+
export declare function shortSessionId(sessionId: string | undefined | null): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Append one sample to the spool. Never throws. Never opens SQLite.
|
|
17
|
+
*/
|
|
18
|
+
export declare function recordSample(sample: PerfSample): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot-path perf writers — append-only NDJSON spool, no SQLite.
|
|
3
|
+
*
|
|
4
|
+
* Loaded from the CLI root `postAction` and from `events.ts` timing helpers.
|
|
5
|
+
* Must stay free of `../sqlite.js` so ordinary commands never load node:sqlite
|
|
6
|
+
* (which emits ExperimentalWarning on stderr).
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import { getPerfSpoolPath } from '../state.js';
|
|
12
|
+
import { localMachineId } from '../session/origin-machine.js';
|
|
13
|
+
let _spoolOverride = null;
|
|
14
|
+
let _disabled = false;
|
|
15
|
+
/** Test seam — pair with db._resetPerfDbForTest. */
|
|
16
|
+
export function _resetPerfSpoolForTest(spoolPath) {
|
|
17
|
+
_spoolOverride = spoolPath === undefined ? null : spoolPath;
|
|
18
|
+
_disabled = false;
|
|
19
|
+
}
|
|
20
|
+
export function resolveSpoolPath() {
|
|
21
|
+
if (process.env.AGENTS_PERF_SPOOL)
|
|
22
|
+
return process.env.AGENTS_PERF_SPOOL;
|
|
23
|
+
if (_spoolOverride)
|
|
24
|
+
return _spoolOverride;
|
|
25
|
+
return getPerfSpoolPath();
|
|
26
|
+
}
|
|
27
|
+
function isDisabled() {
|
|
28
|
+
if (_disabled)
|
|
29
|
+
return true;
|
|
30
|
+
const v = process.env.AGENTS_DISABLE_PERF;
|
|
31
|
+
return v === '1' || v === 'true';
|
|
32
|
+
}
|
|
33
|
+
/** Short session id: first 8 chars (sessions.short_id shape). */
|
|
34
|
+
export function shortSessionId(sessionId) {
|
|
35
|
+
if (!sessionId)
|
|
36
|
+
return undefined;
|
|
37
|
+
const cleaned = sessionId.replace(/^session_/, '');
|
|
38
|
+
return cleaned.length >= 8 ? cleaned.slice(0, 8) : cleaned || undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Append one sample to the spool. Never throws. Never opens SQLite.
|
|
42
|
+
*/
|
|
43
|
+
export function recordSample(sample) {
|
|
44
|
+
if (isDisabled())
|
|
45
|
+
return;
|
|
46
|
+
if (!sample.label || !Number.isFinite(sample.durationMs))
|
|
47
|
+
return;
|
|
48
|
+
try {
|
|
49
|
+
const tsMs = sample.tsMs ?? Date.now();
|
|
50
|
+
const sessionId = sample.sessionId;
|
|
51
|
+
const sessionShort = sample.sessionShort ?? shortSessionId(sessionId);
|
|
52
|
+
const machine = sample.machine ?? localMachineId();
|
|
53
|
+
const hostname = sample.hostname ?? os.hostname();
|
|
54
|
+
const line = JSON.stringify({
|
|
55
|
+
ts_ms: tsMs,
|
|
56
|
+
kind: sample.kind,
|
|
57
|
+
label: sample.label,
|
|
58
|
+
duration_ms: sample.durationMs,
|
|
59
|
+
session_id: sessionId,
|
|
60
|
+
session_short: sessionShort,
|
|
61
|
+
agent: sample.agent,
|
|
62
|
+
agent_version: sample.agentVersion,
|
|
63
|
+
machine,
|
|
64
|
+
hostname,
|
|
65
|
+
actor: sample.actor,
|
|
66
|
+
cwd: sample.cwd,
|
|
67
|
+
cache: sample.cache,
|
|
68
|
+
exit_code: sample.exitCode,
|
|
69
|
+
status: sample.status,
|
|
70
|
+
meta_json: sample.metaJson,
|
|
71
|
+
});
|
|
72
|
+
const spool = resolveSpoolPath();
|
|
73
|
+
fs.mkdirSync(path.dirname(spool), { recursive: true, mode: 0o700 });
|
|
74
|
+
fs.appendFileSync(spool, line + '\n', { mode: 0o600 });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Fail soft.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Shared perf sample shape (spool NDJSON + SQLite rows). */
|
|
2
|
+
export type PerfKind = 'hook.fire' | 'perf.timing' | 'command.end' | string;
|
|
3
|
+
export interface PerfSample {
|
|
4
|
+
tsMs?: number;
|
|
5
|
+
kind: PerfKind;
|
|
6
|
+
label: string;
|
|
7
|
+
durationMs: number;
|
|
8
|
+
/** Full session id — same string as sessions.id when known. */
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
/** First 8 chars of sessionId (sessions.short_id shape). */
|
|
11
|
+
sessionShort?: string;
|
|
12
|
+
agent?: string;
|
|
13
|
+
agentVersion?: string;
|
|
14
|
+
/** Fleet registry name (sessions.machine), preferred over raw hostname. */
|
|
15
|
+
machine?: string;
|
|
16
|
+
hostname?: string;
|
|
17
|
+
actor?: string;
|
|
18
|
+
cwd?: string;
|
|
19
|
+
cache?: string;
|
|
20
|
+
exitCode?: number;
|
|
21
|
+
status?: string;
|
|
22
|
+
metaJson?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface PerfAggregateRow {
|
|
25
|
+
kind: string;
|
|
26
|
+
label: string;
|
|
27
|
+
n: number;
|
|
28
|
+
p50Ms: number;
|
|
29
|
+
p99Ms: number;
|
|
30
|
+
meanMs: number;
|
|
31
|
+
maxMs: number;
|
|
32
|
+
minMs: number;
|
|
33
|
+
cacheHitPct?: number;
|
|
34
|
+
cacheStalePct?: number;
|
|
35
|
+
cacheMissPct?: number;
|
|
36
|
+
errorCount?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface AggregateOptions {
|
|
39
|
+
days?: number;
|
|
40
|
+
kinds?: string[];
|
|
41
|
+
label?: string;
|
|
42
|
+
machine?: string;
|
|
43
|
+
agent?: string;
|
|
44
|
+
minN?: number;
|
|
45
|
+
}
|
package/dist/lib/picker.d.ts
CHANGED
|
@@ -77,9 +77,14 @@ export interface DynamicPickerConfig<T, F> {
|
|
|
77
77
|
/**
|
|
78
78
|
* Side-effecting keys that don't change the filter (e.g. `y` copies a command).
|
|
79
79
|
* Receives the live search `query` so the effect can be search-aware. Return a
|
|
80
|
-
* short string to flash under the list
|
|
80
|
+
* short string to flash under the list, or `{ flash, reload }` when the effect
|
|
81
|
+
* changed something the rows RENDER (a star, a mark) and the list has to be
|
|
82
|
+
* rebuilt — the row labels are memoized, so a flash alone leaves them stale.
|
|
81
83
|
*/
|
|
82
|
-
onKey?: (name: string, filter: F, active: T | undefined, query: string) => string | void
|
|
84
|
+
onKey?: (name: string, filter: F, active: T | undefined, query: string) => string | void | {
|
|
85
|
+
flash?: string;
|
|
86
|
+
reload?: boolean;
|
|
87
|
+
};
|
|
83
88
|
/** Key that enters search mode (default `s`). */
|
|
84
89
|
searchKey?: string;
|
|
85
90
|
/** Key that toggles the preview pane (default `tab`). */
|
|
@@ -89,6 +94,26 @@ export interface DynamicPickerConfig<T, F> {
|
|
|
89
94
|
loadingMessage?: string;
|
|
90
95
|
enterHint?: string;
|
|
91
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* The lookup token for a hotkey: the literal character the key produced, else
|
|
99
|
+
* readline's key name (`tab`, `escape`, arrows).
|
|
100
|
+
*
|
|
101
|
+
* readline reports both `f` and `F` as name `f` — only `sequence` tells them
|
|
102
|
+
* apart — and gives a punctuation key like `*` no name at all. Keying on the
|
|
103
|
+
* character makes shifted letters and punctuation bindable, and is a no-op for
|
|
104
|
+
* every existing binding: for a plain lowercase letter, sequence === name.
|
|
105
|
+
*
|
|
106
|
+
* Callers receiving this in `onKey` see the literal character, so a handler that
|
|
107
|
+
* wants to accept both cases of a letter must say so (`'y'` and `'Y'`); the
|
|
108
|
+
* keyBindings lookup falls back to the key NAME, which keeps the shifted form of
|
|
109
|
+
* an existing single-letter binding working without each caller restating it.
|
|
110
|
+
*/
|
|
111
|
+
export declare function hotkeyToken(key: {
|
|
112
|
+
name?: string;
|
|
113
|
+
sequence?: string;
|
|
114
|
+
ctrl?: boolean;
|
|
115
|
+
meta?: boolean;
|
|
116
|
+
}): string;
|
|
92
117
|
/** The result returned when the user selects a row: the item plus the live filter. */
|
|
93
118
|
export interface DynamicPicked<T, F> {
|
|
94
119
|
item: T;
|