@phnx-labs/agents-cli 1.20.84 → 1.20.86

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,103 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.86
4
+
5
+ - **`agents sessions` now shows a Kimi session's todo list and its file-touching
6
+ tool calls.** Kimi writes its checklist with `TodoList` (items shaped
7
+ `{title, status}`, where finished is `done`) rather than Claude's `TodoWrite`
8
+ (`{content, status: "completed"}`), so the checklist registry matched nothing
9
+ and every Kimi session rendered with no todos — in the picker preview, the
10
+ session detail, and the `--active` fan-out that carries progress off remote
11
+ devices. Kimi also names the file argument `path` where Claude names it
12
+ `file_path`, so `Read`/`Write`/`Edit` calls summarized as a bare `Read ` with
13
+ no file. Both spellings are now handled, and the snapshot-checklist tool names
14
+ live in one exported registry (`SNAPSHOT_TODO_TOOLS`) that the picker and the
15
+ state engine share instead of each hardcoding its own pair. Source:
16
+ `apps/cli/src/lib/session/parse.ts`, `apps/cli/src/lib/session/state.ts`,
17
+ `apps/cli/src/commands/sessions-picker.ts`.
18
+
19
+ - **`agents view` now shows Grok's default model (e.g. `grok-4.5`).** Claude,
20
+ Codex, Antigravity, and Kimi already filled the model column via their
21
+ catalogs; Grok was missing from `locateModelSource`, so
22
+ `resolveConfiguredModel` returned null and the column stayed blank. Grok has
23
+ no `settings.json` `model` field (its config is `config.toml` +
24
+ `models_cache.json`); the authoritative default is `grok models` →
25
+ `Default model: <id>`. The catalog extractor now spawns that command against
26
+ the version-home binary (skipping failed-download stubs) and flags the
27
+ default, so `agents view`, `agents view --json` (`configuredModel`), and the
28
+ other identity-cluster surfaces show it. Source: `apps/cli/src/lib/models.ts`,
29
+ `apps/cli/src/commands/models.ts`.
30
+
31
+ - **`agents events --limit 0` now reads the whole stream, and a capped read says
32
+ so.** `--limit` parsed as `Math.max(1, parseInt(raw) || 50)`, so `--limit 0`
33
+ collapsed back to `50` (`0 || 50`) and there was no way to read past the default
34
+ cap at all. The cap is applied after filtering and before the caller sees
35
+ anything, so every aggregation over `--json` silently ranked the newest 50
36
+ records instead of the matching set — measured against a real 7-day corpus of
37
+ 2,135 CLI failures in 9 classes, 8 of 9 ranks came out wrong with counts off by
38
+ roughly 100x, and nothing warned. `--limit 0` now means no cap (29,649 records
39
+ on a 30-day stream here, against 50 before), a truncated read prints
40
+ `Showing the newest 50 — more events matched. Pass --limit 0 for all.` (on
41
+ stderr under `--json`, so a `| jq` pipeline still receives clean JSON), and a
42
+ non-numeric, negative, or empty `--limit` exits 2 rather than quietly becoming
43
+ 50 — an empty one (`--limit "$LIMIT"` with the variable unset) would otherwise
44
+ have read as "no cap" and returned the whole stream unannounced.
45
+ Source: `apps/cli/src/commands/events.ts`, `apps/cli/tests/events-limit.test.ts`,
46
+ `apps/cli/docs/06-observability.md`.
47
+
48
+ - **Desktop notifications now show the current agents-cli mark, not the old
49
+ logo.** The menu-bar helper's app icon — the icon macOS puts on the left of
50
+ every notification banner it posts (the menu bar helper's own notices and every
51
+ `agents run --notify` finish notice) — was generated from the retired gradient
52
+ "A" logo, so notifications carried stale branding while the menu-bar status
53
+ item already used the new lowercase `a`. The shared master logo
54
+ (`assets/logo.png`) is now the current `a` mark, so the menu-bar helper, the
55
+ `agents computer` helper, and the keychain helper all regenerate their
56
+ `AppIcon.icns` from it on the next build. Source: `assets/logo.png`,
57
+ `apps/cli/menubar/scripts/build.sh`.
58
+
59
+ ## 1.20.85
60
+
61
+ - **`agents feed post` can now be mirrored to the systems you actually watch.**
62
+ A post was durable but local: an operator away from every terminal never saw
63
+ it, and the tracker that owns the work heard nothing. Declare sinks under
64
+ `feed.broadcast` in `agents.yaml` — argv templates, not built-in integrations —
65
+ and each post is fanned out to them. `--level important` marks a post worth
66
+ interrupting someone over, so a sink with `minLevel: important` never fires on
67
+ a routine "CI green"; a template referencing `{ticket}` is skipped when no
68
+ ticket is known, and the ticket is joined from the session index rather than
69
+ asked for as a flag. `{message}` composes the human line a messaging sink wants
70
+ — `<project> · <text>` plus the first attached URL — so an out-of-band ping
71
+ leads with the project and carries a clickable link. Delivery is best-effort
72
+ and reported per sink; a mirror that fails never costs you the post. Source:
73
+ `apps/cli/src/lib/feed-broadcast.ts`, `apps/cli/src/commands/feed.ts`,
74
+ `apps/cli/docs/06-observability.md`.
75
+
76
+ - **`agents feed --filter updates` now shows the progress posts agents actually
77
+ wrote, across the fleet.** The view read the most recent N activity events and
78
+ *then* kept `status.posted`, so routine `file.edited` churn filled the whole
79
+ slice — a box with six real posts rendered "0 posts" (and `--json` returned one
80
+ of six). `readRecentActivity` gained `events` / `tier` filters that apply before
81
+ the limit, so the limit counts posts; the same fix restores the milestone lane
82
+ under `agents feed`. The updates view also fans out over SSH like the block view
83
+ (`-H/--host`, `--device`, `--local` to opt out), because an agent posts on
84
+ whichever box ran it. Source: `apps/cli/src/lib/activity.ts`,
85
+ `apps/cli/src/commands/feed.ts`.
86
+
87
+ - **`agents run --notify` posts a desktop notification when a headless run
88
+ finishes, and menu-bar quick dispatch now uses it.** The dispatch panel used to
89
+ post its "finished"/"failed" notice from the MenubarHelper's own
90
+ process-termination callback, so a helper that restarted mid-run — an upgrade
91
+ replacing the bundle, a crash — took the callback with it while the run carried
92
+ on reparented to launchd, and the dispatch could never report back. The run
93
+ process owns the notice now: armed on its own `exit`, so it covers local,
94
+ `--host` and `--lease` dispatch alike and survives anything that happens to the
95
+ launcher. The helper's click actions also accept `url:<https…>` so a completion
96
+ notification can open the PR or ticket the run produced. Source:
97
+ `apps/cli/src/lib/run-notify.ts`, `apps/cli/src/commands/exec.ts`,
98
+ `apps/cli/menubar/Sources/MenubarHelper/AgentsCLI.swift`,
99
+ `apps/cli/menubar/Sources/MenubarHelper/PromptPanel.swift`.
100
+
3
101
  ## 1.20.84
