@phnx-labs/agents-cli 1.20.91 → 1.20.93

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 (105) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/events.js +91 -1
  9. package/dist/commands/exec.d.ts +14 -0
  10. package/dist/commands/exec.js +144 -14
  11. package/dist/commands/projects.d.ts +22 -0
  12. package/dist/commands/projects.js +539 -0
  13. package/dist/commands/secrets.d.ts +17 -0
  14. package/dist/commands/secrets.js +198 -7
  15. package/dist/commands/send.d.ts +14 -12
  16. package/dist/commands/send.js +105 -35
  17. package/dist/commands/sessions-picker.d.ts +15 -0
  18. package/dist/commands/sessions-picker.js +37 -12
  19. package/dist/commands/sessions-resume.d.ts +2 -0
  20. package/dist/commands/sessions-resume.js +9 -1
  21. package/dist/commands/sessions.d.ts +10 -5
  22. package/dist/commands/sessions.js +65 -27
  23. package/dist/commands/sync.js +9 -3
  24. package/dist/commands/view.js +4 -0
  25. package/dist/index.js +18 -1
  26. package/dist/lib/activity.d.ts +77 -12
  27. package/dist/lib/activity.js +424 -74
  28. package/dist/lib/beta.d.ts +1 -1
  29. package/dist/lib/beta.js +1 -1
  30. package/dist/lib/channels/send.d.ts +83 -0
  31. package/dist/lib/channels/send.js +112 -0
  32. package/dist/lib/devices/registry.d.ts +14 -0
  33. package/dist/lib/devices/registry.js +37 -0
  34. package/dist/lib/events-ingest.d.ts +46 -0
  35. package/dist/lib/events-ingest.js +182 -0
  36. package/dist/lib/events.d.ts +15 -3
  37. package/dist/lib/events.js +55 -3
  38. package/dist/lib/feed-post.js +8 -2
  39. package/dist/lib/hosts/remote-cmd.js +4 -0
  40. package/dist/lib/linear-project-counts.d.ts +62 -0
  41. package/dist/lib/linear-project-counts.js +122 -0
  42. package/dist/lib/linear-projects.d.ts +50 -0
  43. package/dist/lib/linear-projects.js +114 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  46. package/dist/lib/menubar/install-menubar.js +20 -6
  47. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  48. package/dist/lib/menubar/notify-desktop.js +8 -2
  49. package/dist/lib/project-key.d.ts +44 -0
  50. package/dist/lib/project-key.js +79 -0
  51. package/dist/lib/project-probe.d.ts +75 -0
  52. package/dist/lib/project-probe.js +160 -0
  53. package/dist/lib/project-resources.d.ts +8 -0
  54. package/dist/lib/project-resources.js +31 -3
  55. package/dist/lib/project-root.js +16 -0
  56. package/dist/lib/project-status.d.ts +100 -0
  57. package/dist/lib/project-status.js +182 -0
  58. package/dist/lib/projects.d.ts +144 -0
  59. package/dist/lib/projects.js +313 -0
  60. package/dist/lib/remote-agents-json.d.ts +9 -0
  61. package/dist/lib/remote-agents-json.js +11 -5
  62. package/dist/lib/routine-notify.d.ts +11 -0
  63. package/dist/lib/routine-notify.js +22 -0
  64. package/dist/lib/run-notify.js +3 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/audit.d.ts +1 -1
  68. package/dist/lib/secrets/audit.js +53 -10
  69. package/dist/lib/secrets/list-filter.d.ts +20 -5
  70. package/dist/lib/secrets/list-filter.js +22 -6
  71. package/dist/lib/secrets/usage-db.d.ts +106 -0
  72. package/dist/lib/secrets/usage-db.js +236 -0
  73. package/dist/lib/session/bash-command.d.ts +53 -0
  74. package/dist/lib/session/bash-command.js +364 -0
  75. package/dist/lib/session/digest.d.ts +6 -0
  76. package/dist/lib/session/digest.js +19 -0
  77. package/dist/lib/session/relative-time.d.ts +23 -0
  78. package/dist/lib/session/relative-time.js +60 -8
  79. package/dist/lib/session/remote-active.d.ts +5 -1
  80. package/dist/lib/session/remote-active.js +4 -1
  81. package/dist/lib/session/remote-list.js +5 -2
  82. package/dist/lib/session/render.d.ts +2 -9
  83. package/dist/lib/session/render.js +25 -56
  84. package/dist/lib/sqlite.js +28 -1
  85. package/dist/lib/ssh-exec.d.ts +6 -0
  86. package/dist/lib/ssh-exec.js +10 -1
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/state.d.ts +14 -0
  90. package/dist/lib/state.js +19 -0
  91. package/dist/lib/terminal/backends/index.d.ts +10 -2
  92. package/dist/lib/terminal/backends/index.js +14 -2
  93. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  94. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  95. package/dist/lib/terminal/index.d.ts +2 -1
  96. package/dist/lib/terminal/index.js +2 -1
  97. package/dist/lib/terminal/preferred.d.ts +89 -0
  98. package/dist/lib/terminal/preferred.js +87 -0
  99. package/dist/lib/terminal/run-surface.d.ts +82 -0
  100. package/dist/lib/terminal/run-surface.js +146 -0
  101. package/dist/lib/terminal/types.d.ts +1 -1
  102. package/dist/lib/types.d.ts +6 -5
  103. package/dist/lib/versions.d.ts +6 -0
  104. package/dist/lib/versions.js +6 -4
  105. package/package.json +2 -1
