@phnx-labs/agents-cli 1.21.0 → 1.21.2
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 +198 -0
- package/README.md +2 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/feed.js +63 -40
- package/dist/commands/hooks.js +9 -45
- package/dist/commands/menubar.js +24 -24
- package/dist/commands/message.js +23 -3
- package/dist/commands/monitors.js +8 -6
- package/dist/commands/perf.d.ts +13 -0
- package/dist/commands/perf.js +80 -23
- package/dist/commands/projects.d.ts +11 -0
- package/dist/commands/projects.js +153 -21
- package/dist/commands/routines.js +46 -1
- package/dist/commands/ssh.js +69 -0
- package/dist/commands/trends.d.ts +2 -0
- package/dist/commands/trends.js +158 -0
- package/dist/commands/usage.d.ts +4 -4
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +90 -45
- package/dist/index.js +14 -1
- package/dist/lib/activity.d.ts +6 -0
- package/dist/lib/activity.js +3 -0
- package/dist/lib/agents.js +2 -2
- package/dist/lib/analytics/dashboard.d.ts +11 -0
- package/dist/lib/analytics/dashboard.js +31 -0
- package/dist/lib/analytics/recipes.d.ts +32 -0
- package/dist/lib/analytics/recipes.js +316 -0
- package/dist/lib/analytics/usage-db.d.ts +84 -0
- package/dist/lib/analytics/usage-db.js +301 -0
- package/dist/lib/browser/service.js +18 -0
- package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
- package/dist/lib/channels/resolve.d.ts +18 -1
- package/dist/lib/channels/resolve.js +15 -5
- package/dist/lib/cli-resources.d.ts +20 -0
- package/dist/lib/cli-resources.js +48 -1
- package/dist/lib/daemon.js +51 -14
- package/dist/lib/devices/health-report.d.ts +5 -0
- package/dist/lib/devices/health-report.js +3 -0
- package/dist/lib/feed-broadcast.d.ts +93 -12
- package/dist/lib/feed-broadcast.js +235 -30
- package/dist/lib/feed-post.d.ts +16 -2
- package/dist/lib/feed-post.js +35 -7
- package/dist/lib/feed.js +1 -1
- package/dist/lib/fleet-cache.d.ts +37 -0
- package/dist/lib/fleet-cache.js +40 -0
- package/dist/lib/fleet-status.d.ts +53 -0
- package/dist/lib/fleet-status.js +120 -0
- package/dist/lib/friction-heuristics.d.ts +32 -0
- package/dist/lib/friction-heuristics.js +47 -0
- package/dist/lib/hooks/cache.js +28 -6
- package/dist/lib/hooks/profile.d.ts +8 -0
- package/dist/lib/hooks/profile.js +14 -4
- package/dist/lib/hooks.d.ts +16 -1
- package/dist/lib/hooks.js +133 -18
- package/dist/lib/linear-cache.d.ts +63 -0
- package/dist/lib/linear-cache.js +146 -0
- package/dist/lib/linear-project-counts.d.ts +35 -5
- package/dist/lib/linear-project-counts.js +61 -16
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +7 -0
- package/dist/lib/menubar/install-menubar.js +36 -6
- package/dist/lib/monitors/config.d.ts +1 -1
- package/dist/lib/monitors/dispatch.d.ts +6 -2
- package/dist/lib/monitors/dispatch.js +10 -15
- package/dist/lib/notify.d.ts +42 -6
- package/dist/lib/notify.js +41 -32
- package/dist/lib/perf/db.d.ts +6 -1
- package/dist/lib/perf/db.js +35 -5
- package/dist/lib/perf/types.d.ts +10 -0
- package/dist/lib/project-doctor.d.ts +36 -0
- package/dist/lib/project-doctor.js +45 -0
- package/dist/lib/project-import.d.ts +11 -1
- package/dist/lib/project-import.js +17 -3
- package/dist/lib/project-status.d.ts +25 -5
- package/dist/lib/project-status.js +48 -6
- package/dist/lib/rotate.d.ts +27 -0
- package/dist/lib/rotate.js +44 -17
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +39 -0
- package/dist/lib/runner.js +34 -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/usage-db.d.ts +3 -63
- package/dist/lib/secrets/usage-db.js +46 -186
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +14 -3
- package/dist/lib/session/discover.d.ts +3 -0
- package/dist/lib/session/discover.js +8 -0
- package/dist/lib/session/types.d.ts +1 -0
- 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 +31 -3
- package/dist/lib/state.js +53 -10
- package/dist/lib/types.d.ts +14 -4
- package/dist/lib/usage-refresh.d.ts +106 -0
- package/dist/lib/usage-refresh.js +238 -0
- package/dist/lib/usage.d.ts +152 -17
- package/dist/lib/usage.js +393 -79
- package/package.json +1 -1
package/dist/lib/notify.js
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Reuses the OpenClaw Telegram gateway on the local mac-mini (Jeff/`default` bot)
|
|
5
|
-
* instead of raw bot tokens. Notifies once per block (tracked by `notifiedAt`).
|
|
6
|
-
* Best-effort: any openclaw failure is surfaced as a warning, not a hard error,
|
|
7
|
-
* so a notification hiccup never blocks the agent.
|
|
8
|
-
*/
|
|
9
|
-
import { execFile } from 'child_process';
|
|
10
|
-
import { promisify } from 'util';
|
|
11
|
-
const execFileAsync = promisify(execFile);
|
|
1
|
+
import { readMeta } from './state.js';
|
|
2
|
+
import { registerBuiltinProviders } from './channels/providers/index.js';
|
|
3
|
+
import { lookupTransport } from './channels/resolve.js';
|
|
12
4
|
export function formatUrgentBlockMessage(block) {
|
|
13
5
|
const q = block.questions[0];
|
|
14
6
|
const header = q?.header ? `[${q.header}] ` : '';
|
|
@@ -18,11 +10,14 @@ export function formatUrgentBlockMessage(block) {
|
|
|
18
10
|
const cost = block.costOfDelay ?? 'low';
|
|
19
11
|
return `URGENT ${cls.toUpperCase()}${host}: ${header}${text} (cost: ${cost}, id: ${block.blockId})`;
|
|
20
12
|
}
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Build openclaw argv for a Telegram send (used by the openclaw-telegram
|
|
15
|
+
* provider and its tests). `target` is required — the recipient is always
|
|
16
|
+
* resolved by the caller, never defaulted to a hardcoded number here.
|
|
17
|
+
*/
|
|
18
|
+
export function buildOpenClawNotifyArgs(text, opts) {
|
|
19
|
+
const channel = opts.channel ?? 'telegram';
|
|
20
|
+
const account = opts.account ?? 'default';
|
|
26
21
|
return [
|
|
27
22
|
'message',
|
|
28
23
|
'send',
|
|
@@ -31,11 +26,38 @@ export function buildOpenClawNotifyArgs(text, options = {}) {
|
|
|
31
26
|
'--account',
|
|
32
27
|
account,
|
|
33
28
|
'--target',
|
|
34
|
-
target,
|
|
29
|
+
opts.target,
|
|
35
30
|
'--message',
|
|
36
31
|
text,
|
|
37
32
|
];
|
|
38
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Deliver a message to the configured owner through the one channel seam.
|
|
36
|
+
* `channel`/`target` default to `notify.owner.{channel,to}`; `notify.transports`
|
|
37
|
+
* selects the provider per host. A missing owner config or a delivery failure
|
|
38
|
+
* (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
|
|
39
|
+
* ENOENT — so callers surface a consistent, best-effort failure.
|
|
40
|
+
*/
|
|
41
|
+
export async function sendToOwner(text, options = {}) {
|
|
42
|
+
const meta = options.meta ?? readMeta();
|
|
43
|
+
const owner = meta.notify?.owner;
|
|
44
|
+
const channel = options.channel ?? owner?.channel;
|
|
45
|
+
const target = options.target ?? owner?.to;
|
|
46
|
+
if (!channel || !target) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
channel: channel ?? 'unknown',
|
|
50
|
+
id: target ?? '',
|
|
51
|
+
error: 'notify.owner.{channel,to} not set in agents.yaml',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
registerBuiltinProviders();
|
|
55
|
+
const { provider, error } = lookupTransport(channel, meta);
|
|
56
|
+
if (!provider) {
|
|
57
|
+
return { ok: false, channel, id: target, error };
|
|
58
|
+
}
|
|
59
|
+
return provider.send(text, { target, dryRun: options.dryRun });
|
|
60
|
+
}
|
|
39
61
|
export async function notifyUrgentBlock(block, options = {}) {
|
|
40
62
|
if (block.notifiedAt) {
|
|
41
63
|
return { ok: true, skipped: true };
|
|
@@ -43,19 +65,6 @@ export async function notifyUrgentBlock(block, options = {}) {
|
|
|
43
65
|
if (options.dryRun) {
|
|
44
66
|
return { ok: true, skipped: true };
|
|
45
67
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
await execFileAsync('which', ['openclaw']);
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return { ok: false, error: 'openclaw CLI not found on PATH' };
|
|
52
|
-
}
|
|
53
|
-
const text = formatUrgentBlockMessage(block);
|
|
54
|
-
try {
|
|
55
|
-
await execFileAsync('openclaw', buildOpenClawNotifyArgs(text, options));
|
|
56
|
-
return { ok: true };
|
|
57
|
-
}
|
|
58
|
-
catch (err) {
|
|
59
|
-
return { ok: false, error: err.message };
|
|
60
|
-
}
|
|
68
|
+
const result = await sendToOwner(formatUrgentBlockMessage(block), options);
|
|
69
|
+
return result.ok ? { ok: true } : { ok: false, error: result.error };
|
|
61
70
|
}
|
package/dist/lib/perf/db.d.ts
CHANGED
|
@@ -17,7 +17,12 @@ export declare function drainSpool(db?: Database.Database): number;
|
|
|
17
17
|
/** Percentile of a sorted-ascending array. p in [0,100]. */
|
|
18
18
|
export declare function percentile(sorted: number[], p: number): number;
|
|
19
19
|
/**
|
|
20
|
-
* Aggregate samples by (kind, label) with p50/p99. Drains the spool first.
|
|
20
|
+
* Aggregate samples by (kind, label) with p50/p95/p99. Drains the spool first.
|
|
21
|
+
*
|
|
22
|
+
* `opts.project` scopes the query to samples whose recorded `cwd` resolves to
|
|
23
|
+
* that project key (see project-key.ts) — resolution runs per unique cwd
|
|
24
|
+
* (memoized) rather than per row, since `resolveProjectKey` does a filesystem
|
|
25
|
+
* walk and a warehouse query can carry many rows sharing the same cwd.
|
|
21
26
|
*/
|
|
22
27
|
export declare function aggregateSamples(opts?: AggregateOptions): PerfAggregateRow[];
|
|
23
28
|
export declare function perfDbPath(): string;
|
package/dist/lib/perf/db.js
CHANGED
|
@@ -9,6 +9,7 @@ import * as path from 'path';
|
|
|
9
9
|
import Database from '../sqlite.js';
|
|
10
10
|
import { getPerfDbPath, getPerfDir } from '../state.js';
|
|
11
11
|
import { localMachineId } from '../session/origin-machine.js';
|
|
12
|
+
import { resolveProjectKey } from '../project-key.js';
|
|
12
13
|
import { resolveSpoolPath, shortSessionId, _resetPerfSpoolForTest } from './spool.js';
|
|
13
14
|
export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
|
|
14
15
|
export const PERF_SCHEMA_VERSION = 1;
|
|
@@ -200,7 +201,12 @@ export function percentile(sorted, p) {
|
|
|
200
201
|
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
|
|
201
202
|
}
|
|
202
203
|
/**
|
|
203
|
-
* Aggregate samples by (kind, label) with p50/p99. Drains the spool first.
|
|
204
|
+
* Aggregate samples by (kind, label) with p50/p95/p99. Drains the spool first.
|
|
205
|
+
*
|
|
206
|
+
* `opts.project` scopes the query to samples whose recorded `cwd` resolves to
|
|
207
|
+
* that project key (see project-key.ts) — resolution runs per unique cwd
|
|
208
|
+
* (memoized) rather than per row, since `resolveProjectKey` does a filesystem
|
|
209
|
+
* walk and a warehouse query can carry many rows sharing the same cwd.
|
|
204
210
|
*/
|
|
205
211
|
export function aggregateSamples(opts = {}) {
|
|
206
212
|
const db = openDb();
|
|
@@ -228,14 +234,29 @@ export function aggregateSamples(opts = {}) {
|
|
|
228
234
|
clauses.push('agent = ?');
|
|
229
235
|
params.push(opts.agent);
|
|
230
236
|
}
|
|
231
|
-
const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code
|
|
237
|
+
const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code, status, cwd
|
|
232
238
|
FROM samples WHERE ${clauses.join(' AND ')}`).all(...params);
|
|
239
|
+
// Memoize cwd -> project key: resolveProjectKey walks the filesystem for
|
|
240
|
+
// a repo root, and many rows in one warehouse query share the same cwd.
|
|
241
|
+
const projectCache = new Map();
|
|
242
|
+
const projectForCwd = (cwd) => {
|
|
243
|
+
if (!cwd)
|
|
244
|
+
return undefined;
|
|
245
|
+
let key = projectCache.get(cwd);
|
|
246
|
+
if (key === undefined && !projectCache.has(cwd)) {
|
|
247
|
+
key = resolveProjectKey(cwd);
|
|
248
|
+
projectCache.set(cwd, key);
|
|
249
|
+
}
|
|
250
|
+
return key;
|
|
251
|
+
};
|
|
233
252
|
const map = new Map();
|
|
234
253
|
for (const r of rows) {
|
|
254
|
+
if (opts.project && projectForCwd(r.cwd) !== opts.project)
|
|
255
|
+
continue;
|
|
235
256
|
const key = `${r.kind}\0${r.label}`;
|
|
236
257
|
let b = map.get(key);
|
|
237
258
|
if (!b) {
|
|
238
|
-
b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0 };
|
|
259
|
+
b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, timeouts: 0 };
|
|
239
260
|
map.set(key, b);
|
|
240
261
|
}
|
|
241
262
|
b.durations.push(Number(r.duration_ms));
|
|
@@ -245,7 +266,9 @@ export function aggregateSamples(opts = {}) {
|
|
|
245
266
|
b.stale++;
|
|
246
267
|
else if (r.cache === 'miss' || r.cache === 'none')
|
|
247
268
|
b.misses++;
|
|
248
|
-
if (
|
|
269
|
+
if (r.status === 'timeout')
|
|
270
|
+
b.timeouts++;
|
|
271
|
+
else if (typeof r.exit_code === 'number' && r.exit_code !== 0)
|
|
249
272
|
b.errors++;
|
|
250
273
|
}
|
|
251
274
|
const out = [];
|
|
@@ -260,6 +283,7 @@ export function aggregateSamples(opts = {}) {
|
|
|
260
283
|
label: b.label,
|
|
261
284
|
n,
|
|
262
285
|
p50Ms: Math.round(percentile(sorted, 50)),
|
|
286
|
+
p95Ms: Math.round(percentile(sorted, 95)),
|
|
263
287
|
p99Ms: Math.round(percentile(sorted, 99)),
|
|
264
288
|
meanMs: Math.round(sum / n),
|
|
265
289
|
maxMs: sorted[n - 1],
|
|
@@ -270,8 +294,14 @@ export function aggregateSamples(opts = {}) {
|
|
|
270
294
|
row.cacheStalePct = Math.round((b.stale / n) * 100);
|
|
271
295
|
row.cacheMissPct = Math.round((b.misses / n) * 100);
|
|
272
296
|
}
|
|
273
|
-
if (b.errors > 0)
|
|
297
|
+
if (b.errors > 0) {
|
|
274
298
|
row.errorCount = b.errors;
|
|
299
|
+
row.errorRate = Math.round((b.errors / n) * 1000) / 1000;
|
|
300
|
+
}
|
|
301
|
+
if (b.timeouts > 0)
|
|
302
|
+
row.timeoutRate = Math.round((b.timeouts / n) * 1000) / 1000;
|
|
303
|
+
if (opts.project)
|
|
304
|
+
row.project = opts.project;
|
|
275
305
|
out.push(row);
|
|
276
306
|
}
|
|
277
307
|
out.sort((a, b) => b.p99Ms - a.p99Ms);
|
package/dist/lib/perf/types.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface PerfAggregateRow {
|
|
|
26
26
|
label: string;
|
|
27
27
|
n: number;
|
|
28
28
|
p50Ms: number;
|
|
29
|
+
p95Ms: number;
|
|
29
30
|
p99Ms: number;
|
|
30
31
|
meanMs: number;
|
|
31
32
|
maxMs: number;
|
|
@@ -34,6 +35,13 @@ export interface PerfAggregateRow {
|
|
|
34
35
|
cacheStalePct?: number;
|
|
35
36
|
cacheMissPct?: number;
|
|
36
37
|
errorCount?: number;
|
|
38
|
+
/** Fraction (0-1) of samples with a nonzero exit code. */
|
|
39
|
+
errorRate?: number;
|
|
40
|
+
/** Fraction (0-1) of samples with status:'timeout'. */
|
|
41
|
+
timeoutRate?: number;
|
|
42
|
+
/** Project key (see project-key.ts) the row is scoped to — set only when
|
|
43
|
+
* the `project` filter narrowed the query to one project. */
|
|
44
|
+
project?: string;
|
|
37
45
|
}
|
|
38
46
|
export interface AggregateOptions {
|
|
39
47
|
days?: number;
|
|
@@ -42,4 +50,6 @@ export interface AggregateOptions {
|
|
|
42
50
|
machine?: string;
|
|
43
51
|
agent?: string;
|
|
44
52
|
minN?: number;
|
|
53
|
+
/** Scope results to samples whose cwd resolves to this project key. */
|
|
54
|
+
project?: string;
|
|
45
55
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Definition-vs-reality checks for `agents projects`.
|
|
3
|
+
*
|
|
4
|
+
* A project definition is hand-editable YAML that nothing validates against the
|
|
5
|
+
* world it describes, so it can drift into being confidently wrong. The one that
|
|
6
|
+
* bit for real: `repo:` said `<user>/agents-cli` while the checkout's `origin`
|
|
7
|
+
* was `phnx-labs/agents-cli`. Both are real repositories, so nothing errored —
|
|
8
|
+
* the card's merged-PR and release lines simply reported a stranger's repo (0
|
|
9
|
+
* merges in 7 days instead of 100). A wrong answer that looks like a right one
|
|
10
|
+
* is worse than a missing one, so the mismatch gets said out loud wherever the
|
|
11
|
+
* def is shown.
|
|
12
|
+
*
|
|
13
|
+
* Findings carry their own fix, mirroring `DoctorFinding.remediation`
|
|
14
|
+
* (`lib/devices/doctor-findings.ts`) — a warning the reader has to go work out
|
|
15
|
+
* how to act on is half a warning. Pure: the caller supplies the observed
|
|
16
|
+
* remote, so this is unit-testable with no git and no fixture repo.
|
|
17
|
+
*/
|
|
18
|
+
import type { ProjectDef } from './projects.js';
|
|
19
|
+
/** A definition that disagrees with the machine it describes. */
|
|
20
|
+
export interface ProjectFinding {
|
|
21
|
+
project: string;
|
|
22
|
+
/** One line naming the disagreement, both sides quoted. */
|
|
23
|
+
message: string;
|
|
24
|
+
/** The exact command that fixes it. */
|
|
25
|
+
remediation: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Compare a def's `repo` slug against the checkout's real `origin` remote.
|
|
29
|
+
*
|
|
30
|
+
* - remote unreadable (no checkout on this machine, not a git repo) → no
|
|
31
|
+
* finding. The def may be perfectly right; this machine just can't say.
|
|
32
|
+
* - def has no `repo` → a finding only when a remote exists to adopt, so the
|
|
33
|
+
* fix is a real one-liner rather than a nag.
|
|
34
|
+
* - they disagree → a finding. This is the case that silently lies.
|
|
35
|
+
*/
|
|
36
|
+
export declare function checkRepoSlug(def: ProjectDef, actualRemote: string | undefined): ProjectFinding | undefined;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Definition-vs-reality checks for `agents projects`.
|
|
3
|
+
*
|
|
4
|
+
* A project definition is hand-editable YAML that nothing validates against the
|
|
5
|
+
* world it describes, so it can drift into being confidently wrong. The one that
|
|
6
|
+
* bit for real: `repo:` said `<user>/agents-cli` while the checkout's `origin`
|
|
7
|
+
* was `phnx-labs/agents-cli`. Both are real repositories, so nothing errored —
|
|
8
|
+
* the card's merged-PR and release lines simply reported a stranger's repo (0
|
|
9
|
+
* merges in 7 days instead of 100). A wrong answer that looks like a right one
|
|
10
|
+
* is worse than a missing one, so the mismatch gets said out loud wherever the
|
|
11
|
+
* def is shown.
|
|
12
|
+
*
|
|
13
|
+
* Findings carry their own fix, mirroring `DoctorFinding.remediation`
|
|
14
|
+
* (`lib/devices/doctor-findings.ts`) — a warning the reader has to go work out
|
|
15
|
+
* how to act on is half a warning. Pure: the caller supplies the observed
|
|
16
|
+
* remote, so this is unit-testable with no git and no fixture repo.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Compare a def's `repo` slug against the checkout's real `origin` remote.
|
|
20
|
+
*
|
|
21
|
+
* - remote unreadable (no checkout on this machine, not a git repo) → no
|
|
22
|
+
* finding. The def may be perfectly right; this machine just can't say.
|
|
23
|
+
* - def has no `repo` → a finding only when a remote exists to adopt, so the
|
|
24
|
+
* fix is a real one-liner rather than a nag.
|
|
25
|
+
* - they disagree → a finding. This is the case that silently lies.
|
|
26
|
+
*/
|
|
27
|
+
export function checkRepoSlug(def, actualRemote) {
|
|
28
|
+
if (!actualRemote)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (def.repo === actualRemote)
|
|
31
|
+
return undefined;
|
|
32
|
+
const fix = `agents projects set ${def.name} --repo ${actualRemote}`;
|
|
33
|
+
if (!def.repo) {
|
|
34
|
+
return {
|
|
35
|
+
project: def.name,
|
|
36
|
+
message: `no repo set; origin is ${actualRemote}`,
|
|
37
|
+
remediation: fix,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
project: def.name,
|
|
42
|
+
message: `repo is ${def.repo} but origin is ${actualRemote} — PR and release counts are being read from the wrong repository`,
|
|
43
|
+
remediation: fix,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -63,8 +63,18 @@ export declare function validateImportOpts(flags: RawImportFlags): ImportOptions
|
|
|
63
63
|
* `repoSlug`→`repo`, `linearProjectId`→`linear.projectId`), now gated on the
|
|
64
64
|
* row's `confidence`. A row with no confidence field is a guess with no stated
|
|
65
65
|
* strength, so it ranks below every floor and only `--all` takes it.
|
|
66
|
+
*
|
|
67
|
+
* `resolveRemote` reads the checkout's actual `origin` and OVERRIDES the row's
|
|
68
|
+
* `repoSlug`. Factory derives that slug from the checkout path's last two
|
|
69
|
+
* segments (`apps/factory/src/core/projectIndex.ts:19-26`), which is only right
|
|
70
|
+
* when the directory tree happens to mirror the GitHub org. It commonly does
|
|
71
|
+
* not: a checkout at `~/src/github.com/<you>/agents-cli` whose remote is
|
|
72
|
+
* `phnx-labs/agents-cli` imported as `<you>/agents-cli` — a real, different
|
|
73
|
+
* repo — so the card's merged-PR and release lines silently reported a
|
|
74
|
+
* stranger's repository instead of failing. The remote is the only authority on
|
|
75
|
+
* which repo a checkout is; the path is a guess about it.
|
|
66
76
|
*/
|
|
67
|
-
export declare function buildFactoryImportCandidates(rows: unknown[], existing: Map<string, ProjectDef>, opts: Pick<ImportOptions, 'minConfidence' | 'force'
|
|
77
|
+
export declare function buildFactoryImportCandidates(rows: unknown[], existing: Map<string, ProjectDef>, opts: Pick<ImportOptions, 'minConfidence' | 'force'>, resolveRemote?: (root: string) => string | undefined): ImportPlan;
|
|
68
78
|
/**
|
|
69
79
|
* Turn a Linear project name into a definition slug: lowercase, every run of
|
|
70
80
|
* unusable characters collapsed to one `-`, trimmed of leading/trailing
|
|
@@ -60,8 +60,18 @@ export function validateImportOpts(flags) {
|
|
|
60
60
|
* `repoSlug`→`repo`, `linearProjectId`→`linear.projectId`), now gated on the
|
|
61
61
|
* row's `confidence`. A row with no confidence field is a guess with no stated
|
|
62
62
|
* strength, so it ranks below every floor and only `--all` takes it.
|
|
63
|
+
*
|
|
64
|
+
* `resolveRemote` reads the checkout's actual `origin` and OVERRIDES the row's
|
|
65
|
+
* `repoSlug`. Factory derives that slug from the checkout path's last two
|
|
66
|
+
* segments (`apps/factory/src/core/projectIndex.ts:19-26`), which is only right
|
|
67
|
+
* when the directory tree happens to mirror the GitHub org. It commonly does
|
|
68
|
+
* not: a checkout at `~/src/github.com/<you>/agents-cli` whose remote is
|
|
69
|
+
* `phnx-labs/agents-cli` imported as `<you>/agents-cli` — a real, different
|
|
70
|
+
* repo — so the card's merged-PR and release lines silently reported a
|
|
71
|
+
* stranger's repository instead of failing. The remote is the only authority on
|
|
72
|
+
* which repo a checkout is; the path is a guess about it.
|
|
63
73
|
*/
|
|
64
|
-
export function buildFactoryImportCandidates(rows, existing, opts) {
|
|
74
|
+
export function buildFactoryImportCandidates(rows, existing, opts, resolveRemote = () => undefined) {
|
|
65
75
|
const floor = CONFIDENCE_RANK[opts.minConfidence];
|
|
66
76
|
const defs = [];
|
|
67
77
|
const skipped = [];
|
|
@@ -97,8 +107,12 @@ export function buildFactoryImportCandidates(rows, existing, opts) {
|
|
|
97
107
|
const def = { name };
|
|
98
108
|
if (typeof o.path === 'string')
|
|
99
109
|
def.root = toHomeRelative(o.path);
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
// The checkout's own remote wins over the registry's path-derived guess;
|
|
111
|
+
// the guess is the fallback for a path with no git remote to ask.
|
|
112
|
+
const fromRemote = typeof o.path === 'string' ? resolveRemote(o.path) : undefined;
|
|
113
|
+
const repo = fromRemote ?? (typeof o.repoSlug === 'string' ? o.repoSlug : undefined);
|
|
114
|
+
if (repo)
|
|
115
|
+
def.repo = repo;
|
|
102
116
|
if (typeof o.linearProjectId === 'string')
|
|
103
117
|
def.linear = { projectId: o.linearProjectId };
|
|
104
118
|
defs.push(def);
|
|
@@ -54,11 +54,25 @@ export interface ProjectSessionRollup {
|
|
|
54
54
|
* with the full definition list to show zero-agent projects.
|
|
55
55
|
*/
|
|
56
56
|
export declare function rollupSessionsByProject(defs: ProjectDef[], sessions: ActiveSession[]): Map<string, ProjectSessionRollup>;
|
|
57
|
-
/**
|
|
58
|
-
export
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
/** Live vs finished sessions on a project. */
|
|
58
|
+
export interface LiveDeadSplit {
|
|
59
|
+
live: number;
|
|
60
|
+
dead: number;
|
|
61
|
+
/** Dead broken out by status, for the card's parenthetical. */
|
|
62
|
+
deadByStatus: Array<{
|
|
63
|
+
status: string;
|
|
64
|
+
n: number;
|
|
65
|
+
}>;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Split a rollup's sessions into what is working and what is wreckage.
|
|
69
|
+
*
|
|
70
|
+
* The headline used to be the raw session count, which on a real project read
|
|
71
|
+
* `39 agents` when 19 of those had crashed. A count that is half corpses is not
|
|
72
|
+
* a throughput signal — but the corpses are worth their own number, because 19
|
|
73
|
+
* crashed sessions is itself a thing to go fix.
|
|
74
|
+
*/
|
|
75
|
+
export declare function liveDeadSplit(byStatus: Partial<Record<ActiveStatus, number>>): LiveDeadSplit;
|
|
62
76
|
/** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
|
|
63
77
|
export declare function sortProjectMembers(members: ProjectMember[]): ProjectMember[];
|
|
64
78
|
/** Cap for the members line before it collapses to `+N more`. */
|
|
@@ -77,6 +91,12 @@ export interface ProjectRemoteSignals {
|
|
|
77
91
|
windowDays: number;
|
|
78
92
|
/** PRs merged into the primary repo within the window (via `gh`). */
|
|
79
93
|
mergedPrs: number;
|
|
94
|
+
/**
|
|
95
|
+
* True when the `gh` fetch cap cut the count short — `mergedPrs` is then a
|
|
96
|
+
* LOWER bound (rendered `100+`), never presented as the complete count. Same
|
|
97
|
+
* contract `LinearProjectCounts.truncated` keeps for the Linear line.
|
|
98
|
+
*/
|
|
99
|
+
mergedPrsTruncated?: boolean;
|
|
80
100
|
/** Artifacts agents produced within the window (activity.created milestones). */
|
|
81
101
|
artifacts: number;
|
|
82
102
|
/** Basename of the most recent artifact, when any. */
|
|
@@ -17,6 +17,13 @@ import chalk from 'chalk';
|
|
|
17
17
|
import { projectNameForCwd } from './projects.js';
|
|
18
18
|
import { readRecentActivity } from './activity.js';
|
|
19
19
|
const execFileAsync = promisify(execFile);
|
|
20
|
+
/**
|
|
21
|
+
* How many recent merges `gh` is asked for. A repo busy enough that all of them
|
|
22
|
+
* land inside the window has more than this — agents-cli itself merged 100 of
|
|
23
|
+
* its 100 most recent PRs within 7 days — so the count is reported as a lower
|
|
24
|
+
* bound rather than as a total.
|
|
25
|
+
*/
|
|
26
|
+
const MERGED_PR_LIMIT = 100;
|
|
20
27
|
function blank(name) {
|
|
21
28
|
return {
|
|
22
29
|
name,
|
|
@@ -77,11 +84,40 @@ export function rollupSessionsByProject(defs, sessions) {
|
|
|
77
84
|
}
|
|
78
85
|
return map;
|
|
79
86
|
}
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Statuses that mean the session is over. Taken from the repo's own rule
|
|
89
|
+
* (`commands/sessions.ts`): "`closed` and `crashed` are unconditionally dead".
|
|
90
|
+
* `orphaned` is NOT among them — `session/active.ts` defines it as "Alive, but
|
|
91
|
+
* no client is attached", i.e. the agent outlived its window and is still
|
|
92
|
+
* working. Counting it as dead understates the project by exactly the sessions
|
|
93
|
+
* that are running unattended.
|
|
94
|
+
*/
|
|
95
|
+
const DEAD_STATUSES = new Set(['closed', 'crashed']);
|
|
96
|
+
/**
|
|
97
|
+
* Split a rollup's sessions into what is working and what is wreckage.
|
|
98
|
+
*
|
|
99
|
+
* The headline used to be the raw session count, which on a real project read
|
|
100
|
+
* `39 agents` when 19 of those had crashed. A count that is half corpses is not
|
|
101
|
+
* a throughput signal — but the corpses are worth their own number, because 19
|
|
102
|
+
* crashed sessions is itself a thing to go fix.
|
|
103
|
+
*/
|
|
104
|
+
export function liveDeadSplit(byStatus) {
|
|
105
|
+
let live = 0;
|
|
106
|
+
let dead = 0;
|
|
107
|
+
const deadByStatus = [];
|
|
108
|
+
for (const [status, n] of Object.entries(byStatus)) {
|
|
109
|
+
if (!n)
|
|
110
|
+
continue;
|
|
111
|
+
if (DEAD_STATUSES.has(status)) {
|
|
112
|
+
dead += n;
|
|
113
|
+
deadByStatus.push({ status, n });
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
live += n;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
deadByStatus.sort((a, b) => b.n - a.n || a.status.localeCompare(b.status));
|
|
120
|
+
return { live, dead, deadByStatus };
|
|
85
121
|
}
|
|
86
122
|
/**
|
|
87
123
|
* Display order for the members line: the states a human scans for first
|
|
@@ -158,9 +194,15 @@ export async function enrichProjectSignals(def, windowDays, nowMs, opts = {}) {
|
|
|
158
194
|
}
|
|
159
195
|
if (def.repo && !opts.skipRemote) {
|
|
160
196
|
try {
|
|
161
|
-
const { stdout } = await execFileAsync('gh', ['pr', 'list', '--repo', def.repo, '--state', 'merged', '--json', 'number,mergedAt', '--limit',
|
|
197
|
+
const { stdout } = await execFileAsync('gh', ['pr', 'list', '--repo', def.repo, '--state', 'merged', '--json', 'number,mergedAt', '--limit', String(MERGED_PR_LIMIT)], { timeout: 8000, encoding: 'utf8' });
|
|
162
198
|
const rows = JSON.parse(stdout);
|
|
163
199
|
out.mergedPrs = rows.filter((r) => r.mergedAt && Date.parse(r.mergedAt) >= sinceMs).length;
|
|
200
|
+
// `--limit 100` caps the fetch, so a busy repo where every one of the 100
|
|
201
|
+
// most recent merges falls inside the window has MORE than 100 — this repo
|
|
202
|
+
// really does. Say so (`100+`) rather than presenting a cap as a count,
|
|
203
|
+
// the same contract `LinearProjectCounts.truncated` already keeps.
|
|
204
|
+
if (rows.length >= MERGED_PR_LIMIT && out.mergedPrs >= MERGED_PR_LIMIT)
|
|
205
|
+
out.mergedPrsTruncated = true;
|
|
164
206
|
}
|
|
165
207
|
catch {
|
|
166
208
|
/* gh missing / unauthenticated / repo not found — skip this signal */
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -24,6 +24,15 @@ export interface RotateCandidate {
|
|
|
24
24
|
usageStatus: AccountInfo['usageStatus'];
|
|
25
25
|
usageSnapshot: UsageSnapshot | null;
|
|
26
26
|
usageError: string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Projected minutes until this account's 5-hour session window caps, as
|
|
29
|
+
* computed by the daemon's burn-rate refresher and read from the headroom
|
|
30
|
+
* cache. `null` when unknown (cold cache, idle, or not burning up). Balanced
|
|
31
|
+
* routing deprioritizes an account projected to cap soon — see
|
|
32
|
+
* {@link capacityWeight} — so a launch avoids an account racing toward its
|
|
33
|
+
* limit, not just one already 100%-maxed.
|
|
34
|
+
*/
|
|
35
|
+
usageMinutesToLimit: number | null;
|
|
27
36
|
plan: string | null;
|
|
28
37
|
signedIn: boolean;
|
|
29
38
|
lastActive: Date | null;
|
|
@@ -141,6 +150,24 @@ export declare function checkRunAccountReadiness(agent: AgentId, version: string
|
|
|
141
150
|
* version so behavior stays predictable.
|
|
142
151
|
*/
|
|
143
152
|
export declare function pickBalancedCandidate(candidates: RotateCandidate[], nowMs?: number): RotateResult | null;
|
|
153
|
+
/**
|
|
154
|
+
* How far from its projected cap an account must be to keep its FULL headroom
|
|
155
|
+
* weight. Inside this horizon the weight is scaled down linearly toward the
|
|
156
|
+
* floor, so an account racing toward its 5h cap loses priority before it maxes.
|
|
157
|
+
*/
|
|
158
|
+
export declare const PROJECTION_HORIZON_MIN = 30;
|
|
159
|
+
/**
|
|
160
|
+
* Weight one candidate by remaining routing capacity, deprioritized by how soon
|
|
161
|
+
* it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
|
|
162
|
+
* an account with no live snapshot is treated as full-capacity (100) since there
|
|
163
|
+
* is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
|
|
164
|
+
* projection on the 5h session window) then scales that base: >= horizon (or
|
|
165
|
+
* unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
|
|
166
|
+
* so a launch avoids an account projected to cap soon, not just a 100%-maxed
|
|
167
|
+
* one. Pure + exported so the deprioritization is unit-tested directly (a
|
|
168
|
+
* weighted-random draw is not).
|
|
169
|
+
*/
|
|
170
|
+
export declare function capacityWeight(usedPercent: number | null, minutesToLimit: number | null): number;
|
|
144
171
|
/**
|
|
145
172
|
* Pick an available candidate. Prefers the configured pinned version when that
|
|
146
173
|
* version has usage available; otherwise routes to the candidate with the most
|
package/dist/lib/rotate.js
CHANGED
|
@@ -12,6 +12,7 @@ import { listInstalledVersions, getVersionHomePath, resolveVersion } from './ver
|
|
|
12
12
|
import { getProjectRunConfigs } from './run-config.js';
|
|
13
13
|
import { emit } from './events.js';
|
|
14
14
|
import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
|
|
15
|
+
import { readAccountHeadroom } from './fleet-cache.js';
|
|
15
16
|
function getRotateDir() {
|
|
16
17
|
const dir = path.join(getHelpersDir(), 'rotate');
|
|
17
18
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -264,20 +265,37 @@ function preferVerified(pool, nowMs, choose) {
|
|
|
264
265
|
usageUnverified: verified.length === 0,
|
|
265
266
|
};
|
|
266
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* How far from its projected cap an account must be to keep its FULL headroom
|
|
270
|
+
* weight. Inside this horizon the weight is scaled down linearly toward the
|
|
271
|
+
* floor, so an account racing toward its 5h cap loses priority before it maxes.
|
|
272
|
+
*/
|
|
273
|
+
export const PROJECTION_HORIZON_MIN = 30;
|
|
274
|
+
/**
|
|
275
|
+
* Weight one candidate by remaining routing capacity, deprioritized by how soon
|
|
276
|
+
* it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
|
|
277
|
+
* an account with no live snapshot is treated as full-capacity (100) since there
|
|
278
|
+
* is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
|
|
279
|
+
* projection on the 5h session window) then scales that base: >= horizon (or
|
|
280
|
+
* unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
|
|
281
|
+
* so a launch avoids an account projected to cap soon, not just a 100%-maxed
|
|
282
|
+
* one. Pure + exported so the deprioritization is unit-tested directly (a
|
|
283
|
+
* weighted-random draw is not).
|
|
284
|
+
*/
|
|
285
|
+
export function capacityWeight(usedPercent, minutesToLimit) {
|
|
286
|
+
const base = usedPercent === null ? 100 : Math.max(1, 100 - usedPercent);
|
|
287
|
+
if (minutesToLimit === null || !Number.isFinite(minutesToLimit))
|
|
288
|
+
return base;
|
|
289
|
+
const factor = Math.max(0, Math.min(1, minutesToLimit / PROJECTION_HORIZON_MIN));
|
|
290
|
+
return Math.max(1, base * factor);
|
|
291
|
+
}
|
|
267
292
|
/**
|
|
268
293
|
* Pick one candidate from `sorted` using weights proportional to remaining
|
|
269
|
-
* routing capacity. Floor each weight at 1 so a
|
|
270
|
-
* eligible candidate can still be picked occasionally.
|
|
271
|
-
* (no live snapshot), treat the candidate as full-capacity (weight 100) — we
|
|
272
|
-
* have no signal to deprioritize it.
|
|
294
|
+
* routing capacity (see {@link capacityWeight}). Floor each weight at 1 so a
|
|
295
|
+
* near-exhausted-but-still-eligible candidate can still be picked occasionally.
|
|
273
296
|
*/
|
|
274
297
|
function weightedRandomByCapacity(sorted) {
|
|
275
|
-
const weights = sorted.map((c) =>
|
|
276
|
-
const used = getRoutingUsedPercent(c.usageSnapshot);
|
|
277
|
-
if (used === null)
|
|
278
|
-
return 100;
|
|
279
|
-
return Math.max(1, 100 - used);
|
|
280
|
-
});
|
|
298
|
+
const weights = sorted.map((c) => capacityWeight(getRoutingUsedPercent(c.usageSnapshot), c.usageMinutesToLimit));
|
|
281
299
|
const total = weights.reduce((sum, w) => sum + w, 0);
|
|
282
300
|
if (total <= 0)
|
|
283
301
|
return sorted[0];
|
|
@@ -350,26 +368,35 @@ export async function collectRunCandidates(agent) {
|
|
|
350
368
|
lastActive: info.lastActive,
|
|
351
369
|
};
|
|
352
370
|
}));
|
|
353
|
-
// These candidates feed a routing decision
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
371
|
+
// These candidates feed a routing decision on the `agents run` hot path, so
|
|
372
|
+
// this read is CACHE-ONLY (`readOnly`): it never blocks on a live provider
|
|
373
|
+
// fetch. A snapshot older than USAGE_DECISION_MAX_AGE_MS is not trusted for
|
|
374
|
+
// the pick — but the guard that enforces that is `isUsageVerified` below, not
|
|
375
|
+
// a blocking refresh here. Keeping the cache fresh is the daemon's job
|
|
376
|
+
// (`runUsageRefresh`, adaptive + rate-capped, sole-writer per local account),
|
|
377
|
+
// so a cold `agents run` reads the last daemon-written snapshot instead of
|
|
378
|
+
// stalling on N parallel HTTP round trips (the measured cold-start stall this
|
|
379
|
+
// removes). A stale-or-absent snapshot routes as unverified, exactly as a
|
|
380
|
+
// failed live read did before.
|
|
359
381
|
const { usageByKey } = await getUsageInfoByIdentity(rows.map(({ home, info, version }) => ({
|
|
360
382
|
agentId: agent,
|
|
361
383
|
home,
|
|
362
384
|
cliVersion: version,
|
|
363
385
|
info,
|
|
364
|
-
})), {
|
|
386
|
+
})), { readOnly: true });
|
|
365
387
|
return rows.map(({ home: _home, info, ...candidate }) => {
|
|
366
388
|
const usageKey = getUsageLookupKey(info);
|
|
367
389
|
const usage = usageKey ? usageByKey.get(usageKey) : undefined;
|
|
390
|
+
// Projected headroom is a separate cache-only read (also off the network) —
|
|
391
|
+
// the daemon publishes minutesToLimit; a cold cache yields null and routing
|
|
392
|
+
// falls back to snapshot-only weighting.
|
|
393
|
+
const headroom = usageKey ? readAccountHeadroom(usageKey) : null;
|
|
368
394
|
return {
|
|
369
395
|
...candidate,
|
|
370
396
|
usageKey,
|
|
371
397
|
usageSnapshot: usage?.snapshot ?? null,
|
|
372
398
|
usageError: usage?.error ?? null,
|
|
399
|
+
usageMinutesToLimit: headroom?.minutesToLimit ?? null,
|
|
373
400
|
};
|
|
374
401
|
});
|
|
375
402
|
}
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -504,6 +504,22 @@ export declare function getLatestRun(jobName: string): RunMeta | null;
|
|
|
504
504
|
* run's prompt.
|
|
505
505
|
*/
|
|
506
506
|
export declare function getLatestCompletedRun(jobName: string): RunMeta | null;
|
|
507
|
+
/** Duration + outcome rollup for a job's run history. */
|
|
508
|
+
export interface RoutineStats {
|
|
509
|
+
/** Total run records (any status, including `missed`). */
|
|
510
|
+
count: number;
|
|
511
|
+
failed: number;
|
|
512
|
+
missed: number;
|
|
513
|
+
avgMs: number;
|
|
514
|
+
p50: number;
|
|
515
|
+
p95: number;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Fold a job's run history (`listRuns`) into a duration + outcome summary.
|
|
519
|
+
* `missed` fires (no process ever ran) carry no `duration` and are excluded
|
|
520
|
+
* from the latency percentiles but still counted in `count`/`missed`.
|
|
521
|
+
*/
|
|
522
|
+
export declare function routineStats(jobName: string): RoutineStats;
|
|
507
523
|
/** Persist run metadata to its run directory as meta.json. */
|
|
508
524
|
export declare function writeRunMeta(meta: RunMeta): void;
|
|
509
525
|
/** Read run metadata from disk. Returns null if missing or corrupt. */
|