@phnx-labs/agents-cli 1.21.1 → 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.
Files changed (87) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +1 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +28 -19
  6. package/dist/commands/hooks.js +9 -45
  7. package/dist/commands/menubar.js +24 -24
  8. package/dist/commands/message.js +23 -3
  9. package/dist/commands/perf.d.ts +13 -0
  10. package/dist/commands/perf.js +80 -23
  11. package/dist/commands/projects.d.ts +11 -0
  12. package/dist/commands/projects.js +153 -21
  13. package/dist/commands/routines.js +46 -1
  14. package/dist/commands/ssh.js +69 -0
  15. package/dist/commands/trends.d.ts +2 -0
  16. package/dist/commands/trends.js +158 -0
  17. package/dist/commands/usage.d.ts +4 -4
  18. package/dist/commands/view.d.ts +6 -0
  19. package/dist/commands/view.js +90 -45
  20. package/dist/index.js +14 -1
  21. package/dist/lib/agents.js +2 -2
  22. package/dist/lib/analytics/dashboard.d.ts +11 -0
  23. package/dist/lib/analytics/dashboard.js +31 -0
  24. package/dist/lib/analytics/recipes.d.ts +32 -0
  25. package/dist/lib/analytics/recipes.js +316 -0
  26. package/dist/lib/analytics/usage-db.d.ts +84 -0
  27. package/dist/lib/analytics/usage-db.js +301 -0
  28. package/dist/lib/browser/service.js +18 -0
  29. package/dist/lib/cli-resources.d.ts +20 -0
  30. package/dist/lib/cli-resources.js +48 -1
  31. package/dist/lib/daemon.js +51 -14
  32. package/dist/lib/devices/health-report.d.ts +5 -0
  33. package/dist/lib/devices/health-report.js +3 -0
  34. package/dist/lib/feed-broadcast.d.ts +52 -7
  35. package/dist/lib/feed-broadcast.js +125 -18
  36. package/dist/lib/fleet-cache.d.ts +37 -0
  37. package/dist/lib/fleet-cache.js +40 -0
  38. package/dist/lib/fleet-status.d.ts +53 -0
  39. package/dist/lib/fleet-status.js +120 -0
  40. package/dist/lib/friction-heuristics.d.ts +32 -0
  41. package/dist/lib/friction-heuristics.js +47 -0
  42. package/dist/lib/hooks/cache.js +28 -6
  43. package/dist/lib/hooks/profile.d.ts +8 -0
  44. package/dist/lib/hooks/profile.js +14 -4
  45. package/dist/lib/hooks.js +72 -17
  46. package/dist/lib/linear-cache.d.ts +63 -0
  47. package/dist/lib/linear-cache.js +146 -0
  48. package/dist/lib/linear-project-counts.d.ts +35 -5
  49. package/dist/lib/linear-project-counts.js +61 -16
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  54. package/dist/lib/menubar/install-menubar.js +36 -6
  55. package/dist/lib/perf/db.d.ts +6 -1
  56. package/dist/lib/perf/db.js +35 -5
  57. package/dist/lib/perf/types.d.ts +10 -0
  58. package/dist/lib/project-doctor.d.ts +36 -0
  59. package/dist/lib/project-doctor.js +45 -0
  60. package/dist/lib/project-import.d.ts +11 -1
  61. package/dist/lib/project-import.js +17 -3
  62. package/dist/lib/project-status.d.ts +25 -5
  63. package/dist/lib/project-status.js +48 -6
  64. package/dist/lib/rotate.d.ts +27 -0
  65. package/dist/lib/rotate.js +44 -17
  66. package/dist/lib/routines.d.ts +16 -0
  67. package/dist/lib/routines.js +39 -0
  68. package/dist/lib/runner.js +34 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  70. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  71. package/dist/lib/secrets/usage-db.d.ts +3 -63
  72. package/dist/lib/secrets/usage-db.js +46 -186
  73. package/dist/lib/session/db.d.ts +2 -1
  74. package/dist/lib/session/db.js +14 -3
  75. package/dist/lib/session/discover.d.ts +3 -0
  76. package/dist/lib/session/discover.js +8 -0
  77. package/dist/lib/session/types.d.ts +1 -0
  78. package/dist/lib/startup/command-registry.d.ts +1 -0
  79. package/dist/lib/startup/command-registry.js +2 -0
  80. package/dist/lib/state.d.ts +31 -3
  81. package/dist/lib/state.js +53 -10
  82. package/dist/lib/types.d.ts +8 -4
  83. package/dist/lib/usage-refresh.d.ts +106 -0
  84. package/dist/lib/usage-refresh.js +238 -0
  85. package/dist/lib/usage.d.ts +152 -17
  86. package/dist/lib/usage.js +393 -79
  87. package/package.json +1 -1
