@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/README.md +2 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +63 -40
  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/monitors.js +8 -6
  10. package/dist/commands/perf.d.ts +13 -0
  11. package/dist/commands/perf.js +80 -23
  12. package/dist/commands/projects.d.ts +11 -0
  13. package/dist/commands/projects.js +153 -21
  14. package/dist/commands/routines.js +46 -1
  15. package/dist/commands/ssh.js +69 -0
  16. package/dist/commands/trends.d.ts +2 -0
  17. package/dist/commands/trends.js +158 -0
  18. package/dist/commands/usage.d.ts +4 -4
  19. package/dist/commands/view.d.ts +6 -0
  20. package/dist/commands/view.js +90 -45
  21. package/dist/index.js +14 -1
  22. package/dist/lib/activity.d.ts +6 -0
  23. package/dist/lib/activity.js +3 -0
  24. package/dist/lib/agents.js +2 -2
  25. package/dist/lib/analytics/dashboard.d.ts +11 -0
  26. package/dist/lib/analytics/dashboard.js +31 -0
  27. package/dist/lib/analytics/recipes.d.ts +32 -0
  28. package/dist/lib/analytics/recipes.js +316 -0
  29. package/dist/lib/analytics/usage-db.d.ts +84 -0
  30. package/dist/lib/analytics/usage-db.js +301 -0
  31. package/dist/lib/browser/service.js +18 -0
  32. package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
  33. package/dist/lib/channels/resolve.d.ts +18 -1
  34. package/dist/lib/channels/resolve.js +15 -5
  35. package/dist/lib/cli-resources.d.ts +20 -0
  36. package/dist/lib/cli-resources.js +48 -1
  37. package/dist/lib/daemon.js +51 -14
  38. package/dist/lib/devices/health-report.d.ts +5 -0
  39. package/dist/lib/devices/health-report.js +3 -0
  40. package/dist/lib/feed-broadcast.d.ts +93 -12
  41. package/dist/lib/feed-broadcast.js +235 -30
  42. package/dist/lib/feed-post.d.ts +16 -2
  43. package/dist/lib/feed-post.js +35 -7
  44. package/dist/lib/feed.js +1 -1
  45. package/dist/lib/fleet-cache.d.ts +37 -0
  46. package/dist/lib/fleet-cache.js +40 -0
  47. package/dist/lib/fleet-status.d.ts +53 -0
  48. package/dist/lib/fleet-status.js +120 -0
  49. package/dist/lib/friction-heuristics.d.ts +32 -0
  50. package/dist/lib/friction-heuristics.js +47 -0
  51. package/dist/lib/hooks/cache.js +28 -6
  52. package/dist/lib/hooks/profile.d.ts +8 -0
  53. package/dist/lib/hooks/profile.js +14 -4
  54. package/dist/lib/hooks.d.ts +16 -1
  55. package/dist/lib/hooks.js +133 -18
  56. package/dist/lib/linear-cache.d.ts +63 -0
  57. package/dist/lib/linear-cache.js +146 -0
  58. package/dist/lib/linear-project-counts.d.ts +35 -5
  59. package/dist/lib/linear-project-counts.js +61 -16
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  64. package/dist/lib/menubar/install-menubar.js +36 -6
  65. package/dist/lib/monitors/config.d.ts +1 -1
  66. package/dist/lib/monitors/dispatch.d.ts +6 -2
  67. package/dist/lib/monitors/dispatch.js +10 -15
  68. package/dist/lib/notify.d.ts +42 -6
  69. package/dist/lib/notify.js +41 -32
  70. package/dist/lib/perf/db.d.ts +6 -1
  71. package/dist/lib/perf/db.js +35 -5
  72. package/dist/lib/perf/types.d.ts +10 -0
  73. package/dist/lib/project-doctor.d.ts +36 -0
  74. package/dist/lib/project-doctor.js +45 -0
  75. package/dist/lib/project-import.d.ts +11 -1
  76. package/dist/lib/project-import.js +17 -3
  77. package/dist/lib/project-status.d.ts +25 -5
  78. package/dist/lib/project-status.js +48 -6
  79. package/dist/lib/rotate.d.ts +27 -0
  80. package/dist/lib/rotate.js +44 -17
  81. package/dist/lib/routines.d.ts +16 -0
  82. package/dist/lib/routines.js +39 -0
  83. package/dist/lib/runner.js +34 -0
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  86. package/dist/lib/secrets/usage-db.d.ts +3 -63
  87. package/dist/lib/secrets/usage-db.js +46 -186
  88. package/dist/lib/session/db.d.ts +2 -1
  89. package/dist/lib/session/db.js +14 -3
  90. package/dist/lib/session/discover.d.ts +3 -0
  91. package/dist/lib/session/discover.js +8 -0
  92. package/dist/lib/session/types.d.ts +1 -0
  93. package/dist/lib/startup/command-registry.d.ts +1 -0
  94. package/dist/lib/startup/command-registry.js +2 -0
  95. package/dist/lib/state.d.ts +31 -3
  96. package/dist/lib/state.js +53 -10
  97. package/dist/lib/types.d.ts +14 -4
  98. package/dist/lib/usage-refresh.d.ts +106 -0
  99. package/dist/lib/usage-refresh.js +238 -0
  100. package/dist/lib/usage.d.ts +152 -17
  101. package/dist/lib/usage.js +393 -79
  102. 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
  }