4
102
 
5
103
  - **Agent onboarding cheat sheet and docs drift guard.** Added
package/dist/bin/agents CHANGED
Binary file
@@ -14,4 +14,20 @@
14
14
  * today's operational log live.
15
15
  */
16
16
  import type { Command } from 'commander';
17
+ /**
18
+ * Resolve `--limit` into a record cap. `0` means "no cap" — without it there is
19
+ * no way to read the whole stream, and any aggregation (group-by failure, count
20
+ * per module) silently ranks the newest 50 records instead of the real set.
21
+ * A non-numeric or negative value is a usage error, not a quiet fallback.
22
+ */
23
+ export declare function resolveEventsLimit(raw: string | undefined): number | undefined;
24
+ /**
25
+ * Cap `fetched` (read with `limit + 1`) to `limit`, reporting whether records
26
+ * were dropped. The caller announces the cap so a truncated read is never
27
+ * mistaken for the complete set.
28
+ */
29
+ export declare function capRecords<T>(fetched: T[], limit: number | undefined): {
30
+ records: T[];
31
+ truncated: boolean;
32
+ };
17
33
  export declare function registerEventsCommand(program: Command): void;
@@ -17,6 +17,33 @@ 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
+ /**
21
+ * Resolve `--limit` into a record cap. `0` means "no cap" — without it there is
22
+ * no way to read the whole stream, and any aggregation (group-by failure, count
23
+ * per module) silently ranks the newest 50 records instead of the real set.
24
+ * A non-numeric or negative value is a usage error, not a quiet fallback.
25
+ */
26
+ export function resolveEventsLimit(raw) {
27
+ const token = raw ?? '50';
28
+ // Number('') and Number(' ') are both 0, which would read as "no cap" — an
29
+ // empty --limit (an unset "$LIMIT" in a script) must be rejected, not silently
30
+ // turned into the unbounded read.
31
+ const value = token.trim() === '' ? NaN : Number(token);
32
+ if (!Number.isInteger(value) || value < 0) {
33
+ throw new RangeError(`Invalid --limit ${raw} — pass a whole number, or 0 for no cap.`);
34
+ }
35
+ return value === 0 ? undefined : value;
36
+ }
37
+ /**
38
+ * Cap `fetched` (read with `limit + 1`) to `limit`, reporting whether records
39
+ * were dropped. The caller announces the cap so a truncated read is never
40
+ * mistaken for the complete set.
41
+ */
42
+ export function capRecords(fetched, limit) {
43
+ if (limit === undefined || fetched.length <= limit)
44
+ return { records: fetched, truncated: false };
45
+ return { records: fetched.slice(0, limit), truncated: true };
46
+ }
20
47
  /** Parse `--since`: relative offsets (30s/5m/2h/7d/4w) or an ISO/absolute date. */
