@phnx-labs/agents-cli 1.21.1 → 1.21.3

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 (111) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +1 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.d.ts +4 -0
  5. package/dist/commands/computer-actions.js +34 -0
  6. package/dist/commands/computer.js +4 -2
  7. package/dist/commands/doctor.js +5 -2
  8. package/dist/commands/feed.js +28 -19
  9. package/dist/commands/hooks.js +9 -45
  10. package/dist/commands/menubar.js +24 -24
  11. package/dist/commands/message.js +23 -3
  12. package/dist/commands/perf.d.ts +29 -0
  13. package/dist/commands/perf.js +90 -23
  14. package/dist/commands/projects.d.ts +21 -0
  15. package/dist/commands/projects.js +169 -23
  16. package/dist/commands/routines.js +46 -1
  17. package/dist/commands/sessions-picker.js +17 -2
  18. package/dist/commands/sessions.d.ts +5 -0
  19. package/dist/commands/sessions.js +14 -0
  20. package/dist/commands/ssh.js +69 -0
  21. package/dist/commands/trends.d.ts +2 -0
  22. package/dist/commands/trends.js +158 -0
  23. package/dist/commands/usage.d.ts +4 -4
  24. package/dist/commands/view.d.ts +6 -0
  25. package/dist/commands/view.js +90 -45
  26. package/dist/index.js +22 -1
  27. package/dist/lib/agents.js +2 -2
  28. package/dist/lib/analytics/dashboard.d.ts +11 -0
  29. package/dist/lib/analytics/dashboard.js +31 -0
  30. package/dist/lib/analytics/recipes.d.ts +32 -0
  31. package/dist/lib/analytics/recipes.js +316 -0
  32. package/dist/lib/analytics/usage-db.d.ts +84 -0
  33. package/dist/lib/analytics/usage-db.js +301 -0
  34. package/dist/lib/browser/service.js +31 -0
  35. package/dist/lib/cli-resources.d.ts +20 -0
  36. package/dist/lib/cli-resources.js +48 -1
  37. package/dist/lib/computer/dispatch.d.ts +3 -1
  38. package/dist/lib/computer/dispatch.js +10 -2
  39. package/dist/lib/daemon.js +51 -14
  40. package/dist/lib/devices/health-report.d.ts +5 -0
  41. package/dist/lib/devices/health-report.js +3 -0
  42. package/dist/lib/event-stream.d.ts +2 -0
  43. package/dist/lib/event-stream.js +3 -0
  44. package/dist/lib/events.d.ts +3 -1
  45. package/dist/lib/events.js +4 -2
  46. package/dist/lib/feed-broadcast.d.ts +52 -7
  47. package/dist/lib/feed-broadcast.js +125 -18
  48. package/dist/lib/fleet-cache.d.ts +37 -0
  49. package/dist/lib/fleet-cache.js +40 -0
  50. package/dist/lib/fleet-status.d.ts +53 -0
  51. package/dist/lib/fleet-status.js +120 -0
  52. package/dist/lib/friction-heuristics.d.ts +32 -0
  53. package/dist/lib/friction-heuristics.js +47 -0
  54. package/dist/lib/git.d.ts +14 -0
  55. package/dist/lib/git.js +36 -0
  56. package/dist/lib/hooks/cache.js +28 -6
  57. package/dist/lib/hooks/profile.d.ts +8 -0
  58. package/dist/lib/hooks/profile.js +15 -18
  59. package/dist/lib/hooks.js +72 -17
  60. package/dist/lib/linear-cache.d.ts +63 -0
  61. package/dist/lib/linear-cache.js +146 -0
  62. package/dist/lib/linear-project-counts.d.ts +35 -5
  63. package/dist/lib/linear-project-counts.js +61 -16
  64. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  65. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  66. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  67. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  68. package/dist/lib/menubar/install-menubar.js +36 -6
  69. package/dist/lib/percentile.d.ts +12 -0
  70. package/dist/lib/percentile.js +24 -0
  71. package/dist/lib/perf/db.d.ts +7 -3
  72. package/dist/lib/perf/db.js +37 -19
  73. package/dist/lib/perf/types.d.ts +10 -0
  74. package/dist/lib/plugins.js +12 -1
  75. package/dist/lib/project-doctor.d.ts +36 -0
  76. package/dist/lib/project-doctor.js +45 -0
  77. package/dist/lib/project-import.d.ts +11 -1
  78. package/dist/lib/project-import.js +17 -3
  79. package/dist/lib/project-status.d.ts +25 -5
  80. package/dist/lib/project-status.js +48 -6
  81. package/dist/lib/resources.d.ts +16 -0
  82. package/dist/lib/resources.js +25 -14
  83. package/dist/lib/rotate.d.ts +27 -0
  84. package/dist/lib/rotate.js +44 -17
  85. package/dist/lib/routines.d.ts +16 -0
  86. package/dist/lib/routines.js +26 -0
  87. package/dist/lib/runner.js +41 -0
  88. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  89. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  90. package/dist/lib/secrets/usage-db.d.ts +3 -63
  91. package/dist/lib/secrets/usage-db.js +46 -186
  92. package/dist/lib/session/db.d.ts +17 -1
  93. package/dist/lib/session/db.js +240 -6
  94. package/dist/lib/session/discover.d.ts +31 -0
  95. package/dist/lib/session/discover.js +50 -1
  96. package/dist/lib/session/highlights.d.ts +24 -4
  97. package/dist/lib/session/highlights.js +52 -7
  98. package/dist/lib/session/parse.js +19 -3
  99. package/dist/lib/session/prompt.d.ts +19 -0
  100. package/dist/lib/session/prompt.js +43 -0
  101. package/dist/lib/session/types.d.ts +43 -0
  102. package/dist/lib/startup/command-registry.d.ts +1 -0
  103. package/dist/lib/startup/command-registry.js +2 -0
  104. package/dist/lib/state.d.ts +31 -3
  105. package/dist/lib/state.js +53 -10
  106. package/dist/lib/types.d.ts +22 -4
  107. package/dist/lib/usage-refresh.d.ts +106 -0
  108. package/dist/lib/usage-refresh.js +238 -0
  109. package/dist/lib/usage.d.ts +152 -17
  110. package/dist/lib/usage.js +393 -79
  111. package/package.json +1 -1