@@ -205,10 +205,25 @@ export function gatekeeperAssesses(appPath) {
205
205
  const r = spawnSync('spctl', ['--assess', '--type', 'exec', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
206
206
  return r.status === 0;
207
207
  }
208
+ /** True when the bundle carries a Developer ID TeamIdentifier (not ad-hoc). */
209
+ export function hasDeveloperIdSignature(appPath) {
210
+ const r = spawnSync('codesign', ['-dv', '--verbose=4', appPath], {
211
+ stdio: ['ignore', 'pipe', 'pipe'],
212
+ encoding: 'utf-8',
213
+ });
214
+ const out = `${r.stderr || ''}${r.stdout || ''}`;
215
+ const team = out.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1];
216
+ return Boolean(team && team !== 'not set');
217
+ }
208
218
  /**
209
219
  * Copy the bundled `.app` to the stable user path (idempotent unless forced).
210
220
  * Returns the installed executable path, or null if no source bundle ships
211
221
  * with this install (e.g. Linux package, or a build without the helper).
222
+ *
223
+ * Also heals an older install that was ad-hoc re-signed over a Developer ID
224
+ * source: that unstable identity made Accessibility re-prompt on every upgrade.
225
+ * When the shipped source is Developer ID and the installed copy is only
226
+ * ad-hoc, replace it — even without forceReinstall.
212
227
  */
213
228
  export function ensureMenubarAppInstalled(opts = {}) {
214
229
  if (!onDarwin())
@@ -218,7 +233,11 @@ export function ensureMenubarAppInstalled(opts = {}) {
218
233
  return null;
219
234
  const dest = installedAppPath();
220
235
  if (!opts.forceReinstall && fs.existsSync(dest)) {
221
- return installedExecutablePath();
236
+ const sourceIsDevId = hasDeveloperIdSignature(src);
237
+ const destIsDevId = hasDeveloperIdSignature(dest);
238
+ if (!(sourceIsDevId && !destIsDevId)) {
239
+ return installedExecutablePath();
240
+ }
222
241
  }
223
242
  copyAppBundle(src, dest);
224
243
  // A fresh copy is exactly when the bundle's icon can be new (first install) or
@@ -325,7 +344,7 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
325
344
  // this passes; if it ever doesn't, skip the service and point at the upgrade
326
345
  // rather than re-signing over it (an ad-hoc re-sign never satisfies Gatekeeper).
327
346
  if (!(codesignVerifies(installedAppPath()) && gatekeeperAssesses(installedAppPath()))) {
328
- process.stderr.write('agents: menu-bar helper is not notarized/valid on this machine; skipping launch. ' +
347
+ process.stderr.write('agents: AGI Menu is not notarized/valid on this machine; skipping launch. ' +
329
348
  'Upgrade to a notarized build (npm i -g @phnx-labs/agents-cli), then `agents menubar setup`.\n');
330
349
  return false;
331
350
  }
@@ -472,8 +491,10 @@ export function installMenubarLaunchAgentOnUpgrade() {
472
491
  }
473
492
  // Re-enable (recopy helper + rewrite plist) when the version drifted OR the
474
493
  // plist's baked interpreter/entry no longer point at the install now running
475
- // `agents` — the dual-install skew a version bump alone can't catch.
476
- if (menubarSetupStale() || menubarSetupNeedsRepoint()) {
494
+ // `agents` — the dual-install skew a version bump alone can't catch — OR the
495
+ // installed copy is still ad-hoc while the shipped source is Developer ID
496
+ // (older heal path; Accessibility re-prompts until the identity is restored).
497
+ if (menubarSetupStale() || menubarSetupNeedsRepoint() || installedNeedsDevIdHeal()) {
477
498
  enableMenubarService({ clearOptOut: false });
478
499
  }
479
500
  }
@@ -481,6 +502,15 @@ export function installMenubarLaunchAgentOnUpgrade() {
481
502
  /* never block startup on the menu bar */
482
503
  }
483
504
  }
505
+ /** True when App Support still has an ad-hoc copy but the npm bundle is Developer ID. */
506
+ function installedNeedsDevIdHeal() {
507
+ const src = sourceAppPath();
508
+ if (!src || !fs.existsSync(installedAppPath()))
509
+ return false;
510
+ if (hasDeveloperIdSignature(installedAppPath()))
511
+ return false;
512
+ return hasDeveloperIdSignature(src);
513
+ }
484
514
  /**
485
515
  * Decide which live helper processes must be ended so exactly one status item
486
516
  * survives. Pure so the choice is unit-testable without a live menu bar.
@@ -521,12 +551,12 @@ export function runMenubarSetup() {
521
551
  steps.push({ name, outcome, detail });
522
552
  };
523
553
  if (!onDarwin()) {
524
- step('platform', 'failed', `the menu bar helper is macOS only (this is ${process.platform})`);
554
+ step('platform', 'failed', `AGI Menu is macOS only (this is ${process.platform})`);
525
555
  return { steps, configured: false, status: getMenubarStatus() };
526
556
  }
527
557
  const before = getMenubarStatus();
528
558
  if (!sourceAppPath()) {
529
- step('bundle', 'failed', 'no menu-bar helper bundle ships with this install');
559
+ step('bundle', 'failed', 'no AGI Menu bundle ships with this install');
530
560
  return { steps, configured: false, status: before };
531
561
  }
532
562
  // 3 before 1: end the running copies BEFORE swapping the bundle underneath
@@ -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;
@@ -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 (typeof r.exit_code === 'number' && r.exit_code !== 0)
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);
@@ -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'>): ImportPlan;
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
- if (typeof o.repoSlug === 'string')
101
- def.repo = o.repoSlug;
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
- /** Plan completion percentage (0–100), or undefined when nothing is tracked. */
58
- export declare function planPct(plan: {
59
- done: number;
60
- total: number;
61
- }): number | undefined;
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
- /** Plan completion percentage (0–100), or undefined when nothing is tracked. */
81
- export function planPct(plan) {
82
- if (plan.total <= 0)
83
- return undefined;
84
- return Math.round((plan.done / plan.total) * 100);
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', '100'], { timeout: 8000, encoding: 'utf8' });
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 */
@@ -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
@@ -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 near-exhausted-but-still-
270
- * eligible candidate can still be picked occasionally. When usage is unknown
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, so cap how stale their usage may
354
- // be (see USAGE_DECISION_MAX_AGE_MS). Past that the fetch blocks on a live
355
- // read instead of serving the cache one bounded, parallel round trip per
356
- // account, and none at all inside the 2-minute fresh window that back-to-back
357
- // launches hit. A failed read still falls back to the cache; the pick then
358
- // routes around it via isUsageVerified rather than trusting the old number.
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
- })), { maxAgeMs: USAGE_DECISION_MAX_AGE_MS });
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
  }
@@ -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. */