21
48
  function parseSince(s) {
22
49
  const m = s.match(/^(\d+)([smhdw])$/);
@@ -80,7 +107,7 @@ export function registerEventsCommand(program) {
80
107
  .option('--agent <name>', 'Only events tagged with this agent')
81
108
  .option('--since <time>', 'Only events newer than this (e.g. 2h, 7d, or ISO date)')
82
109
  .option('--audit', 'Operational events only (skip agent activity)')
83
- .option('--limit <n>', 'Max records to show (default 50)', '50')
110
+ .option('--limit <n>', 'Max records to show; 0 for no cap (default 50)', '50')
84
111
  .option('--json', 'Output raw records as JSON')
85
112
  .option('-f, --follow', "Tail today's operational log live")
86
113
  .addHelpText('after', `
@@ -90,31 +117,41 @@ Examples:
90
117
  agents events --audit Operational events only (secrets / teams / ...)
91
118
  agents events --event pr.opened --since 7d
92
119
  agents events --module secrets Every secret accessed or revealed
93
- agents events -f Live tail (operational)`)
120
+ agents events -f Live tail (operational)
121
+ agents events --event pr.opened --since 30d --limit 0 --json
122
+ Every match — use --limit 0 whenever you
123
+ aggregate, or you rank only the newest 50`)
94
124
  .action(async (options) => {
95
125
  if (options.follow) {
96
126
  await followLog();
97
127
  return;
98
128
  }
99
- const limit = Math.max(1, parseInt(options.limit ?? '50', 10) || 50);
129
+ let limit;
100
130
  let startDate;
101
131
  try {
132
+ limit = resolveEventsLimit(options.limit);
102
133
  startDate = options.since ? parseSince(options.since) : undefined;
103
134
  }
104
135
  catch (err) {
105
136
  console.error(chalk.red(err.message));
106
137
  process.exit(2);
107
138
  }
108
- const records = readUnifiedEvents({
139
+ // Read one past the cap so we can tell a full result from a clipped one.
140
+ const fetched = readUnifiedEvents({
109
141
  startDate,
110
142
  eventTypes: options.event && options.event.length ? options.event : undefined,
111
143
  agent: options.agent,
112
144
  command: options.command,
113
145
  module: options.module,
114
- limit,
146
+ limit: limit === undefined ? undefined : limit + 1,
115
147
  includeActivity: !options.audit,
116
148
  });
149
+ const { records, truncated } = capRecords(fetched, limit);
150
+ const capNote = `Showing the newest ${limit} — more events matched. Pass --limit 0 for all.`;
117
151
  if (options.json) {
152
+ // Notice goes to stderr so `--json | jq` still receives clean JSON.
153
+ if (truncated)
154
+ console.error(chalk.yellow(capNote));
118
155
  console.log(JSON.stringify(records, null, 2));
119
156
  return;
120
157
  }
@@ -126,6 +163,8 @@ Examples:
126
163
  for (const r of records.slice().reverse())
127
164
  console.log(renderRow(r));
128
165
  console.log(chalk.gray(`\n${records.length} event(s). Log: ${getLogsPath()}`));
166
+ if (truncated)
167
+ console.log(chalk.yellow(capNote));
129
168
  });
130
169
  }
131
170
  /** commander repeatable-option collector. */
@@ -367,6 +367,7 @@ export function registerRunCommand(program) {
367
367
  .option('--resume [id]', 'Resume a previous conversation. Accepts a full or partial session id (prefix-matched against the index); omit the id to pick from recent sessions interactively. Resumes under the version that started the session. claude/codex resume natively; other agents replay via a /continue first message. Pair with a prompt to continue headlessly.')
368
368
  .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.')
369
369
  .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.')
370
+ .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).')
370
371
  .option('--verbose', 'Show detailed execution logs')
371
372
  .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.')
372
373
  .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.')
@@ -492,6 +493,20 @@ export function registerRunCommand(program) {
492
493
  // a native flag, not a prompt. Run interactively.
493
494
  prompt = undefined;
494
495
  }
496
+ // --notify: post a desktop notification when this run finishes. Armed on
497
+ // process exit so it covers EVERY dispatch path below (local, --host,
498
+ // --lease, the error path) instead of one branch. Only for headless runs
499
+ // — an interactive run ends in front of the person who started it.
500
+ if (options.notify && prompt !== undefined) {
501
+ const { armRunFinishNotification } = await import('../lib/run-notify.js');
502
+ armRunFinishNotification({
503
+ agent: agentSpec,
504
+ name: options.name,
505
+ prompt,
506
+ cwd: options.cwd ?? process.cwd(),
507
+ host: options.host,
508
+ });
509
+ }
495
510
  // A trailing @ is an explicit request to choose one installed account.
496
511
  // Strip only that terminal marker; concrete agent@version pins retain
497
512
  // their existing meaning in every dispatch path below.
@@ -1,7 +1,10 @@
1
1
  import chalk from 'chalk';
2
2
  import { ensureFeedPublishHook, listAskStats, listBlocks, recordNotified } from '../lib/feed.js';
3
- import { ensureActivityLogHook, readRecentActivity, formatActivityLine, formatProgressUpdate } from '../lib/activity.js';
3
+ import { ensureActivityLogHook, readRecentActivity, formatActivityLine, formatProgressUpdate, mergeActivityEvents, parseActivityPayload, } from '../lib/activity.js';
4
4
  import { postFeedStatus } from '../lib/feed-post.js';
5
+ import { parseFeedPostLevel, planFeedBroadcast, runFeedBroadcast, } from '../lib/feed-broadcast.js';
6
+ import { getSessionById } from '../lib/session/db.js';
7
+ import { readMeta } from '../lib/state.js';
5
8
  import { enrichBlocksFromSessions, groupBlocksByOutcome, isUnambiguousOutcomeAnswer, openBlocksForOutcome, stampBlockOutcomes, } from '../lib/feed-outcome.js';
6
9
  import { classifyBlock, filterBlocksForFeed, suppressionDigest, } from '../lib/ask-classifier.js';
7
10
  import { machineId, normalizeHost } from '../lib/machine-id.js';
@@ -234,6 +237,7 @@ export function registerFeedCommand(program) {
234
237
  .argument('<text...>', 'What just happened — one short human line')
235
238
  .option('--session <id>', 'Session id escape hatch (default: auto from env / pid registry)')
236
239
  .option('--attach <path-or-url...>', 'Attach an artifact (local file or URL); repeatable')
240
+ .option('--level <level>', 'How loudly to broadcast: milestone (default) or important. Configured sinks with minLevel: important only fire on the latter.', 'milestone')
237
241
  .option('--json', 'Emit the written event as JSON')
238
242
  .addHelpText('after', `
239
243
  Examples:
@@ -242,11 +246,19 @@ Examples:
242
246
  agents feed post "cover render ready" --attach ./out/cover.png
243
247
  agents feed post "ready for review" --json
244
248
 
249
+ # Worth interrupting someone over — reaches sinks gated on minLevel: important:
250
+ agents feed post "release blocked: npm token expired" --level important
251
+
245
252
  # Outside a run, pass the session explicitly:
246
253
  agents feed post "manual note" --session 00998b0e-2d15-4d2f-a58b-974a886c9b47
247
254
 
248
255
  Identity (session, agent, host, runtime, pid, launchId) is stamped automatically.
249
- Domain facts (tickets, PRs) are not CLI flags — join them on the session at read time.
256
+ Domain facts (tickets, PRs) are not CLI flags — the ticket is joined from the
257
+ session index at post time, so a broadcast sink can comment on it without the
258
+ agent having to remember it.
259
+
260
+ Configure where a post is mirrored under feed.broadcast in agents.yaml — see
261
+ docs/06-observability.md.
250
262
  `)
251
263
  .action((textParts, opts, cmd) => {
252
264
  // Parent `feed` also declares `--json` (for the list view). Commander
@@ -255,19 +267,23 @@ Domain facts (tickets, PRs) are not CLI flags — join them on the session at re
255
267
  const flags = {
256
268
  session: opts?.session ?? cmd?.opts?.()?.session,
257
269
  attach: opts?.attach ?? cmd?.opts?.()?.attach,
270
+ level: opts?.level ?? cmd?.opts?.()?.level,
258
271
  json: Boolean(opts?.json ?? cmd?.opts?.()?.json ?? cmd?.parent?.opts?.()?.json),
259
272
  };
260
273
  try {
274
+ const level = parseFeedPostLevel(flags.level);
261
275
  const { event } = postFeedStatus({
262
276
  text: Array.isArray(textParts) ? textParts.join(' ') : String(textParts ?? ''),
263
277
  sessionId: flags.session,
264
278
  attach: flags.attach,
265
279
  });
280
+ const outcomes = broadcastPostedEvent(event, level);
266
281
  if (flags.json) {
267
- console.log(JSON.stringify(event, null, 2));
282
+ console.log(JSON.stringify(outcomes.length ? { ...event, broadcast: outcomes } : event, null, 2));
268
283
  return;
269
284
  }
270
285
  console.log(formatProgressUpdate(event));
286
+ reportBroadcast(outcomes);
271
287
  }
272
288
  catch (err) {
273
289
  console.error(chalk.red(err.message));
@@ -305,20 +321,39 @@ Domain facts (tickets, PRs) are not CLI flags — join them on the session at re
305
321
  }
306
322
  }
307
323
  }
308
- // Updates view: deliberate progress posts only, over the local activity
309
- // timeline (blocks are decisions, not announcements). Short-circuit the
310
- // whole block pipeline no remote fan-out, no dispatch policy.
324
+ // Trailing lane under the block views: `--filter all` appends the same
325
+ // fleet-wide updates section, anything else the compact local lane.
326
+ const renderTrailingActivity = async () => {
327
+ if (filter === 'all') {
328
+ console.log();
329
+ renderUpdatesView(await gatherStatusPosts({
330
+ limit: UPDATES_VIEW_LIMIT, hosts: opts.host, local: opts.local, includeLocal, self,
331
+ }));
332
+ return;
333
+ }
334
+ if (includeLocal)
335
+ renderActivityLane();
336
+ };
337
+ // Updates view: deliberate progress posts only (blocks are decisions, not
338
+ // announcements). Short-circuits the block pipeline — no dispatch policy —
339
+ // but fans out like the block view, because a post lands on whichever box
340
+ // ran the agent.
311
341
  if (filter === 'updates') {
312
342
  for (const warning of setupWarnings) {
313
343
  console.error(chalk.yellow(`Feed hook setup warning: ${warning}`));
314
344
  }
345
+ const updates = await gatherStatusPosts({
346
+ limit: opts.json ? UPDATES_JSON_LIMIT : UPDATES_VIEW_LIMIT,
347
+ hosts: opts.host,
348
+ local: opts.local,
349
+ includeLocal,
350
+ self,
351
+ });
315
352
  if (opts.json) {
316
- const updates = readRecentActivity({ sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1000, limit: 100 })
317
- .filter((e) => e.event === 'status.posted');
318
353
  console.log(JSON.stringify(updates, null, 2));
319
354
  return;
320
355
  }
321
- renderUpdatesView();
356
+ renderUpdatesView(updates);
322
357
  return;
323
358
  }
324
359
  // Active sessions feed both the GC sweep and outcome enrichment (ticket/PR).
@@ -428,14 +463,7 @@ Domain facts (tickets, PRs) are not CLI flags — join them on the session at re
428
463
  }
429
464
  if (blocks.length === 0) {
430
465
  console.log(chalk.gray(digest ? 'No open blocks after stall suppression.' : 'No open blocks.'));
431
- if (includeLocal) {
432
- if (filter === 'all') {
433
- console.log();
434
- renderUpdatesView();
435
- }
436
- else
437
- renderActivityLane();
438
- }
466
+ await renderTrailingActivity();
439
467
  return;
440
468
  }
441
469
  // Shared fleet-comms masthead (same family as `agents mailboxes`).
@@ -458,16 +486,45 @@ Domain facts (tickets, PRs) are not CLI flags — join them on the session at re
458
486
  });
459
487
  for (const g of groups)
460
488
  renderOutcomeGroup(g, self);
461
- if (includeLocal) {
462
- if (filter === 'all') {
463
- console.log();
464
- renderUpdatesView();
465
- }
466
- else
467
- renderActivityLane();
468
- }
489
+ await renderTrailingActivity();
469
490
  });
470
491
  }
492
+ /**
493
+ * Mirror a written post to the configured sinks (`feed.broadcast` in
494
+ * agents.yaml). The ticket is JOINED from the session index rather than asked
495
+ * for as a flag — it is a domain fact about the session, and an agent that has
496
+ * to remember a `--ticket` argument is an agent that will forget it. Returns the
497
+ * per-sink outcomes; an empty array means nothing is configured, which is the
498
+ * default and is not a failure.
499
+ */
500
+ function broadcastPostedEvent(event, level) {
501
+ const config = readMeta().feed?.broadcast;
502
+ if (!config || Object.keys(config).length === 0)
503
+ return [];
504
+ const ticket = getSessionById(event.sessionId)?.ticketId;
505
+ const planned = planFeedBroadcast(config, {
506
+ text: event.detail ?? '',
507
+ level,
508
+ ticket,
509
+ project: event.project,
510
+ agent: event.agent,
511
+ host: event.host,
512
+ session: event.sessionId,
513
+ links: (event.attachments ?? [])
514
+ .map((a) => a.href)
515
+ .filter((href) => /^https?:\/\//i.test(href)),
516
+ });
517
+ return runFeedBroadcast(planned);
518
+ }
519
+ /** One line per sink that ran. Silent when nothing is configured. */
520
+ function reportBroadcast(outcomes) {
521
+ for (const o of outcomes) {
522
+ if (o.ok)
523
+ console.log(chalk.gray(` → ${o.name}`));
524
+ else
525
+ console.error(chalk.yellow(` → ${o.name} failed: ${o.error}`));
526
+ }
527
+ }
471
528
  /** Normalize a raw --filter value; unknown/empty falls back to the default. */
472
529
  export function resolveFeedFilter(raw) {
473
530
  const v = (raw ?? '').trim().toLowerCase();
@@ -490,15 +547,59 @@ function renderActivityEntry(ev) {
490
547
  console.log(formatActivityLine(ev, { showHost: true }));
491
548
  }
492
549
  }
550
+ /** How far back the updates view looks for deliberate progress posts. */
551
+ const UPDATES_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
552
+ /** Posts kept per machine in the rendered view / the `--json` payload. */
553
+ const UPDATES_VIEW_LIMIT = 30;
554
+ const UPDATES_JSON_LIMIT = 100;
555
+ /**
556
+ * The most recent `limit` deliberate progress posts on THIS machine, newest
557
+ * first. The event filter is pushed into the reader so `limit` counts posts —
558
+ * slicing first and filtering after returned an empty view on a busy box, where
559
+ * routine `file.edited` hook events fill the whole slice.
560
+ */
561
+ function readStatusPosts(limit) {
562
+ return readRecentActivity({
563
+ sinceMs: Date.now() - UPDATES_WINDOW_MS,
564
+ limit,
565
+ events: ['status.posted'],
566
+ });
567
+ }
568
+ /**
569
+ * Progress posts across the fleet, newest first. An agent posts on whichever
570
+ * box it runs on, so a local-only read shows the operator a fraction of what
571
+ * the fleet reported. Peers are dialed with the same SSH fan-out the block view
572
+ * uses; `--local` (or the no-fanout env guard on a peer) keeps it to this box.
573
+ */
574
+ async function gatherStatusPosts(opts) {
575
+ const local = opts.includeLocal ? readStatusPosts(opts.limit) : [];
576
+ const forceLocal = opts.local === true || process.env[FEED_NO_FANOUT_ENV] === '1';
577
+ if (forceLocal)
578
+ return local;
579
+ const remoteHosts = opts.hosts?.length ? remoteFeedHostsToDial(opts.hosts, opts.self) : undefined;
580
+ if (opts.hosts?.length && (!remoteHosts || remoteHosts.length === 0))
581
+ return local;
582
+ const remote = await gatherRemoteAgentsJson({
583
+ args: ['feed', '--filter', 'updates', '--json'],
584
+ noFanoutEnv: FEED_NO_FANOUT_ENV,
585
+ hosts: remoteHosts,
586
+ parse: parseActivityPayload,
587
+ });
588
+ return mergeActivityEvents(local, remote.items).slice(0, opts.limit);
589
+ }
493
590
  /**
494
591
  * Render the **Updates** view: deliberate progress posts only (`status.posted`),
495
592
  * recency-ordered, with rich identity chips. Pure `file.edited` / git-hook noise
496
593
  * is excluded so operators see announcements, not tool churn.
497
594
  */
498
- function renderUpdatesView(limit = 30) {
499
- const updates = readRecentActivity({ sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1000, limit })
500
- .filter((e) => e.event === 'status.posted');
501
- console.log(masthead({ title: 'updates', accent: 'cyan', host: machineId(), right: `${updates.length} post${updates.length === 1 ? '' : 's'}` }));
595
+ function renderUpdatesView(updates) {
596
+ const hosts = new Set(updates.map((e) => e.host).filter(Boolean));
597
+ console.log(masthead({
598
+ title: 'updates',
599
+ accent: 'cyan',
600
+ host: hosts.size > 1 ? `${hosts.size} machines` : (updates[0]?.host ?? machineId()),
601
+ right: `${updates.length} post${updates.length === 1 ? '' : 's'}`,
602
+ }));
502
603
  console.log();
503
604
  if (updates.length === 0) {
504
605
  console.log(chalk.gray(' No progress updates yet. Agents post them with `agents feed post "…"`.'));
@@ -516,8 +617,11 @@ function renderUpdatesView(limit = 30) {
516
617
  * when empty.
517
618
  */
518
619
  function renderActivityLane(limit = 6) {
519
- const events = readRecentActivity({ sinceMs: Date.now() - 24 * 60 * 60 * 1000, limit })
520
- .filter((e) => e.tier === 'milestone');
620
+ const events = readRecentActivity({
621
+ sinceMs: Date.now() - 24 * 60 * 60 * 1000,
622
+ limit,
623
+ tier: 'milestone',
624
+ });
521
625
  if (events.length === 0)
522
626
  return;
523
627
  console.log(chalk.bold('\n recent activity'));
@@ -13,7 +13,7 @@ import { listInstalledVersions, getGlobalDefault, resolveVersion, resolveVersion
13
13
  import { getModelCatalog, locateModelSource } from '../lib/models.js';
14
14
  import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
15
15
  import { wrapJoined } from './inspect.js';
16
- const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'openclaw', 'antigravity', 'kimi'];
16
+ const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'openclaw', 'antigravity', 'kimi', 'grok'];
17
17
  /**
18
18
  * Agents that don't necessarily install under ~/.agents/versions (cursor ships
19
19
  * via a curl script). For these, fall back to the PATH binary and synthesize
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import chalk from 'chalk';
11
11
  import { truncate, humanDuration } from '../lib/format.js';
12
- import { parseSession, sanitizeForTerminal } from '../lib/session/parse.js';
12
+ import { parseSession, sanitizeForTerminal, SNAPSHOT_TODO_TOOLS } from '../lib/session/parse.js';
13
13
  import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
14
14
  import { linkPath, linkUrl, relativeToCwd, shortenModel } from '../lib/session/render.js';
15
15
  import { linearIssueUrl } from '../lib/session/linear.js';
@@ -312,9 +312,10 @@ function formatCompactPreview(events, session) {
312
312
  if (!planFile && p && /\/plans\/[^/]+\.md$/.test(p)) {
313
313
  planFile = p;
314
314
  }
315
- // Claude TodoWrite (`todos`) and Codex update_plan (`plan`) same source as
316
- // extractTodoProgress in the state engine. Prefer the most recent write.
317
- if (tool === 'TodoWrite' || tool === 'update_plan') {
315
+ // Every harness's checklist-snapshot tool (Claude TodoWrite, Kimi TodoList,
316
+ // Codex update_plan, …) — the same registry the state engine folds through
317
+ // extractTodoProgress. Prefer the most recent write.
318
+ if (SNAPSHOT_TODO_TOOLS.has(tool)) {
318
319
  const progress = extractTodoProgress(event.args);
319
320
  if (progress)
320
321
  latestTodos = progress;
@@ -105,6 +105,14 @@ export interface RecentActivityOptions {
105
105
  root?: string;
106
106
  /** Per-session tail budget in bytes. */
107
107
  maxBytesPerSession?: number;
108
+ /**
109
+ * Only include these event names. Applied BEFORE `limit`, so asking for a
110
+ * rare event (a deliberate `status.posted`) returns that many of it instead
111
+ * of however many survive a slice dominated by routine `file.edited` churn.
112
+ */
113
+ events?: string[];
114
+ /** Only include events in these tiers. Applied BEFORE `limit`, as `events` is. */
115
+ tier?: ActivityTier;
108
116
  }
109
117
  /**
110
118
  * Merge recent events across every session's log, newest first. Reads only the
@@ -194,9 +194,14 @@ export function listActivitySessions(root) {
194
194
  export function readRecentActivity(opts = {}) {
195
195
  const dir = opts.root ?? getActivityDir();
196
196
  const sinceMs = opts.sinceMs ?? 0;
197
+ const wanted = opts.events && opts.events.length > 0 ? new Set(opts.events) : null;
197
198
  const all = [];
198
199
  for (const sessionId of listActivitySessions(dir)) {
199
200
  for (const ev of readSessionActivity(sessionId, dir, opts.maxBytesPerSession)) {
201
+ if (wanted && !wanted.has(ev.event))
202
+ continue;
203
+ if (opts.tier && ev.tier !== opts.tier)
204
+ continue;
200
205
  const t = Date.parse(ev.ts);
201
206
  if (Number.isFinite(t) && t >= sinceMs)
202
207
  all.push(ev);
@@ -0,0 +1,66 @@
1
+ /** How loudly a post asks to be heard. Ordered — `important` implies milestone. */
2
+ export type FeedPostLevel = 'milestone' | 'important';
3
+ /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
4
+ export declare function parseFeedPostLevel(raw: string | undefined): FeedPostLevel;
5
+ export interface FeedSinkConfig {
6
+ /**
7
+ * argv to run, with `{placeholder}` tokens substituted. First element is the
8
+ * program; it is spawned directly (no shell), so quoting is not a concern and
9
+ * post text can never become shell syntax.
10
+ */
11
+ command: string[];
12
+ /** Lowest post level that reaches this sink. Defaults to `milestone` (all posts). */
13
+ minLevel?: FeedPostLevel;
14
+ }
15
+ /** `feed.broadcast` in agents.yaml — sink name → what to run. */
16
+ export type FeedBroadcastConfig = Record<string, FeedSinkConfig>;
17
+ /** Everything a template may interpolate. Absent values skip templates that need them. */
18
+ export interface FeedBroadcastContext {
19
+ /** The post text, verbatim. */
20
+ text: string;
21
+ level: FeedPostLevel;
22
+ /** Tracker id for the work, e.g. `RUSH-2081`. */
23
+ ticket?: string;
24
+ /** Repo/project the post came from. */
25
+ project?: string;
26
+ agent?: string;
27
+ host?: string;
28
+ session?: string;
29
+ /** URLs attached to the post — the PR, the ticket, a shared plan. */
30
+ links?: string[];
31
+ }
32
+ export interface PlannedSink {
33
+ name: string;
34
+ argv: string[];
35
+ }
36
+ export interface SinkOutcome {
37
+ name: string;
38
+ ok: boolean;
39
+ /** stderr tail when the sink failed, for the warning line. */
40
+ error?: string;
41
+ }
42
+ /**
43
+ * A human-facing one-liner for a messaging sink: what project, what happened,
44
+ * and the link to go read more. Leading with the project is deliberate — a
45
+ * message that opens with an agent name tells the reader nothing about which of
46
+ * their projects just moved.
47
+ */
48
+ export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): string;
49
+ /**
50
+ * Substitute `{placeholder}` tokens in an argv template. Returns undefined when
51
+ * the template needs a value this post does not have — the sink is then skipped
52
+ * rather than run with an empty argument, which is how a `linear update --comment`
53
+ * would otherwise comment on nothing.
54
+ */
55
+ export declare function renderSinkArgv(template: string[], ctx: FeedBroadcastContext): string[] | undefined;
56
+ /**
57
+ * Which sinks this post reaches, in config order. Pure — the dry-run listing and
58
+ * the real fan-out plan through here, so what `--dry-run` shows is what runs.
59
+ */
60
+ export declare function planFeedBroadcast(config: FeedBroadcastConfig | undefined, ctx: FeedBroadcastContext): PlannedSink[];
61
+ /**
62
+ * Run the planned sinks. Each is a direct spawn with a bounded lifetime; a sink
63
+ * that fails or is not installed is reported, never thrown — the post is already
64
+ * written and must not be undone by a mirror that could not be reached.
65
+ */
66
+ export declare function runFeedBroadcast(planned: PlannedSink[], timeoutMs?: number): SinkOutcome[];