@@ -1,13 +1,37 @@
1
1
  import chalk from 'chalk';
2
- import { collapseActivity, enrichActivityEvents, ensureActivityLogHook, filterActivityEvents, formatActivityGroupHeader, formatEnrichedActivityLine, formatProgressUpdate, groupActivity, mergeActivityEvents, parseActivityPayload, projectFromCwd, readRecentActivity, styleForEvent, tierForEvent, } from '../lib/activity.js';
2
+ import { ACTIVITY_HOOK_DEFINITIONS, collapseActivity, enrichActivityEvents, capActivityEvents, ensureActivityLogHook, filterActivityByProject, filterActivityEvents, formatActivityGroupMeta, formatEnrichedActivityLine, formatProgressUpdate, groupActivity, mergeActivityEvents, parseActivityPayload, readRecentActivity, styleForEvent, tierForEvent, } from '../lib/activity.js';
3
3
  import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
4
+ import { resolveProjectKey } from '../lib/project-key.js';
5
+ import { listProjectDefs, projectNameForCwd, resolveProjectNameForCwd } from '../lib/projects.js';
4
6
  import { machineId, normalizeHost } from '../lib/machine-id.js';
5
7
  import { shouldIncludeLocalFeed, remoteFeedHostsToDial } from './feed.js';
6
8
  import { getActiveSessions } from '../lib/session/active.js';
7
9
  /** Recursion guard: a peer answering a fan-out never re-fans-out itself. */
8
10
  export const ACTIVITY_NO_FANOUT_ENV = 'AGENTS_ACTIVITY_LOCAL';
11
+ /** `--group-by` values; `none` is the opt-out that `--flat` also selects. */
9
12
  const GROUP_BY_VALUES = ['project', 'device', 'agent'];
10
- /** Wire the activity-log hooks into each hooks-capable version's settings. */
13
+ const GROUP_BY_NONE = 'none';
14
+ /** Grouping when neither `--group-by` nor `--flat` is given. */
15
+ const DEFAULT_GROUP_BY = 'project';
16
+ /**
17
+ * How much further back than `--limit` the LOCAL log read goes, so routine
18
+ * churn on this box can't crowd the fleet's milestones out of the window
19
+ * ({@link capActivityEvents} does the real capping).
20
+ */
21
+ const LOCAL_READ_HEADROOM = 5;
22
+ /**
23
+ * Wire the activity-log hooks into each hooks-capable version's settings.
24
+ *
25
+ * Only a failure that leaves THIS command's log unwritten is worth a warning.
26
+ * `registerHooksToSettings` reports every unresolved hook in the manifest — a
27
+ * missing `inject-session-id` script, someone else's half-installed plugin —
28
+ * and echoing all of them printed five wrapped lines of unrelated noise above
29
+ * the timeline on every run. Those belong to `agents doctor`, which exists to
30
+ * find them; here they are dropped. What still surfaces: an activity-hook
31
+ * failure, and any error attributable to NO manifest hook — a corrupt
32
+ * settings.json or an unwritable hooks file aborts registration for the whole
33
+ * agent, which is exactly a failure that leaves the activity log unwritten.
34
+ */
11
35
  async function installActivityHooks() {
12
36
  const warnings = [];
13
37
  const hookInstall = ensureActivityLogHook();
@@ -20,22 +44,45 @@ async function installActivityHooks() {
20
44
  import('../lib/versions.js'),
21
45
  ]);
22
46
  const manifest = parseHookManifest({ warn: false });
