@phnx-labs/agents-cli 1.20.90 → 1.20.92

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 (112) 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/exec.d.ts +14 -0
  9. package/dist/commands/exec.js +144 -14
  10. package/dist/commands/feed.js +77 -4
  11. package/dist/commands/hooks.js +22 -6
  12. package/dist/commands/perf.d.ts +14 -0
  13. package/dist/commands/perf.js +221 -0
  14. package/dist/commands/projects.d.ts +12 -0
  15. package/dist/commands/projects.js +358 -0
  16. package/dist/commands/routines.js +30 -24
  17. package/dist/commands/secrets.d.ts +43 -4
  18. package/dist/commands/secrets.js +217 -32
  19. package/dist/commands/send.d.ts +5 -1
  20. package/dist/commands/send.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +15 -0
  22. package/dist/commands/sessions-picker.js +107 -13
  23. package/dist/commands/sessions-resume.d.ts +2 -0
  24. package/dist/commands/sessions-resume.js +9 -1
  25. package/dist/commands/sessions.d.ts +10 -5
  26. package/dist/commands/sessions.js +65 -27
  27. package/dist/index.js +19 -3
  28. package/dist/lib/activity.d.ts +80 -13
  29. package/dist/lib/activity.js +418 -74
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/catchup.d.ts +105 -0
  33. package/dist/lib/catchup.js +160 -0
  34. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  35. package/dist/lib/channels/providers/desktop.js +132 -0
  36. package/dist/lib/channels/providers/index.js +2 -0
  37. package/dist/lib/daemon.js +74 -13
  38. package/dist/lib/devices/registry.d.ts +14 -0
  39. package/dist/lib/devices/registry.js +37 -0
  40. package/dist/lib/events.d.ts +12 -0
  41. package/dist/lib/events.js +122 -9
  42. package/dist/lib/exec.js +10 -0
  43. package/dist/lib/feed-broadcast.d.ts +47 -0
  44. package/dist/lib/feed-broadcast.js +65 -1
  45. package/dist/lib/feed-post.d.ts +10 -0
  46. package/dist/lib/feed-post.js +9 -3
  47. package/dist/lib/feed.d.ts +47 -1
  48. package/dist/lib/feed.js +38 -0
  49. package/dist/lib/hooks/cache.d.ts +2 -0
  50. package/dist/lib/hooks/cache.js +24 -4
  51. package/dist/lib/hosts/remote-cmd.js +4 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  54. package/dist/lib/menubar/install-menubar.js +20 -6
  55. package/dist/lib/overdue.d.ts +14 -0
  56. package/dist/lib/overdue.js +37 -1
  57. package/dist/lib/perf/db.d.ts +25 -0
  58. package/dist/lib/perf/db.js +290 -0
  59. package/dist/lib/perf/spool.d.ts +18 -0
  60. package/dist/lib/perf/spool.js +79 -0
  61. package/dist/lib/perf/types.d.ts +45 -0
  62. package/dist/lib/perf/types.js +2 -0
  63. package/dist/lib/project-key.d.ts +44 -0
  64. package/dist/lib/project-key.js +79 -0
  65. package/dist/lib/project-root.js +16 -0
  66. package/dist/lib/project-status.d.ts +69 -0
  67. package/dist/lib/project-status.js +101 -0
  68. package/dist/lib/projects.d.ts +138 -0
  69. package/dist/lib/projects.js +301 -0
  70. package/dist/lib/remote-agents-json.d.ts +9 -0
  71. package/dist/lib/remote-agents-json.js +11 -5
  72. package/dist/lib/routines-project.js +6 -0
  73. package/dist/lib/routines.d.ts +30 -1
  74. package/dist/lib/routines.js +11 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/list-filter.d.ts +94 -0
  78. package/dist/lib/secrets/list-filter.js +245 -0
  79. package/dist/lib/session/bash-command.d.ts +53 -0
  80. package/dist/lib/session/bash-command.js +364 -0
  81. package/dist/lib/session/digest.d.ts +13 -0
  82. package/dist/lib/session/digest.js +48 -1
  83. package/dist/lib/session/discover.d.ts +1 -2
  84. package/dist/lib/session/discover.js +7 -24
  85. package/dist/lib/session/highlights.d.ts +82 -0
  86. package/dist/lib/session/highlights.js +251 -0
  87. package/dist/lib/session/parse.js +23 -1
  88. package/dist/lib/session/relative-time.d.ts +37 -0
  89. package/dist/lib/session/relative-time.js +96 -8
  90. package/dist/lib/session/remote-list.js +5 -2
  91. package/dist/lib/session/render.d.ts +9 -9
  92. package/dist/lib/session/render.js +112 -73
  93. package/dist/lib/session/types.d.ts +4 -1
  94. package/dist/lib/ssh-exec.d.ts +6 -0
  95. package/dist/lib/ssh-exec.js +10 -1
  96. package/dist/lib/startup/command-registry.d.ts +2 -0
  97. package/dist/lib/startup/command-registry.js +4 -0
  98. package/dist/lib/state.d.ts +11 -0
  99. package/dist/lib/state.js +16 -0
  100. package/dist/lib/terminal/backends/index.d.ts +10 -2
  101. package/dist/lib/terminal/backends/index.js +14 -2
  102. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  103. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  104. package/dist/lib/terminal/index.d.ts +2 -1
  105. package/dist/lib/terminal/index.js +2 -1
  106. package/dist/lib/terminal/preferred.d.ts +89 -0
  107. package/dist/lib/terminal/preferred.js +87 -0
  108. package/dist/lib/terminal/run-surface.d.ts +82 -0
  109. package/dist/lib/terminal/run-surface.js +146 -0
  110. package/dist/lib/terminal/types.d.ts +1 -1
  111. package/dist/lib/types.d.ts +1 -1
  112. package/package.json +4 -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))