@@ -57,7 +57,7 @@ function actionLabel(action) {
57
57
  case 'routine':
58
58
  return `routine ${action.routine ?? ''}`;
59
59
  case 'notify':
60
- return `notify ${action.notifyChannel ?? 'telegram'}`;
60
+ return `notify ${action.notifyChannel ?? 'owner'}`;
61
61
  case 'webhook-out':
62
62
  return `webhook-out ${action.url ?? ''}`;
63
63
  default:
@@ -195,9 +195,11 @@ function buildAction(options) {
195
195
  if (options.routine)
196
196
  chosen.push({ type: 'routine', routine: options.routine });
197
197
  if (options.notify !== undefined) {
198
- // --notify may be a bare flag (true) or carry a channel string.
199
- const channel = typeof options.notify === 'string' ? options.notify : 'telegram';
200
- chosen.push({ type: 'notify', notifyChannel: channel });
198
+ // --notify may be a bare flag (notify the owner) or carry a channel that
199
+ // overrides notify.owner.channel. Left unset, the send resolves the owner
200
+ // channel + target from notify.owner in agents.yaml (one source of truth).
201
+ const channel = typeof options.notify === 'string' ? options.notify : undefined;
202
+ chosen.push({ type: 'notify', ...(channel ? { notifyChannel: channel } : {}) });
201
203
  }
202
204
  if (options.webhookOut)
203
205
  chosen.push({ type: 'webhook-out', url: options.webhookOut });
@@ -310,7 +312,7 @@ export function registerMonitorsCommands(program) {
310
312
  .option('--effort <effort>', 'Reasoning effort for --run: low | medium | high | xhigh | max | auto')
311
313
  .option('--action-timeout <t>', 'Kill the --run action if it runs longer than this (e.g. 10m)')
312
314
  .option('--routine <name>', 'Fire an existing routine on change')
313
- .option('--notify [channel]', 'Send a notification (default channel: telegram)')
315
+ .option('--notify [channel]', 'Notify the owner (notify.owner); [channel] overrides the owner channel')
314
316
  .option('--webhook-out <url>', 'POST the event to this URL')
315
317
  // PLACEMENT / hygiene
316
318
  .option('--device <name>', 'OWNER device — the single machine that evaluates + fires (exactly-once)')
@@ -573,7 +575,7 @@ export function registerMonitorsCommands(program) {
573
575
  name,
574
576
  source: { type: 'poll', command: 'echo hello', interval: '1m' },
575
577
  condition: { mode: 'on-change' },
576
- action: { type: 'notify', notifyChannel: 'telegram' },
578
+ action: { type: 'notify' },
577
579
  });
578
580
  fs.writeFileSync(monitorPath, template, 'utf-8');
579
581
  console.log(chalk.gray(`Created new monitor file: ${monitorPath}`));
@@ -6,9 +6,22 @@
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
+ /** Map warehouse rows shaped like hook.fire into the existing HookProfileRow UI. */
18
+ export declare function asHookRows(rows: PerfAggregateRow[]): HookProfileRow[];
19
+ export declare function renderHookTable(rows: HookProfileRow[], warnMs: number): void;
20
+ /**
21
+ * Prefer SQLite samples; fall back to the legacy daily JSONL so existing
22
+ * instrumentation still surfaces until shims are resynced. `project` only
23
+ * narrows the SQLite path — the legacy JSONL log has no cwd, so a fallback
24
+ * hit ignores it (a caller filtering by project has no legacy rows to miss).
25
+ */
26
+ export declare function loadHookProfile(days: number, project?: string): HookProfileRow[];
14
27
  export declare function registerPerfCommand(program: Command): void;
@@ -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,69 @@ 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
+ function frictionAction(opts) {
179
+ const days = parseDays(opts.days);
180
+ const startDate = new Date(Date.now() - days * 86_400_000);
181
+ const events = query({ eventTypes: ['friction'], startDate });
182
+ const findings = detectRepeatedGuardBlocks(events);
183
+ if (opts.json) {
184
+ console.log(JSON.stringify(findings, null, 2));
185
+ return;
186
+ }
187
+ if (findings.length === 0) {
188
+ console.log(chalk.gray(`No repeated guard blocks in the last ${days} day${days === 1 ? '' : 's'}.`));
189
+ return;
190
+ }
191
+ console.log(chalk.bold(`Repeated guard blocks — last ${days} day${days === 1 ? '' : 's'}\n`));
192
+ const widths = [14, 10, 24, 5, 22, 22];
193
+ 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));
194
+ }
147
195
  function summaryAction(opts) {
148
196
  const days = parseDays(opts.days);
197
+ const project = opts.project;
149
198
  if (opts.json) {
150
199
  console.log(JSON.stringify({
151
200
  days,
201
+ project: project ?? null,
152
202
  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),
203
+ hooks: loadHookProfile(days, project),
204
+ commands: aggregateSamples({ days, kinds: ['command.end'], project }).slice(0, 20),
205
+ run: aggregateSamples({ days, kinds: ['perf.timing'], project }).slice(0, 20),
156
206
  }, null, 2));
157
207
  return;
158
208
  }