@@ -11,7 +11,7 @@ import { setHelpSections } from '../lib/help.js';
11
11
  import { enableMenubarService, disableMenubarService, getMenubarStatus, runMenubarSetup, } from '../lib/menubar/install-menubar.js';
12
12
  function notMac() {
13
13
  if (process.platform !== 'darwin') {
14
- console.log(chalk.yellow('The menu bar helper is macOS only.'));
14
+ console.log(chalk.yellow('AGI Menu is macOS only.'));
15
15
  return true;
16
16
  }
17
17
  return false;
@@ -19,7 +19,7 @@ function notMac() {
19
19
  /** Shared status readout — `status`, bare `menubar`, and `setup --check` all end here. */
20
20
  function printStatus(s, opts = {}) {
21
21
  const yn = (b) => (b ? chalk.green('yes') : chalk.gray('no'));
22
- console.log(chalk.bold('Menu bar helper\n'));
22
+ console.log(chalk.bold('AGI Menu\n'));
23
23
  console.log(` running ${yn(s.running)}`);
24
24
  console.log(` service installed ${yn(s.serviceInstalled)}`);
25
25
  if (opts.brief) {
@@ -34,7 +34,7 @@ function printStatus(s, opts = {}) {
34
34
  // Two copies of the INSTALLED bundle is the duplicate the user sees as two
35
35
  // agents marks in the menu bar. It used to read as a healthy `running: yes`.
36
36
  if (s.instances.length > 1) {
37
- console.log(chalk.yellow(`\n ${s.instances.length} copies of the installed helper are running — that is the duplicate menu-bar icon:`));
37
+ console.log(chalk.yellow(`\n ${s.instances.length} copies of AGI Menu are running — that is the duplicate menu-bar icon:`));
38
38
  for (const p of s.instances)
39
39
  console.log(chalk.gray(` ${p.pid} ${p.executable}`));
40
40
  console.log(chalk.gray(' Fix it with `agents menubar setup`.'));
@@ -45,20 +45,20 @@ function printStatus(s, opts = {}) {
45
45
  // was — only that a rival exists — so report the conflict, not a winner.
46
46
  // The loser has no other symptom: its chords simply never fire.
47
47
  const n = s.foreignInstances.length;
48
- console.log(chalk.yellow(`\n ${n} other helper process${n === 1 ? '' : 'es'} running — ${n === 1 ? 'it' : 'they'} may hold Cmd-Shift-V/O instead of the installed one:`));
48
+ console.log(chalk.yellow(`\n ${n} other AGI Menu process${n === 1 ? '' : 'es'} running — ${n === 1 ? 'it' : 'they'} may hold Cmd-Shift-V/O instead of the installed one:`));
49
49
  for (const p of s.foreignInstances)
50
50
  console.log(chalk.gray(` ${p.pid} ${p.executable}`));
51
51
  console.log(chalk.gray(' End them with `agents menubar setup`.'));
52
52
  }
53
53
  if (s.stale) {
54
- console.log(chalk.yellow('\n Installed helper is stale — runs on next `agents` startup, or `agents menubar setup` now.'));
54
+ console.log(chalk.yellow('\n Installed AGI Menu is stale — runs on next `agents` startup, or `agents menubar setup` now.'));
55
55
  }
56
56
  else if (!s.serviceInstalled && !s.disabledByUser) {
57
57
  console.log(chalk.gray('\n Set it up with `agents menubar setup`.'));
58
58
  }
59
59
  }
60
60
  function printSetupResult(r) {
61
- console.log(chalk.bold('Menu bar setup\n'));
61
+ console.log(chalk.bold('AGI Menu setup\n'));
62
62
  for (const step of r.steps) {
63
63
  const mark = step.outcome === 'failed' ? chalk.red('✗')
64
64
  : step.outcome === 'changed' ? chalk.green('+') : chalk.green('✓');
@@ -66,22 +66,22 @@ function printSetupResult(r) {
66
66
  }
67
67
  console.log();
68
68
  if (r.configured) {
69
- console.log(chalk.green('Menu bar configured.') + chalk.gray(' One agents mark, started at login.'));
69
+ console.log(chalk.green('AGI Menu configured.') + chalk.gray(' One agents mark, started at login.'));
70
70
  }
71
71
  else {
72
- console.log(chalk.red('Menu bar not fully configured.') + chalk.gray(' See the failed step above.'));
72
+ console.log(chalk.red('AGI Menu not fully configured.') + chalk.gray(' See the failed step above.'));
73
73
  }
74
74
  }
75
75
  export function registerMenubarCommands(program) {
76
76
  const menubar = program
77
77
  .command('menubar')
78
- .description('Manage the macOS menu-bar helper (running sessions, agents awaiting input, routines)');
78
+ .description('Manage AGI Menu (running sessions, agents awaiting input, routines)');
79
79
  // `setup` is the one command that gets a machine to the intended state:
80
80
  // exactly one status item, started at login. `enable` stays the narrow
81
81
  // install+start; setup adds duplicate cleanup and verifies the end state.
82
82
  const setup = menubar
83
83
  .command('setup')
84
- .description('Configure the menu bar end-to-end: one instance, started at login')
84
+ .description('Configure AGI Menu end-to-end: one instance, started at login')
85
85
  .option('--check', 'Report the current state, change nothing')
86
86
  .option('--json', 'Emit machine-readable JSON')
87
87
  .action((options) => {
@@ -110,7 +110,7 @@ export function registerMenubarCommands(program) {
110
110
  });
111
111
  setHelpSections(setup, {
112
112
  examples: `
113
- # Configure the menu bar end-to-end (idempotent — safe to re-run)
113
+ # Configure AGI Menu end-to-end (idempotent — safe to re-run)
114
114
  agents menubar setup
115
115
 
116
116
  # Two agents marks in the menu bar? This ends the duplicate.
@@ -120,44 +120,44 @@ export function registerMenubarCommands(program) {
120
120
  agents menubar setup --check
121
121
  `,
122
122
  notes: `
123
- Configures, in order: every running helper ended, the helper bundle at
124
- ~/Library/Application Support/agents-cli, its code signature, the launchd
125
- login item (com.phnx-labs.agents-menubar — RunAtLoad + KeepAlive), then
126
- verifies exactly one helper came back up.
123
+ Configures, in order: every running helper ended, AGI Menu at
124
+ ~/Library/Application Support/agents-cli/MenubarHelper.app, its code
125
+ signature, the launchd login item (com.phnx-labs.agents-menubar —
126
+ RunAtLoad + KeepAlive), then verifies exactly one helper came back up.
127
127
 
128
128
  Every running helper is ended and launchd restarts one, so the survivor is
129
129
  always the login-managed copy. Exits nonzero if it cannot reach that state.
130
130
 
131
- Setup clears a previous \`agents menubar disable\`. To turn the menu bar off
131
+ Setup clears a previous \`agents menubar disable\`. To turn AGI Menu off
132
132
  again, run \`agents menubar disable\`.
133
133
  `,
134
134
  });
135
135
  menubar
136
136
  .command('enable')
137
- .description('Install and start the menu-bar helper (launches at login)')
137
+ .description('Install and start AGI Menu (launches at login)')
138
138
  .action(() => {
139
139
  if (notMac())
140
140
  return;
141
141
  const ok = enableMenubarService({ clearOptOut: true });
142
142
  if (!ok) {
143
- console.log(chalk.red('Could not enable: no menu-bar helper bundle ships with this install.'));
143
+ console.log(chalk.red('Could not enable: no AGI Menu bundle ships with this install.'));
144
144
  console.log(chalk.gray(' This build may predate the helper, or be a non-macOS package.'));
145
145
  return;
146
146
  }
147
- console.log(chalk.green('Menu bar helper enabled.') + chalk.gray(' Look for the agents mark in your menu bar.'));
147
+ console.log(chalk.green('AGI Menu enabled.') + chalk.gray(' Look for the agents mark in your menu bar.'));
148
148
  });
149
149
  menubar
150
150
  .command('disable')
151
- .description('Stop and remove the menu-bar helper (stays off across upgrades)')
151
+ .description('Stop and remove AGI Menu (stays off across upgrades)')
152
152
  .action(() => {
153
153
  if (notMac())
154
154
  return;
155
155
  disableMenubarService();
156
- console.log(chalk.green('Menu bar helper disabled.') + chalk.gray(' Re-enable any time with `agents menubar setup`.'));
156
+ console.log(chalk.green('AGI Menu disabled.') + chalk.gray(' Re-enable any time with `agents menubar setup`.'));
157
157
  });
158
158
  menubar
159
159
  .command('status')
160
- .description('Show whether the menu-bar helper is installed and running')
160
+ .description('Show whether AGI Menu is installed and running')
161
161
  .option('--json', 'Emit machine-readable JSON')
162
162
  .action((options) => {
163
163
  const s = getMenubarStatus();
@@ -166,7 +166,7 @@ export function registerMenubarCommands(program) {
166
166
  return;
167
167
  }
168
168
  if (s.platform !== 'darwin') {
169
- console.log(chalk.yellow('The menu bar helper is macOS only.'));
169
+ console.log(chalk.yellow('AGI Menu is macOS only.'));
170
170
  return;
171
171
  }
172
172
  printStatus(s);
@@ -175,7 +175,7 @@ export function registerMenubarCommands(program) {
175
175
  menubar.action(() => {
176
176
  const s = getMenubarStatus();
177
177
  if (s.platform !== 'darwin') {
178
- console.log(chalk.yellow('The menu bar helper is macOS only.'));
178
+ console.log(chalk.yellow('AGI Menu is macOS only.'));
179
179
  return;
180
180
  }
181
181
  printStatus(s, { brief: true });
@@ -12,6 +12,7 @@ import { blockIdForSession, listBlocks, readBlock, recordAnswer, recordMessageRe
12
12
  import { verifyOperatorIdentity } from '../lib/operator.js';
13
13
  import { resolveAnswerRoute, resumeArgv, } from '../lib/answer-router.js';
14
14
  import { injectIntoTerminal } from '../lib/terminal/inject.js';
15
+ import { setHelpSections } from '../lib/help.js';
15
16
  /** Find the still-open block addressed to `mailboxId`, if any. */
16
17
  function findOpenBlockForMailbox(mailboxId) {
17
18
  // Fast path: the mailbox id is usually the session id, so the block id is
@@ -111,15 +112,34 @@ async function deliverViaResume(route, mailboxId) {
111
112
  console.log(chalk.green(`Resumed ${mailboxId} with answer. `) +
112
113
  chalk.dim(route.reason));
113
114
  }
115
+ /**
116
+ * `message` is the agent-control plane (RUSH-2123): the answer/keystroke/
117
+ * injected input a running agent consumes, never a notification a human reads.
118
+ * Mirrors SHARED_NOTES in commands/send.ts so an agent reading either --help
119
+ * sees the same three-plane map and doesn't reach for `message` when it means
120
+ * `send`/`notify`.
121
+ */
122
+ const CONTROL_PLANE_NOTES = `
123
+ Planes (do not mix them up):
124
+ message / sessions inject - CONTROL a running agent (mailbox answer, PTY keystroke, or resume by runtime)
125
+ send / notify - DELIVER a message to a human recipient over a channel provider
126
+ feed post - RECORD progress / milestones (optional broadcast may call send/notify)
127
+
128
+ <text> here is consumed BY THE TARGET AGENT (an answer, a keystroke, or the
129
+ argument to a resume) -- it is not a notification a person reads on their
130
+ phone. To reach the operator instead, use \`agents send\` / \`agents notify\`
131
+ (or a feed.broadcast \`channel:\` sink).
132
+ `;
114
133
  export function registerMessageCommand(program) {
115
- program
134
+ const messageCmd = program
116
135
  .command('message <target> <text>')
117
136
  .description('Send a message to a running or parked agent (mailbox / PTY-select / resume by runtime).')
118
137
  .option('--from <who>', 'Label recorded as the sender of this message')
119
138
  .option('--as <operator>', 'Verified operator id answering a high-consequence block')
120
139
  .option('--surface <surface>', 'Surface that is sending this answer (feed, terminal, etc.)', 'cli')
121
- .option('--ttl <dur>', 'Delivery TTL if the message is not consumed (e.g. 30m, 1h, 24h); 0 disables expiry')
122
- .action(async (target, text, opts) => {
140
+ .option('--ttl <dur>', 'Delivery TTL if the message is not consumed (e.g. 30m, 1h, 24h); 0 disables expiry');
141
+ setHelpSections(messageCmd, { notes: CONTROL_PLANE_NOTES });
142
+ messageCmd.action(async (target, text, opts) => {
123
143
  if (!target.trim()) {
124
144
  die('Target must be a session/agent id or cloud task id. Run `agents sessions --active` to list running agents.');
125
145
  }
@@ -6,9 +6,38 @@
6
6
  * agents perf hooks per-hook p50/p99 + cache hit rates
7
7
  * agents perf commands slowest CLI command paths (from command.end)
8
8
  * agents perf run agent.run / perf.timing labels
9
+ * agents perf friction sessions stuck repeatedly hitting the same guard
9
10
  *
10
11
  * Soft-joins sessions.db via shared string keys (session_id, agent, machine) —
11
12
  * no foreign keys. Warehouse lives at ~/.agents/.cache/perf/perf.db (safe to wipe).
12
13
  */
13
14
  import type { Command } from 'commander';
15
+ import { type PerfAggregateRow } from '../lib/perf/db.js';
16
+ import { type HookProfileRow } from '../lib/hooks/profile.js';
17
+ interface PerfGlobalOpts {
18
+ days?: string;
19
+ warnMs?: string;
20
+ json?: boolean;
21
+ limit?: string;
22
+ project?: string;
23
+ }
24
+ /** Map warehouse rows shaped like hook.fire into the existing HookProfileRow UI. */
25
+ export declare function asHookRows(rows: PerfAggregateRow[]): HookProfileRow[];
26
+ export declare function renderHookTable(rows: HookProfileRow[], warnMs: number): void;
27
+ /**
28
+ * Prefer SQLite samples; fall back to the legacy daily JSONL so existing
29
+ * instrumentation still surfaces until shims are resynced. `project` only
30
+ * narrows the SQLite path — the legacy JSONL log has no cwd, so a fallback
31
+ * hit ignores it (a caller filtering by project has no legacy rows to miss).
32
+ */
33
+ export declare function loadHookProfile(days: number, project?: string): HookProfileRow[];
34
+ /**
35
+ * Sessions stuck repeatedly hitting the SAME guard block (git-guard,
36
+ * rm-guard, git-require-clean-tree, …) instead of adapting after the first
37
+ * denial. Reads the `friction` event sink (emitFriction in events.ts) that
38
+ * guard hooks self-report into via `agents _internal friction` before they
39
+ * exit 2 — see lib/friction-heuristics.ts for the grouping.
40
+ */
41
+ export declare function frictionAction(opts: PerfGlobalOpts): void;
14
42
  export declare function registerPerfCommand(program: Command): void;
43
+ export {};
@@ -6,6 +6,7 @@
6
6
  * agents perf hooks per-hook p50/p99 + cache hit rates
7
7
  * agents perf commands slowest CLI command paths (from command.end)
8
8
  * agents perf run agent.run / perf.timing labels
9
+ * agents perf friction sessions stuck repeatedly hitting the same guard
9
10
  *
10
11
  * Soft-joins sessions.db via shared string keys (session_id, agent, machine) —
11
12
  * no foreign keys. Warehouse lives at ~/.agents/.cache/perf/perf.db (safe to wipe).
@@ -13,6 +14,8 @@
13
14
  import chalk from 'chalk';
14
15
  import { aggregateSamples, perfDbPath, } from '../lib/perf/db.js';
15
16
  import { formatMs, formatCacheColumn, DEFAULT_SLOW_HOOK_WARN_MS, loadHookFireEvents, aggregateHookProfile, } from '../lib/hooks/profile.js';
17
+ import { query } from '../lib/events.js';
18
+ import { detectRepeatedGuardBlocks } from '../lib/friction-heuristics.js';
16
19
  function parseDays(raw) {
17
20
  const n = parseInt(raw ?? '7', 10);
18
21
  return Number.isFinite(n) && n > 0 ? n : 7;
@@ -26,11 +29,12 @@ function parseLimit(raw, fallback) {
26
29
  return Number.isFinite(n) && n > 0 ? n : fallback;
27
30
  }
28
31
  /** Map warehouse rows shaped like hook.fire into the existing HookProfileRow UI. */
29
- function asHookRows(rows) {
32
+ export function asHookRows(rows) {
30
33
  return rows.map((r) => ({
31
34
  hook: r.label,
32
35
  n: r.n,
33
36
  p50Ms: r.p50Ms,
37
+ p95Ms: r.p95Ms,
34
38
  p99Ms: r.p99Ms,
35
39
  meanMs: r.meanMs,
36
40
  maxMs: r.maxMs,
@@ -38,6 +42,9 @@ function asHookRows(rows) {
38
42
  cacheStalePct: r.cacheStalePct ?? 0,
39
43
  cacheMissPct: r.cacheMissPct ?? 0,
40
44
  errorCount: r.errorCount ?? 0,
45
+ errorRate: r.errorRate,
46
+ timeoutRate: r.timeoutRate,
47
+ project: r.project,
41
48
  }));
42
49
  }
43
50
  function printTable(headers, widths, lines, slowFlags) {
@@ -50,23 +57,34 @@ function printTable(headers, widths, lines, slowFlags) {
50
57
  console.log(slowFlags[i] ? chalk.yellow(line) : line);
51
58
  }
52
59
  }
53
- function renderHookTable(rows, warnMs) {
60
+ /** `err:12% to:4%` when either rate is present, else ''. */
61
+ function formatRateColumn(r) {
62
+ const parts = [];
63
+ if (r.errorRate)
64
+ parts.push(`err:${Math.round(r.errorRate * 100)}%`);
65
+ if (r.timeoutRate)
66
+ parts.push(`to:${Math.round(r.timeoutRate * 100)}%`);
67
+ return parts.join(' ');
68
+ }
69
+ export function renderHookTable(rows, warnMs) {
54
70
  if (rows.length === 0) {
55
71
  console.log(chalk.gray('No hook timing samples yet.'));
56
72
  console.log(chalk.gray(`Warehouse: ${perfDbPath()}`));
57
73
  console.log(chalk.gray('Hooks write via cache/matches shims into the spool; run a session or resync hooks.'));
58
74
  return;
59
75
  }
60
- const widths = { hook: 36, n: 5, p50: 7, p99: 7, mean: 7, max: 7, cache: 28 };
76
+ const widths = { hook: 36, n: 5, p50: 7, p95: 7, p99: 7, mean: 7, max: 7, cache: 22, rate: 14 };
61
77
  const pad = (s, w) => (s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length));
62
78
  const header = [
63
79
  pad('HOOK', widths.hook),
64
80
  pad('N', widths.n),
65
81
  pad('P50', widths.p50),
82
+ pad('P95', widths.p95),
66
83
  pad('P99', widths.p99),
67
84
  pad('MEAN', widths.mean),
68
85
  pad('MAX', widths.max),
69
86
  pad('CACHE', widths.cache),
87
+ pad('ERR/TIMEOUT', widths.rate),
70
88
  ].join(' ');
71
89
  console.log(chalk.bold(header));
72
90
  console.log(chalk.gray('─'.repeat(header.length)));
@@ -78,10 +96,12 @@ function renderHookTable(rows, warnMs) {
78
96
  pad(r.hook, widths.hook),
79
97
  pad(String(r.n), widths.n),
80
98
  pad(formatMs(r.p50Ms), widths.p50),
99
+ pad(formatMs(r.p95Ms), widths.p95),
81
100
  pad(formatMs(r.p99Ms), widths.p99),
82
101
  pad(formatMs(r.meanMs), widths.mean),
83
102
  pad(formatMs(r.maxMs), widths.max),
84
103
  pad(cacheCol, widths.cache),
104
+ pad(formatRateColumn(r), widths.rate),
85
105
  ].join(' ') + warning;
86
106
  console.log(slow ? chalk.yellow(line) : line);
87
107
  }
@@ -92,30 +112,34 @@ function renderLabelTable(title, rows, warnMs, limit) {
92
112
  console.log(chalk.gray(`No ${title} samples yet.`));
93
113
  return;
94
114
  }
95
- const widths = [40, 5, 7, 7, 7, 7];
96
- printTable(['LABEL', 'N', 'P50', 'P99', 'MEAN', 'MAX'], widths, sliced.map((r) => [
115
+ const widths = [40, 5, 7, 7, 7, 7, 7, 14];
116
+ printTable(['LABEL', 'N', 'P50', 'P95', 'P99', 'MEAN', 'MAX', 'ERR/TIMEOUT'], widths, sliced.map((r) => [
97
117
  r.label,
98
118
  String(r.n),
99
119
  formatMs(r.p50Ms),
120
+ formatMs(r.p95Ms),
100
121
  formatMs(r.p99Ms),
101
122
  formatMs(r.meanMs),
102
123
  formatMs(r.maxMs),
124
+ formatRateColumn(r),
103
125
  ]), sliced.map((r) => r.p99Ms > warnMs));
104
126
  }
105
127
  /**
106
128
  * Prefer SQLite samples; fall back to the legacy daily JSONL so existing
107
- * instrumentation still surfaces until shims are resynced.
129
+ * instrumentation still surfaces until shims are resynced. `project` only
130
+ * narrows the SQLite path — the legacy JSONL log has no cwd, so a fallback
131
+ * hit ignores it (a caller filtering by project has no legacy rows to miss).
108
132
  */
109
- function loadHookProfile(days) {
110
- const fromDb = asHookRows(aggregateSamples({ days, kinds: ['hook.fire'] }));
111
- if (fromDb.length > 0)
133
+ export function loadHookProfile(days, project) {
134
+ const fromDb = asHookRows(aggregateSamples({ days, kinds: ['hook.fire'], project }));
135
+ if (fromDb.length > 0 || project)
112
136
  return fromDb;
113
137
  return aggregateHookProfile(loadHookFireEvents(days));
114
138
  }
115
139
  function hooksAction(opts) {
116
140
  const days = parseDays(opts.days);
117
141
  const warnMs = parseWarnMs(opts.warnMs, DEFAULT_SLOW_HOOK_WARN_MS);
118
- const rows = loadHookProfile(days);
142
+ const rows = loadHookProfile(days, opts.project);
119
143
  if (opts.json) {
120
144
  console.log(JSON.stringify(rows, null, 2));
121
145
  return;
@@ -126,7 +150,7 @@ function commandsAction(opts) {
126
150
  const days = parseDays(opts.days);
127
151
  const warnMs = parseWarnMs(opts.warnMs, 500);
128
152
  const limit = parseLimit(opts.limit, 40);
129
- const rows = aggregateSamples({ days, kinds: ['command.end'] });
153
+ const rows = aggregateSamples({ days, kinds: ['command.end'], project: opts.project });
130
154
  if (opts.json) {
131
155
  console.log(JSON.stringify(rows.slice(0, limit), null, 2));
132
156
  return;
@@ -137,42 +161,79 @@ function runAction(opts) {
137
161
  const days = parseDays(opts.days);
138
162
  const warnMs = parseWarnMs(opts.warnMs, 60_000);
139
163
  const limit = parseLimit(opts.limit, 40);
140
- const rows = aggregateSamples({ days, kinds: ['perf.timing'] });
164
+ const rows = aggregateSamples({ days, kinds: ['perf.timing'], project: opts.project });
141
165
  if (opts.json) {
142
166
  console.log(JSON.stringify(rows.slice(0, limit), null, 2));
143
167
  return;
144
168
  }
145
169
  renderLabelTable('run/timing', rows, warnMs, limit);
146
170
  }
171
+ /**
172
+ * Sessions stuck repeatedly hitting the SAME guard block (git-guard,
173
+ * rm-guard, git-require-clean-tree, …) instead of adapting after the first
174
+ * denial. Reads the `friction` event sink (emitFriction in events.ts) that
175
+ * guard hooks self-report into via `agents _internal friction` before they
176
+ * exit 2 — see lib/friction-heuristics.ts for the grouping.
177
+ */
178
+ export function frictionAction(opts) {
179
+ // --project is declared on the shared `perf` parent for hooks/commands/run,
180
+ // where every sample carries a cwd. friction events (emitFriction in
181
+ // events.ts) don't carry one today — agents _internal friction has no
182
+ // --cwd flag — so silently accepting the flag here would look like it
183
+ // filtered when it did nothing. Fail loud instead of no-op.
184
+ if (opts.project) {
185
+ console.error(chalk.red("agents perf friction does not support --project yet — friction events carry no cwd to filter on."));
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ const days = parseDays(opts.days);
190
+ const startDate = new Date(Date.now() - days * 86_400_000);
191
+ const events = query({ eventTypes: ['friction'], startDate });
192
+ const findings = detectRepeatedGuardBlocks(events);
193
+ if (opts.json) {
194
+ console.log(JSON.stringify(findings, null, 2));
195
+ return;
196
+ }
197
+ if (findings.length === 0) {
198
+ console.log(chalk.gray(`No repeated guard blocks in the last ${days} day${days === 1 ? '' : 's'}.`));
199
+ return;
200
+ }
201
+ console.log(chalk.bold(`Repeated guard blocks — last ${days} day${days === 1 ? '' : 's'}\n`));
202
+ const widths = [14, 10, 24, 5, 22, 22];
203
+ printTable(['SESSION', 'SURFACE', 'FAILURE ID', 'N', 'FIRST', 'LAST'], widths, findings.map((f) => [f.session, f.surface, f.failureId, String(f.count), f.firstTs, f.lastTs]), findings.map(() => false));
204
+ }
147
205
  function summaryAction(opts) {
148
206
  const days = parseDays(opts.days);
207
+ const project = opts.project;
149
208
  if (opts.json) {
150
209
  console.log(JSON.stringify({
151
210
  days,
211
+ project: project ?? null,
152
212
  warehouse: perfDbPath(),
153
- hooks: loadHookProfile(days),
154
- commands: aggregateSamples({ days, kinds: ['command.end'] }).slice(0, 20),
155
- run: aggregateSamples({ days, kinds: ['perf.timing'] }).slice(0, 20),
213
+ hooks: loadHookProfile(days, project),
214
+ commands: aggregateSamples({ days, kinds: ['command.end'], project }).slice(0, 20),
215
+ run: aggregateSamples({ days, kinds: ['perf.timing'], project }).slice(0, 20),
156
216
  }, null, 2));
157
217
  return;
158
218
  }
159
- console.log(chalk.bold(`agents perf — last ${days} day${days === 1 ? '' : 's'}`));
219
+ console.log(chalk.bold(`agents perf — last ${days} day${days === 1 ? '' : 's'}${project ? ` — project ${project}` : ''}`));
160
220
  console.log(chalk.gray(`warehouse: ${perfDbPath()} (disposable; soft-join sessions via session_id/agent/machine)`));
161
221
  console.log('');
162
222
  console.log(chalk.bold('Commands (slowest by p99)'));
163
- renderLabelTable('command', aggregateSamples({ days, kinds: ['command.end'] }), parseWarnMs(opts.warnMs, 500), 12);
223
+ renderLabelTable('command', aggregateSamples({ days, kinds: ['command.end'], project }), parseWarnMs(opts.warnMs, 500), 12);
164
224
  console.log('');
165
225
  console.log(chalk.bold('Hooks'));
166
- renderHookTable(loadHookProfile(days), parseWarnMs(opts.warnMs, DEFAULT_SLOW_HOOK_WARN_MS));
226
+ renderHookTable(loadHookProfile(days, project), parseWarnMs(opts.warnMs, DEFAULT_SLOW_HOOK_WARN_MS));
167
227
  console.log('');
168
228
  console.log(chalk.bold('Runs (perf.timing)'));
169
- renderLabelTable('run/timing', aggregateSamples({ days, kinds: ['perf.timing'] }), parseWarnMs(opts.warnMs, 60_000), 12);
229
+ renderLabelTable('run/timing', aggregateSamples({ days, kinds: ['perf.timing'], project }), parseWarnMs(opts.warnMs, 60_000), 12);
170
230
  }
171
231
  function attachSharedOptions(cmd) {
172
232
  return cmd
173
233
  .option('--days <n>', 'Days of samples to include', '7')
174
234
  .option('--warn-ms <n>', 'p99 above this is highlighted')
175
235
  .option('--limit <n>', 'Max rows in the table', '40')
236
+ .option('--project <key>', 'Scope to one project (the repo directory name a sample\'s cwd resolves to — see project-key.ts)')
176
237
  .option('--json', 'Emit JSON instead of a table');
177
238
  }
178
239
  /**
@@ -195,11 +256,13 @@ Identity columns reuse sessions/events string shapes (session_id, agent, machine
195
256
  for soft cross-reference; there are no foreign keys.
196
257
 
197
258
  Examples:
198
- agents perf # summary: commands + hooks + runs
199
- agents perf hooks # per-hook p50/p99 + cache hit rate
200
- agents perf commands --days 30 # slowest CLI entrypoints
201
- agents perf run --json # agent.run timings as JSON
259
+ agents perf # summary: commands + hooks + runs
260
+ agents perf hooks # per-hook p50/p95/p99 + cache hit rate
261
+ agents perf commands --days 30 # slowest CLI entrypoints
262
+ agents perf run --json # agent.run timings as JSON
202
263
  agents perf hooks --warn-ms 500
264
+ agents perf hooks --project agents-cli # scope to one repo's samples
265
+ agents perf friction # sessions stuck retrying the same guard block
203
266
  `);
204
267
  // Options live on the parent so `agents perf --json` and
205
268
  // `agents perf commands --json` both work (see leafOpts).
@@ -218,4 +281,8 @@ Examples:
218
281
  .action(function run() {
219
282
  runAction(leafOpts(this));
220
283
  });
284
+ perf.command('friction').description('Sessions stuck repeatedly hitting the same guard block')
285
+ .action(function friction() {
286
+ frictionAction(leafOpts(this));
287
+ });
221
288
  }
@@ -20,6 +20,18 @@ export declare const PROJECTS_NO_FANOUT_ENV = "AGENTS_PROJECTS_LOCAL";
20
20
  * string when everything answered.
21
21
  */
22
22
  export declare function formatFleetSkippedNote(skipped: string[]): string;
23
+ /**
24
+ * Best-effort `owner/repo` from a repo's origin remote.
25
+ *
26
+ * `stderr: 'ignore'` is load-bearing, not tidiness. A checkout with no origin
27
+ * makes git print `error: No such remote 'origin'` on ITS stderr, which is the
28
+ * terminal's — the catch below never sees it. That was invisible while this was
29
+ * called once from `add` inside a real repo, and became noise the moment
30
+ * `import --from-factory` started calling it per row: importing 12 registry
31
+ * rows printed two raw git errors between the progress lines. Absence of a
32
+ * remote is an expected answer here (`undefined`), not something to report.
33
+ */
34
+ export declare function originSlug(cwd: string): string | undefined;
23
35
  /** One `projects list` row, pre-render. */
24
36
  export interface ProjectListRow {
25
37
  name: string;
@@ -45,6 +57,15 @@ export declare function computeProjectListWidths(rows: ProjectListRow[]): {
45
57
  * read it as UTC and shift the answer by a day for anyone west of Greenwich.
46
58
  */
47
59
  export declare function formatMilestoneDue(targetDate: string, nowMs: number): string | undefined;
60
+ /**
61
+ * The milestone block. `status` prints one line — the next checkpoint — because
62
+ * a roll-up across projects has to stay scannable. `view` prints every declared
63
+ * milestone, because "how many are there and when are they due" is the shape of
64
+ * the plan and the reason to open one project.
65
+ *
66
+ * Pure (chalk only) so the layout is testable without a Linear account.
67
+ */
68
+ export declare function formatMilestoneLines(milestones: LinearMilestone[], next: LinearMilestone | undefined, nowMs: number, limit: number): string[];
48
69
  /** The `next` card line: what this project is due to hit, and how far along it is. */
49
70
  export declare function formatNextMilestone(ms: LinearMilestone, nowMs: number): string;
50
71
  export declare function registerProjectsCommands(program: Command): void;