@@ -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;
@@ -34,6 +34,17 @@ export function parseRunAccountPickerRequest(agentSpec) {
34
34
  };
35
35
  }
36
36
  /** Return every option whose routing semantics conflict with a local account choice. */
37
+ /**
38
+ * The `--host` alias family — the flags that mean "dispatch this run to another
39
+ * machine over SSH". `--host` is canonical; `--device`/`--on`/`--computer` are
40
+ * aliases. Returns the values actually given (so callers can both test presence
41
+ * and read the target). Kept in ONE place because a guard that listed only a
42
+ * subset silently let `--terminal --device` open a local tab and drop the remote
43
+ * target — the drift this predicate exists to prevent.
44
+ */
45
+ export function hostTargetGiven(options) {
46
+ return [options.host, options.device, options.on, options.computer].filter((v) => !!v);
47
+ }
37
48
  export function runAccountPickerConflicts(options) {
38
49
  const conflicts = [];
39
50
  if (options.resume !== undefined)
@@ -46,7 +57,7 @@ export function runAccountPickerConflicts(options) {
46
57
  conflicts.push('--lease');
47
58
  if (options.box)
48
59
  conflicts.push('--box');
49
- if (options.host || options.device || options.on || options.computer)
60
+ if (hostTargetGiven(options).length)
50
61
  conflicts.push('--host/--device');
51
62
  return conflicts;
52
63
  }
@@ -349,6 +360,121 @@ export async function runWorkflowForEach(spec, opts) {
349
360
  process.stderr.write(chalk.yellow(`[for_each] stopped by ${result.stoppedBy} after ${result.waves} waves. Team: ${team}\n`));
350
361
  return 1;
351
362
  }
363
+ /**
364
+ * The run's working directory from `--cwd` / `--project`. `--project <slug>` owns
365
+ * the directory and is mutually exclusive with `--cwd`/`--remote-cwd`; both the
366
+ * main dispatch and the `--terminal` handoff need the same answer, so the rule
367
+ * and its error live here once instead of in two places that can drift.
368
+ */
369
+ async function resolveRunCwd(options, opts) {
370
+ if (!options.project)
371
+ return options.cwd;
372
+ if (options.cwd || options.remoteCwd) {
373
+ console.error(chalk.red('Pass --project alone — not with --cwd or --remote-cwd.'));
374
+ process.exit(1);
375
+ }
376
+ const { resolveProjectRef } = await import('../lib/project-root.js');
377
+ try {
378
+ return await resolveProjectRef(options.project, { forRemote: opts.forRemote });
379
+ }
380
+ catch (err) {
381
+ console.error(chalk.red(err.message));
382
+ process.exit(1);
383
+ }
384
+ }
385
+ /**
386
+ * `--terminal`: hand this run to a real terminal tab and exit.
387
+ *
388
+ * The terminal is detected from the user's live sessions, so a run started from
389
+ * a surface that cannot host a TUI (the menu bar's "New Session", a script) lands
390
+ * in the terminal they actually work in instead of a hardcoded Terminal.app.
391
+ * Exits non-zero when no terminal could be opened — the caller must not believe
392
+ * a session started when none did.
393
+ */
394
+ async function handleTerminalHandoff(agentSpec, options, prompt) {
395
+ const { parseTerminalFlag, openRunInTerminal, toHostSamples } = await import('../lib/terminal/run-surface.js');
396
+ const { currentContext } = await import('../lib/terminal/index.js');
397
+ const parsed = parseTerminalFlag(options.terminal);
398
+ if (parsed.error) {
399
+ console.error(chalk.red(parsed.error));
400
+ process.exit(1);
401
+ }
402
+ // Reject an unrunnable target HERE, where the person can read it. The tab would
403
+ // otherwise open, print the same error, and close — the failure lands in a
404
+ // window that is gone before it can be read, which reads as "nothing happened".
405
+ //
406
+ // `agents run <thing>` takes an agent id, a PROFILE, or a WORKFLOW (see the
407
+ // isValidAgent / profileExists / resolveWorkflowRef chain below), so this must
408
+ // accept all three. Gating on the agent table alone rejected every profile —
409
+ // the whole Kimi/DeepSeek/Qwen/GLM path — for `--terminal` runs only.
410
+ const rawTarget = parseRunAccountPickerRequest(agentSpec).normalizedAgentSpec.split('@')[0];
411
+ const knownAgent = resolveAgentName(rawTarget);
412
+ if (knownAgent && isAgentHardDeprecated(knownAgent)) {
413
+ console.error(chalk.red(hardDeprecationError(knownAgent)));
414
+ process.exit(1);
415
+ }
416
+ if (!knownAgent) {
417
+ const [{ profileExists }, { resolveWorkflowRef }] = await Promise.all([
418
+ import('../lib/profiles.js'),
419
+ import('../lib/workflows.js'),
420
+ ]);
421
+ const probeCwd = options.cwd ?? process.cwd();
422
+ if (!profileExists(rawTarget) && !resolveWorkflowRef(rawTarget, probeCwd)) {
423
+ console.error(chalk.red(`Unknown agent, profile, or workflow: ${rawTarget}. See \`agents list\` for the installed harnesses.`));
424
+ process.exit(1);
425
+ }
426
+ }
427
+ // --host and its aliases (--device/--on/--computer) all mean "dispatch this
428
+ // run to another machine over SSH", which is incompatible with opening a
429
+ // terminal tab on THIS machine — so reject the whole alias family, not just
430
+ // the canonical flag. The rule and its wording live once, in the --host
431
+ // forwarding table, so the classification a reviewer reads and the error a
432
+ // user sees can't drift.
433
+ if (hostTargetGiven(options).length) {
434
+ const { RUN_OPTION_REJECT_MESSAGES } = await import('../lib/hosts/remote-cmd.js');
435
+ console.error(chalk.red(RUN_OPTION_REJECT_MESSAGES.terminal));
436
+ process.exit(1);
437
+ }
438
+ // Machine-readable output would land in the tab, where the caller that asked
439
+ // for it can never read it. Same class of failure as --host: refuse, don't
440
+ // hand back a stream that goes nowhere.
441
+ const streamFlag = options.json ? '--json' : options.emitSessionId ? '--emit-session-id' : undefined;
442
+ if (streamFlag) {
443
+ console.error(chalk.red(`${streamFlag} streams to stdout, but --terminal moves the run into a tab where you cannot read it. Drop one.`));
444
+ process.exit(1);
445
+ }
446
+ // `--project` owns the working directory, but the main action resolves it far
447
+ // below this handoff — so without this the tab would open in THIS process's
448
+ // cwd (launchd's `/` for a menu-bar click) while the run inside it moved to
449
+ // the project. `forRemote: false` because --terminal is always local (--host
450
+ // is rejected above).
451
+ const cwd = await resolveRunCwd(options, { forRemote: false });
452
+ const { getActiveSessions } = await import('../lib/session/active.js');
453
+ let sessions = [];
454
+ try {
455
+ sessions = await toHostSamples(await getActiveSessions());
456
+ }
457
+ catch {
458
+ // Detection is best-effort — an unreadable session index must not block the
459
+ // launch; resolution falls through to the available-backend floor.
460
+ }
461
+ const result = await openRunInTerminal({
462
+ argv: process.argv.slice(2),
463
+ forced: parsed.backend,
464
+ consumedValue: typeof options.terminal === 'string' ? options.terminal : undefined,
465
+ cwd: cwd ?? process.cwd(),
466
+ sessions,
467
+ ctx: currentContext(),
468
+ });
469
+ if (!result.ok) {
470
+ console.error(chalk.red(`Could not open a terminal: ${result.error ?? 'unknown error'}`));
471
+ process.exit(1);
472
+ }
473
+ if (!options.quiet) {
474
+ const what = prompt === undefined ? 'session' : 'run';
475
+ console.log(chalk.gray(`Opened the ${what} in ${result.description}.`));
476
+ }
477
+ }
352
478
  /** Register the `agents run <agent> [prompt]` command. */
353
479
  export function registerRunCommand(program) {
354
480
  const runCmd = program
@@ -374,6 +500,7 @@ export function registerRunCommand(program) {
374
500
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
375
501
  .option('--name <slug>', 'Name the run — seeds the session label so it shows up as `<name>` in `agents sessions` and resolves by it (and `agents hosts logs <name>` for --host runs) instead of an opaque id. An agent-generated title later refines the label; your name shows until then. Optional.')
376
502
  .option('--notify', 'Post a desktop notification when a headless run finishes. Fired by this process on exit, so it survives whatever launched the run (the menu bar dispatching it, a terminal you closed).')
503
+ .option('--terminal [backend]', "Open this run in a real terminal tab instead of here. Without a value the terminal is detected from your live sessions (`agents sessions --active` host), so it lands where you already work — Ghostty for a Ghostty user, iTerm for an iTerm user. Name one to force it: iterm | ghostty | terminal | tmux | vscodium-agent. This is how the menu bar's New Session opens.")
377
504
  .option('--verbose', 'Show detailed execution logs')
378
505
  .option('--raw', 'Interactive runs on macOS/Linux launch inside a shared tmux session (for %pane addressing + re-attach). Pass --raw to spawn the agent directly instead. Also disabled by AGENTS_NO_TMUX=1.')
379
506
  .option('--no-tmux', 'Spawn the agent directly instead of wrapping it in the shared tmux session. Same effect as --raw / AGENTS_NO_TMUX=1. Use this to see the agent\'s full startup output when a launch is failing.')
@@ -433,6 +560,11 @@ export function registerRunCommand(program) {
433
560
  # Pick a signed-in account/version for only this run
434
561
  agents run claude@
435
562
 
563
+ # Open the session in a terminal tab — detected from where your sessions
564
+ # already run (Ghostty / iTerm / Terminal.app); force one with a value
565
+ agents run claude --terminal
566
+ agents run claude --terminal ghostty
567
+
436
568
  # Pipe JSON events to a parser (--quiet drops the preamble)
437
569
  agents run claude "..." --json --quiet | jq
438
570
 
@@ -499,6 +631,15 @@ export function registerRunCommand(program) {
499
631
  // a native flag, not a prompt. Run interactively.
500
632
  prompt = undefined;
501
633
  }
634
+ // --terminal: this process can't host the TUI (a menu-bar click, a script),
635
+ // so hand the run to a real terminal and exit. Resolved from the user's own
636
+ // live sessions, so it opens where they already work. Done before every
637
+ // other dispatch path because the tab re-runs this same argv without the
638
+ // flag — arming --notify or picking a version here would happen twice.
639
+ if (options.terminal) {
640
+ await handleTerminalHandoff(agentSpec, options, prompt);
641
+ return;
642
+ }
502
643
  // --notify: post a desktop notification when this run finishes. Armed on
503
644
  // process exit so it covers EVERY dispatch path below (local, --host,
504
645
  // --lease, the error path) instead of one branch. Only for headless runs
@@ -906,24 +1047,13 @@ export function registerRunCommand(program) {
906
1047
  }
907
1048
  // --host/--on/--computer: offload this run onto a registered agent host
908
1049
  // over SSH instead of running locally. The three flags are aliases.
909
- const hostGiven = [options.host, options.device, options.on, options.computer].filter((v) => !!v);
1050
+ const hostGiven = hostTargetGiven(options);
910
1051
  // --project <slug>[@worktree]: resolve the projects-root shorthand into a
911
1052
  // cwd. On a host run it resolves home-relative (`~/…`, so the host expands
912
1053
  // it); locally it becomes an absolute path. It owns the working directory,
913
1054
  // so it is mutually exclusive with both --cwd and --remote-cwd.
914
1055
  if (options.project) {
915
- if (options.cwd || options.remoteCwd) {
916
- console.error(chalk.red('Pass --project alone — not with --cwd or --remote-cwd.'));
917
- process.exit(1);
918
- }
919
- const { resolveProjectRef } = await import('../lib/project-root.js');
920
- try {
921
- options.cwd = await resolveProjectRef(options.project, { forRemote: hostGiven.length > 0 });
922
- }
923
- catch (err) {
924
- console.error(chalk.red(err.message));
925
- process.exit(1);
926
- }
1056
+ options.cwd = await resolveRunCwd(options, { forRemote: hostGiven.length > 0 });
927
1057
  }
928
1058
  if (hostGiven.length > 0) {
929
1059
  if (new Set(hostGiven).size > 1) {