47
+ const manifestNames = Object.keys(manifest);
23
48
  for (const { agent, version } of iterHooksCapableVersions({ agent: 'claude' })) {
24
49
  const result = registerHooksToSettings(agent, getVersionHomePath(agent, version), manifest);
25
- if (result.errors.length > 0)
26
- warnings.push(`${agent}@${version}: ${result.errors.join('; ')}`);
50
+ const ours = result.errors.filter((e) => isActivityHookError(e) || !isManifestHookError(e, manifestNames));
51
+ if (ours.length > 0)
52
+ warnings.push(`${agent}@${version}: ${ours.join('; ')}`);
27
53
  }
28
54
  return warnings;
29
55
  }
30
- /** Session facts (project / ticket / execution host) keyed for the read-time join. */
31
- function activityHintsFromSessions(sessions) {
56
+ /**
57
+ * Does this hook-registration error concern one of the activity-log hooks?
58
+ * `registerHooksToSettings` prefixes each error with the manifest hook name
59
+ * (`<name>: script not found`), so the entry names are the discriminator.
60
+ */
61
+ export function isActivityHookError(error) {
62
+ return Object.keys(ACTIVITY_HOOK_DEFINITIONS).some((name) => error.startsWith(`${name}:`));
63
+ }
64
+ /**
65
+ * Is this error attributable to a named manifest hook at all? Errors that name
66
+ * no hook — `Failed to parse settings.json`, `Failed to write
67
+ * agents-cli-hooks.ts: …` — are agent-level aborts, not per-hook noise, and
68
+ * must never be filtered out.
69
+ */
70
+ export function isManifestHookError(error, manifestNames) {
71
+ return manifestNames.some((name) => error.startsWith(`${name}:`));
72
+ }
73
+ /**
74
+ * Session facts (project / ticket / execution host) keyed for the read-time
75
+ * join. These sessions are this machine's, so their cwds get the canonical
76
+ * defined-project-first resolution rather than the bare cwd fold.
77
+ */
78
+ function activityHintsFromSessions(sessions, resolveProject = resolveProjectKey) {
32
79
  return sessions.map((s) => ({
33
80
  sessionId: s.sessionId,
34
81
  ticket: s.ticket?.id,
35
82
  // Where the process actually lives — the SSH-resolved provenance host, else
36
83
  // the cross-machine `machine` id. Normalized to match the event `host` form.
37
84
  executionHost: s.provenance?.host ? normalizeHost(s.provenance.host) : s.machine,
38
- project: projectFromCwd(s.cwd),
85
+ project: resolveProject(s.cwd),
39
86
  }));
40
87
  }
41
88
  /**
@@ -70,6 +117,23 @@ function renderFlat(events, opts) {
70
117
  }
71
118
  process.stdout.write('\n');
72
119
  }
120
+ /** Max peers named in the unreachable note before the rest collapse to `+N`. */
121
+ const UNREACHABLE_NAME_LIMIT = 4;
122
+ /**
123
+ * One compact trailing note for peers that didn't answer the fan-out. Now that
124
+ * every run dials the fleet, an offline box is routine -- but never silent: the
125
+ * reader has to know the timeline is missing a machine, not that the machine
126
+ * was idle. Empty string when everything answered.
127
+ */
128
+ export function formatUnreachableNote(skipped) {
129
+ if (skipped.length === 0)
130
+ return '';
131
+ const named = skipped.slice(0, UNREACHABLE_NAME_LIMIT);
132
+ const rest = skipped.length - named.length;
133
+ const list = rest > 0 ? `${named.join(', ')} +${rest}` : named.join(', ');
134
+ const noun = skipped.length === 1 ? 'device' : 'devices';
135
+ return chalk.gray(` · ${skipped.length} ${noun} unreachable: ${list}\n\n`);
136
+ }
73
137
  /** Print the bucketed view (`--group-by project|device|agent`). */
74
138
  function renderGrouped(events, by, opts) {
75
139
  const groups = groupActivity(events, by);
@@ -78,7 +142,11 @@ function renderGrouped(events, by, opts) {
78
142
  const showProject = by !== 'project';
79
143
  process.stdout.write(chalk.bold(`\n activity by ${by}\n`));
80
144
  for (const group of groups) {
81
- process.stdout.write(`\n ${chalk.bold(formatActivityGroupHeader(group))}\n`);
145
+ // `▸ <label> <meta>` -- label leads in cyan so projects scan down the
146
+ // left edge (mirrors the `agents sessions` overview), the counts and the
147
+ // machines that touched this project trail behind it in gray.
148
+ const meta = formatActivityGroupMeta(group, { showDevices: by !== 'device' });
149
+ process.stdout.write(`\n ${chalk.cyan('▸')} ${chalk.cyan.bold(group.label)} ${chalk.gray(meta)}\n`);
82
150
  const { milestones, counts, subagentCount } = collapseActivity(group.events);
83
151
  const shown = opts.all ? group.events : milestones;
84
152
  for (const ev of shown) {
@@ -97,89 +165,168 @@ function renderGrouped(events, by, opts) {
97
165
  }
98
166
  process.stdout.write('\n');
99
167
  }
168
+ /**
169
+ * Resolve `--group-by` / `--flat` into the grouping dimension, or `undefined`
170
+ * for a flat stream. Throws on an unknown value so the caller can exit 1 --
171
+ * a mistyped dimension must not silently fall back to the default.
172
+ */
173
+ export function resolveActivityGrouping(opts) {
174
+ if (opts.flat)
175
+ return undefined;
176
+ if (opts.groupBy === undefined)
177
+ return DEFAULT_GROUP_BY;
178
+ if (opts.groupBy === GROUP_BY_NONE)
179
+ return undefined;
180
+ if (!GROUP_BY_VALUES.includes(opts.groupBy)) {
181
+ throw new Error(`--group-by must be one of: ${[...GROUP_BY_VALUES, GROUP_BY_NONE].join(', ')}`);
182
+ }
183
+ return opts.groupBy;
184
+ }
185
+ /**
186
+ * Decide the read scope. Fleet-wide is the DEFAULT -- agents run on every box,
187
+ * so "what are my agents doing" is a fleet question -- and three things narrow
188
+ * it: `--local`, an explicit `--host`/`--device` list, and the recursion guard
189
+ * a peer answering a fan-out carries (else every peer would re-fan the fleet).
190
+ * `--devices-all`/`--hosts-all` are kept as explicit no-ops for scripts that
191
+ * already pass them.
192
+ */
193
+ export function resolveActivityScope(opts, self, noFanoutEnv = process.env[ACTIVITY_NO_FANOUT_ENV] === '1') {
194
+ if (opts.local === true || noFanoutEnv)
195
+ return { includeLocal: true, wantRemote: false };
196
+ const explicitHosts = opts.host;
197
+ if (explicitHosts && explicitHosts.length > 0) {
198
+ const remoteHosts = remoteFeedHostsToDial(explicitHosts, self) ?? [];
199
+ return {
200
+ includeLocal: shouldIncludeLocalFeed(explicitHosts, self),
201
+ wantRemote: remoteHosts.length > 0,
202
+ remoteHosts,
203
+ };
204
+ }
205
+ return { includeLocal: true, wantRemote: true };
206
+ }
100
207
  export function registerActivityCommand(program) {
101
208
  program
102
209
  .command('activity')
103
- .description('Recent agent activity -- plans, PRs, worktrees, sub-agents (newest first)')
210
+ .description('Recent agent activity across the fleet -- plans, PRs, worktrees, sub-agents, by project')
104
211
  .option('--json', 'Emit the (enriched) event list as JSON')
105
212
  .option('--all', 'Include routine activity (file edits) inline, not collapsed')
106
213
  .option('--milestones', 'Only milestone events (plans, PRs, worktrees, sub-agents)')
107
- .option('-n, --limit <n>', 'Cap the number of events shown', (v) => parseInt(v, 10), 40)
214
+ .option('-n, --limit <n>', 'Cap the milestones shown (routine work rides along as counts); with --all, caps every event', (v) => parseInt(v, 10), 40)
108
215
  .option('--since <minutes>', 'Only events within the last N minutes', (v) => parseInt(v, 10))
109
216
  .option('--local', 'Only this machine -- skip the cross-machine SSH fan-out')
110
217
  .option('-H, --host <target...>', 'Scope to remote machine(s) over SSH; repeatable')
111
218
  .option('--device <target...>', 'Alias for --host; repeatable')
112
- .option('--devices-all', 'Fan out to every reachable device on the fleet')
219
+ .option('--devices-all', 'Fan out to every reachable device (the default; kept for scripts)')
113
220
  .option('--hosts-all', 'Alias for --devices-all')
114
- .option('--group-by <field>', 'Bucket the stream by project | device | agent')
221
+ .option('--group-by <field>', 'Bucket the stream by project | device | agent | none (default: project)')
222
+ .option('--flat', 'One newest-first stream instead of project buckets')
223
+ .option('--project <name>', 'Only items under one project; exact match on the resolved (defined-project-first) label')
115
224
  .option('--filter <text>', 'Narrow to items matching a project / device / agent / event / ticket')
116
225
  .addHelpText('after', `
117
226
  Examples:
118
- agents activity # this machine, newest first
119
- agents activity --devices-all --group-by project # per project across the fleet
227
+ agents activity # the whole fleet, by project
228
+ agents activity --local # just this machine
229
+ agents activity --flat # one newest-first stream
120
230
  agents activity --host yosemite-s1 # one box over SSH
121
- agents activity --devices-all --filter RUSH-2100 # one ticket, fleet-wide
122
- agents activity --milestones # only plans / PRs / worktrees / sub-agents
231
+ agents activity --project rush # one defined project, fleet-wide
232
+ agents activity --filter RUSH-2100 # one ticket, fleet-wide
233
+ agents activity --group-by device # by machine instead of project
123
234
 
124
- Each item is enriched by JOINING to live sessions (project, execution host,
125
- Linear ticket) -- never by re-parsing transcripts. --devices-all runs the same
126
- 'activity --json' on each peer and merges the streams host-tagged.
235
+ Fleet-wide and project-grouped by default: the same 'activity --json' runs on
236
+ every reachable peer and the streams merge host-tagged. Each item is enriched by
237
+ JOINING to live sessions (project, execution host, Linear ticket) -- never by
238
+ re-parsing transcripts. Project labels are canonical: a cwd inside a defined
239
+ project (~/.agents/projects/<name>.yaml) reads as that project's name, so a
240
+ multi-repo project is one bucket; anything else folds to its repository. Each
241
+ project header names the machines its work ran on.
127
242
  `)
128
243
  .action(async (opts) => {
129
244
  if (opts.device?.length)
130
245
  opts.host = [...(opts.host ?? []), ...opts.device];
131
- const groupBy = opts.groupBy;
132
- if (groupBy && !GROUP_BY_VALUES.includes(groupBy)) {
133
- process.stderr.write(chalk.red(`--group-by must be one of: ${GROUP_BY_VALUES.join(', ')}\n`));
246
+ let groupBy;
247
+ try {
248
+ groupBy = resolveActivityGrouping(opts);
249
+ }
250
+ catch (err) {
251
+ process.stderr.write(chalk.red(`${err.message}\n`));
134
252
  process.exitCode = 1;
135
253
  return;
136
254
  }
137
- const self = machineId();
138
- const forceLocal = opts.local === true || process.env[ACTIVITY_NO_FANOUT_ENV] === '1';
139
- const explicitHosts = opts.host;
140
- const wantAll = Boolean(opts.devicesAll || opts.hostsAll);
141
- const wantRemote = !forceLocal && (wantAll || (explicitHosts != null && explicitHosts.length > 0));
142
- // Local events are included unless an explicit --host list excludes this box.
143
- let includeLocal = true;
144
- if (wantRemote && explicitHosts && explicitHosts.length > 0) {
145
- includeLocal = shouldIncludeLocalFeed(explicitHosts, self);
255
+ // `parseInt` yields NaN for a non-numeric -n, and every downstream slice
256
+ // against NaN is empty an unreadable flag would otherwise render as
257
+ // "No recent agent activity", which is a lie about the fleet.
258
+ if (!Number.isFinite(opts.limit) || opts.limit <= 0) {
259
+ process.stderr.write(chalk.red('--limit must be a positive number\n'));
260
+ process.exitCode = 1;
261
+ return;
146
262
  }
263
+ const self = machineId();
264
+ const { includeLocal, wantRemote, remoteHosts } = resolveActivityScope(opts, self);
147
265
  const warnings = includeLocal ? await installActivityHooks() : [];
148
266
  const sinceMs = typeof opts.since === 'number' && opts.since > 0
149
267
  ? Date.now() - opts.since * 60_000
150
268
  : undefined;
269
+ // Each peer contributes its own newest `-n` window, so the merged pool is
270
+ // already several times the cap. The local read is the one source that
271
+ // would otherwise be capped twice, and reading further back costs nothing
272
+ // (the files are tailed either way) — so it gets the headroom.
151
273
  let events = includeLocal
152
- ? readRecentActivity({ sinceMs, limit: opts.limit })
274
+ ? readRecentActivity({ sinceMs, limit: opts.limit * LOCAL_READ_HEADROOM })
153
275
  : [];
276
+ // Peers that were dialed but never answered, reported once at the end
277
+ // rather than as a line each above the timeline.
278
+ let unreachable = [];
154
279
  if (wantRemote) {
155
- // Explicit hosts drop self (it's the local read); --devices-all dials
156
- // every online peer (hosts undefined).
157
- const remoteHosts = explicitHosts?.length ? remoteFeedHostsToDial(explicitHosts, self) : undefined;
158
- if (!explicitHosts?.length || (remoteHosts && remoteHosts.length > 0)) {
159
- const remoteArgs = ['activity', '--json', '-n', String(opts.limit)];
160
- if (typeof opts.since === 'number' && opts.since > 0)
161
- remoteArgs.push('--since', String(opts.since));
162
- const remote = await gatherRemoteAgentsJson({
163
- args: remoteArgs,
164
- noFanoutEnv: ACTIVITY_NO_FANOUT_ENV,
165
- hosts: remoteHosts,
166
- parse: parseActivityPayload,
167
- });
168
- events = mergeActivityEvents(events, remote.items);
169
- }
280
+ const remoteArgs = ['activity', '--json', '-n', String(opts.limit)];
281
+ if (typeof opts.since === 'number' && opts.since > 0)
282
+ remoteArgs.push('--since', String(opts.since));
283
+ const remote = await gatherRemoteAgentsJson({
284
+ args: remoteArgs,
285
+ noFanoutEnv: ACTIVITY_NO_FANOUT_ENV,
286
+ hosts: remoteHosts?.length ? remoteHosts : undefined,
287
+ parse: parseActivityPayload,
288
+ quiet: true,
289
+ });
290
+ events = mergeActivityEvents(events, remote.items);
291
+ unreachable = remote.skipped;
170
292
  }
171
293
  // Enrich by joining to live LOCAL sessions (ticket/project/execution host).
172
294
  // Remote events arrive already enriched by their own peer, so the join only
173
295
  // fills local ones; the pure fallbacks (cwd->project, host->device) cover
174
296
  // everything either way. Skip the ps/lsof scan when there's nothing to show.
297
+ // Project labels are canonical: a defined project's name wins over the
298
+ // repo-level key — including over a stale or def-skewed pre-baked stamp
299
+ // (the def match is a pure prefix compare; foreign paths just don't match),
300
+ // so a multi-repo project buckets as one even across version skew.
301
+ const projectDefs = listProjectDefs();
302
+ const canonicalProject = (cwd) => projectNameForCwd(cwd ?? undefined, projectDefs);
303
+ const resolveProject = (cwd) => resolveProjectNameForCwd(cwd, projectDefs);
175
304
  const sessions = includeLocal && events.length > 0 ? await getActiveSessions() : [];
176
- let enriched = enrichActivityEvents(events, activityHintsFromSessions(sessions));
305
+ let enriched = enrichActivityEvents(events, activityHintsFromSessions(sessions, resolveProject), resolveProject, canonicalProject);
177
306
  if (opts.milestones)
178
307
  enriched = enriched.filter((e) => tierForEvent(e.event) === 'milestone');
308
+ if (opts.project) {
309
+ const unfiltered = enriched;
310
+ enriched = filterActivityByProject(enriched, opts.project);
311
+ // An exact-name flag that matches nothing deserves a useful answer, not
312
+ // a generic "no activity" that lies about the fleet.
313
+ if (enriched.length === 0 && unfiltered.length > 0) {
314
+ const seen = [...new Set(unfiltered.map((e) => e.project).filter((p) => !!p))].sort();
315
+ const defined = projectDefs.map((d) => d.name);
316
+ const parts = [
317
+ seen.length ? `seen: ${seen.join(', ')}` : '',
318
+ defined.length ? `defined: ${defined.join(', ')}` : '',
319
+ ].filter(Boolean);
320
+ process.stderr.write(chalk.gray(` · no events for project "${opts.project}" (exact match)${parts.length ? ` — ${parts.join(' · ')}` : ''}\n`));
321
+ }
322
+ }
179
323
  if (opts.filter)
180
324
  enriched = filterActivityEvents(enriched, opts.filter);
181
- enriched = enriched.slice(0, opts.limit);
325
+ enriched = capActivityEvents(enriched, opts.limit, { all: opts.all || opts.milestones });
182
326
  if (opts.json) {
327
+ // stdout stays clean JSON, but an unanswered peer is never a silent drop.
328
+ if (unreachable.length > 0)
329
+ process.stderr.write(formatUnreachableNote(unreachable));
183
330
  process.stdout.write(`${JSON.stringify(enriched, null, 2)}\n`);
184
331
  return;
185
332
  }
@@ -187,11 +334,13 @@ Linear ticket) -- never by re-parsing transcripts. --devices-all runs the same
187
334
  process.stderr.write(chalk.yellow(` ! ${w}\n`));
188
335
  if (enriched.length === 0) {
189
336
  process.stdout.write(chalk.gray('No recent agent activity. Events appear here as agents create plans, open PRs, and spawn sub-agents.\n'));
337
+ process.stdout.write(formatUnreachableNote(unreachable));
190
338
  return;
191
339
  }
192
340
  if (groupBy)
193
341
  renderGrouped(enriched, groupBy, opts);
194
342
  else
195
343
  renderFlat(enriched, opts);
344
+ process.stdout.write(formatUnreachableNote(unreachable));
196
345
  });
197
346
  }
@@ -4,6 +4,7 @@ const BETA_DESCRIPTIONS = {
4
4
  drive: 'Google Drive integration for reading and writing files',
5
5
  factory: 'Cloud-based agent dispatch via Rush Factory',
6
6
  'session-sync': 'Cross-machine session transcript sync via R2 (daemon push/pull)',
7
+ projects: 'Named multi-repo projects with a progress rollup (agents projects)',
7
8
  };
8
9
  function parseFeatures(values) {
9
10
  const valid = new Set(ALL_BETA_FEATURES);
@@ -12,7 +12,7 @@ import { getCliVersion } from '../lib/version.js';
12
12
  import { resolveHost } from '../lib/hosts/registry.js';
13
13
  import { sshExecAsync } from '../lib/ssh-exec.js';
14
14
  import { sshTargetFor } from '../lib/hosts/types.js';
15
- import { machineId } from '../lib/session/sync/config.js';
15
+ import { machineId, normalizeHost } from '../lib/session/sync/config.js';
16
16
  import chalk from 'chalk';
17
17
  import { checkAllClis, collectTeamsDoctorData } from '../lib/teams/agents.js';
18
18
  import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError, getAccountInfo } from '../lib/agents.js';
@@ -101,7 +101,9 @@ async function resolveFleetTargets(opts) {
101
101
  const registry = await loadDevices();
102
102
  const localName = machineId();
103
103
  return Object.values(registry)
104
- .filter((d) => d.name.toLowerCase() !== localName)
104
+ // Normalize names so zion/ZION/zion.local all match machineId() and we never
105
+ // self-SSH the local box during fleet probes (RUSH-2114).
106
+ .filter((d) => normalizeHost(d.name) !== localName)
105
107
  // Control devices (a cockpit) never run agents — skip them in the fleet
106
108
  // fan-out (an explicit --device <name> still resolves above).
107
109
  .filter((d) => !isControlDevice(d))
@@ -17,6 +17,8 @@ import chalk from 'chalk';
17
17
  import * as fs from 'fs';
18
18
  import { getLogsPath } from '../lib/events.js';
19
19
  import { readUnifiedEvents } from '../lib/event-stream.js';
20
+ import { ingestBatch } from '../lib/events-ingest.js';
21
+ import { setHelpSections } from '../lib/help.js';
20
22
  /**
21
23
  * Resolve `--limit` into a record cap. `0` means "no cap" — without it there is
22
24
  * no way to read the whole stream, and any aggregation (group-by failure, count
@@ -97,8 +99,93 @@ function renderRow(r) {
97
99
  const agent = r.agent ? chalk.gray(` ${r.agent}`) : '';
98
100
  return `${time} ${originLabel(r).padEnd(24)} ${user.padEnd(22)} ${ev.padEnd(26)}${agent} ${detailFor(r)}`;
99
101
  }
102
+ /** Read all of stdin. Returns '' when nothing is piped. */
103
+ async function readStdin() {
104
+ if (process.stdin.isTTY)
105
+ return '';
106
+ const chunks = [];
107
+ for await (const chunk of process.stdin)
108
+ chunks.push(Buffer.from(chunk));
109
+ return Buffer.concat(chunks).toString('utf-8');
110
+ }
111
+ function registerEmitSubcommand(events) {
112
+ const emitCmd = events
113
+ .command('emit')
114
+ .description('Record events produced outside this process (reads JSONL on stdin)')
115
+ .requiredOption('--source <name>', 'Producer name; stamped as `module` so `events --module <name>` filters to it')
116
+ .option('--dry-run', 'Validate and report without writing')
117
+ .option('--json', 'Report {written, rejected} as JSON')
118
+ .action(async (opts, cmd) => {
119
+ // The parent `events` command also declares `--json` (for its read view).
120
+ // Commander binds a flag declared on BOTH to the parent, so a bare
121
+ // `events emit … --json` lands on parent.opts().json and the child sees
122
+ // undefined — the human string would print where a caller expects JSON.
123
+ // Read both, same as `feed post`.
124
+ const wantJson = Boolean(opts?.json ?? cmd?.parent?.opts?.().json);
125
+ const input = await readStdin();
126
+ if (input.trim() === '') {
127
+ console.error('events emit: nothing on stdin — pipe one JSON object per line.');
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ let result;
132
+ try {
133
+ result = ingestBatch(input, { source: opts.source, dryRun: opts.dryRun });
134
+ }
135
+ catch (err) {
136
+ console.error(`events emit: ${err instanceof Error ? err.message : String(err)}`);
137
+ process.exitCode = 1;
138
+ return;
139
+ }
140
+ if (wantJson) {
141
+ console.log(JSON.stringify({ written: result.written, rejected: result.rejected, routed: result.routed }, null, 2));
142
+ }
143
+ else if (result.rejected.length === 0) {
144
+ console.log(`${result.written} event(s) recorded${opts.dryRun ? ' (dry run)' : ''}.`);
145
+ }
146
+ else {
147
+ console.log(`${result.written} event(s) recorded${opts.dryRun ? ' (dry run)' : ''}.`);
148
+ console.error(`rejected ${result.rejected.length} line(s):`);
149
+ for (const r of result.rejected)
150
+ console.error(` line ${r.line}: ${r.reason}`);
151
+ }
152
+ // Valid siblings are already written; a non-zero exit reports that the
153
+ // batch was not fully accepted without pretending nothing landed.
154
+ if (result.rejected.length > 0)
155
+ process.exitCode = 1;
156
+ });
157
+ setHelpSections(emitCmd, {
158
+ examples: `
159
+ # 1. Produce one JSON object per line (this is the whole input format).
160
+ printf '%s\\n' '{"event":"factory.command","commandId":"agents.newClaude"}' \\
161
+ | agents events emit --source factory
162
+
163
+ # 2. Flush a batch and check what landed.
164
+ agents events emit --source factory --json < batch.jsonl
165
+
166
+ # 3. Read it back. --limit 0 matters whenever you aggregate.
167
+ agents events --module factory --limit 0 --json
168
+ `,
169
+ notes: `
170
+ \`event\` is required and must be a known kind. \`ts\` (ISO-8601) is optional
171
+ and defaults to now — pass it so a batched producer records when each event
172
+ HAPPENED rather than when it flushed.
173
+
174
+ Envelope keys: ts, sessionId, mailboxId, terminalId, launchId, tmuxPane,
175
+ host, runtime, agent, tool, detail, url, project, cwd. Any other key is
176
+ payload and passes through the usual redaction.
177
+
178
+ A milestone kind (e.g. factory.launch) REQUIRES a sessionId — the activity
179
+ log is one file per session. Milestones route there; everything else goes
180
+ to the operational log. \`agents events\` reads both.
181
+
182
+ Rejection is per line: one bad line never discards the batch. Exit is 1 if
183
+ any line was rejected.
184
+ `,
185
+ });
186
+ }
100
187
  export function registerEventsCommand(program) {
101
- program
188
+ const events = program
102
189
  .command('events')
103
190
  .description('Read the unified event stream (operational + agent activity)')
104
191
  .option('--module <name>', 'Only events from this group (e.g. teams, secrets, activity)')
@@ -166,6 +253,9 @@ Examples:
166
253
  if (truncated)
167
254
  console.log(chalk.yellow(capNote));
168
255
  });
256
+ // `events` both reads (its own action, above) and writes (this subcommand) —
257
+ // the same shape as `feed` / `feed post`.
258
+ registerEmitSubcommand(events);
169
259
  }
170
260
  /** commander repeatable-option collector. */
171
261
  function collect(value, previous) {
@@ -16,6 +16,20 @@ export interface RunAccountPickerRequest {
16
16
  /** Distinguish a terminal account-picker marker from an explicit @version pin. */
17
17
  export declare function parseRunAccountPickerRequest(agentSpec: string): RunAccountPickerRequest;
18
18
  /** Return every option whose routing semantics conflict with a local account choice. */
19
+ /**
20
+ * The `--host` alias family — the flags that mean "dispatch this run to another
21
+ * machine over SSH". `--host` is canonical; `--device`/`--on`/`--computer` are
22
+ * aliases. Returns the values actually given (so callers can both test presence
23
+ * and read the target). Kept in ONE place because a guard that listed only a
24
+ * subset silently let `--terminal --device` open a local tab and drop the remote
25
+ * target — the drift this predicate exists to prevent.
26
+ */
27
+ export declare function hostTargetGiven(options: {
28
+ host?: string;
29
+ device?: string;
30
+ on?: string;
31
+ computer?: string;
32
+ }): string[];
19
33
  export declare function runAccountPickerConflicts(options: {
20
34
  resume?: string | boolean;
21
35
  strategy?: string;