159
- console.log(chalk.bold(`agents perf — last ${days} day${days === 1 ? '' : 's'}`));
209
+ console.log(chalk.bold(`agents perf — last ${days} day${days === 1 ? '' : 's'}${project ? ` — project ${project}` : ''}`));
160
210
  console.log(chalk.gray(`warehouse: ${perfDbPath()} (disposable; soft-join sessions via session_id/agent/machine)`));
161
211
  console.log('');
162
212
  console.log(chalk.bold('Commands (slowest by p99)'));
163
- renderLabelTable('command', aggregateSamples({ days, kinds: ['command.end'] }), parseWarnMs(opts.warnMs, 500), 12);
213
+ renderLabelTable('command', aggregateSamples({ days, kinds: ['command.end'], project }), parseWarnMs(opts.warnMs, 500), 12);
164
214
  console.log('');
165
215
  console.log(chalk.bold('Hooks'));
166
- renderHookTable(loadHookProfile(days), parseWarnMs(opts.warnMs, DEFAULT_SLOW_HOOK_WARN_MS));
216
+ renderHookTable(loadHookProfile(days, project), parseWarnMs(opts.warnMs, DEFAULT_SLOW_HOOK_WARN_MS));
167
217
  console.log('');
168
218
  console.log(chalk.bold('Runs (perf.timing)'));
169
- renderLabelTable('run/timing', aggregateSamples({ days, kinds: ['perf.timing'] }), parseWarnMs(opts.warnMs, 60_000), 12);
219
+ renderLabelTable('run/timing', aggregateSamples({ days, kinds: ['perf.timing'], project }), parseWarnMs(opts.warnMs, 60_000), 12);
170
220
  }
171
221
  function attachSharedOptions(cmd) {
172
222
  return cmd
173
223
  .option('--days <n>', 'Days of samples to include', '7')
174
224
  .option('--warn-ms <n>', 'p99 above this is highlighted')
175
225
  .option('--limit <n>', 'Max rows in the table', '40')
226
+ .option('--project <key>', 'Scope to one project (the repo directory name a sample\'s cwd resolves to — see project-key.ts)')
176
227
  .option('--json', 'Emit JSON instead of a table');
177
228
  }
178
229
  /**
@@ -195,11 +246,13 @@ Identity columns reuse sessions/events string shapes (session_id, agent, machine
195
246
  for soft cross-reference; there are no foreign keys.
196
247
 
197
248
  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
249
+ agents perf # summary: commands + hooks + runs
250
+ agents perf hooks # per-hook p50/p95/p99 + cache hit rate
251
+ agents perf commands --days 30 # slowest CLI entrypoints
252
+ agents perf run --json # agent.run timings as JSON
202
253
  agents perf hooks --warn-ms 500
254
+ agents perf hooks --project agents-cli # scope to one repo's samples
255
+ agents perf friction # sessions stuck retrying the same guard block
203
256
  `);
204
257
  // Options live on the parent so `agents perf --json` and
205
258
  // `agents perf commands --json` both work (see leafOpts).
@@ -218,4 +271,8 @@ Examples:
218
271
  .action(function run() {
219
272
  runAction(leafOpts(this));
220
273
  });
274
+ perf.command('friction').description('Sessions stuck repeatedly hitting the same guard block')
275
+ .action(function friction() {
276
+ frictionAction(leafOpts(this));
277
+ });
221
278
  }
@@ -20,6 +20,8 @@ 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
+ /** Best-effort `owner/repo` from a repo's origin remote. */
24
+ export declare function originSlug(cwd: string): string | undefined;
23
25
  /** One `projects list` row, pre-render. */
24
26
  export interface ProjectListRow {
25
27
  name: string;
@@ -45,6 +47,15 @@ export declare function computeProjectListWidths(rows: ProjectListRow[]): {
45
47
  * read it as UTC and shift the answer by a day for anyone west of Greenwich.
46
48
  */
47
49
  export declare function formatMilestoneDue(targetDate: string, nowMs: number): string | undefined;
50
+ /**
51
+ * The milestone block. `status` prints one line — the next checkpoint — because
52
+ * a roll-up across projects has to stay scannable. `view` prints every declared
53
+ * milestone, because "how many are there and when are they due" is the shape of
54
+ * the plan and the reason to open one project.
55
+ *
56
+ * Pure (chalk only) so the layout is testable without a Linear account.
57
+ */
58
+ export declare function formatMilestoneLines(milestones: LinearMilestone[], next: LinearMilestone | undefined, nowMs: number, limit: number): string[];
48
59
  /** The `next` card line: what this project is due to hit, and how far along it is. */
49
60
  export declare function formatNextMilestone(ms: LinearMilestone, nowMs: number): string;
50
61
  export declare function registerProjectsCommands(program: Command): void;