@phnx-labs/agents-cli 1.20.76 → 1.20.77

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 (85) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/exec.js +58 -16
  4. package/dist/commands/routines.js +271 -13
  5. package/dist/commands/secrets.js +59 -17
  6. package/dist/commands/sessions-browser.js +11 -1
  7. package/dist/commands/sessions-picker.d.ts +14 -1
  8. package/dist/commands/sessions-picker.js +168 -15
  9. package/dist/commands/sessions.d.ts +23 -4
  10. package/dist/commands/sessions.js +128 -38
  11. package/dist/commands/ssh.d.ts +13 -0
  12. package/dist/commands/ssh.js +39 -2
  13. package/dist/index.js +2 -1
  14. package/dist/lib/activity.d.ts +5 -4
  15. package/dist/lib/activity.js +450 -36
  16. package/dist/lib/cloud/session-index.js +2 -2
  17. package/dist/lib/daemon.d.ts +10 -1
  18. package/dist/lib/daemon.js +99 -12
  19. package/dist/lib/devices/fleet.d.ts +16 -1
  20. package/dist/lib/devices/fleet.js +10 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/exec.d.ts +16 -0
  23. package/dist/lib/exec.js +27 -4
  24. package/dist/lib/feed.d.ts +7 -1
  25. package/dist/lib/feed.js +96 -6
  26. package/dist/lib/heal.d.ts +7 -4
  27. package/dist/lib/heal.js +10 -22
  28. package/dist/lib/hosts/dispatch.d.ts +9 -0
  29. package/dist/lib/hosts/dispatch.js +24 -4
  30. package/dist/lib/hosts/passthrough.js +7 -2
  31. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  32. package/dist/lib/hosts/remote-cmd.js +31 -8
  33. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  34. package/dist/lib/hosts/remote-session-id.js +84 -0
  35. package/dist/lib/hosts/run-target.d.ts +4 -0
  36. package/dist/lib/hosts/run-target.js +5 -1
  37. package/dist/lib/hosts/session-index.js +3 -3
  38. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  39. package/dist/lib/menubar/install-menubar.js +14 -0
  40. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  41. package/dist/lib/menubar/notify-desktop.js +78 -0
  42. package/dist/lib/overdue.d.ts +4 -5
  43. package/dist/lib/overdue.js +8 -41
  44. package/dist/lib/routine-notify.d.ts +76 -0
  45. package/dist/lib/routine-notify.js +190 -0
  46. package/dist/lib/routines-placement.d.ts +38 -0
  47. package/dist/lib/routines-placement.js +79 -0
  48. package/dist/lib/routines-project.d.ts +97 -0
  49. package/dist/lib/routines-project.js +349 -0
  50. package/dist/lib/routines.d.ts +68 -0
  51. package/dist/lib/routines.js +74 -7
  52. package/dist/lib/runner.d.ts +12 -2
  53. package/dist/lib/runner.js +150 -25
  54. package/dist/lib/sandbox.js +10 -0
  55. package/dist/lib/secrets/account-token.d.ts +20 -0
  56. package/dist/lib/secrets/account-token.js +64 -0
  57. package/dist/lib/secrets/agent.d.ts +9 -4
  58. package/dist/lib/secrets/agent.js +30 -20
  59. package/dist/lib/secrets/bundles.d.ts +20 -6
  60. package/dist/lib/secrets/bundles.js +70 -34
  61. package/dist/lib/secrets/index.d.ts +11 -2
  62. package/dist/lib/secrets/index.js +21 -9
  63. package/dist/lib/secrets/session-store.d.ts +6 -2
  64. package/dist/lib/secrets/session-store.js +65 -15
  65. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  66. package/dist/lib/secrets/unlock-hints.js +36 -0
  67. package/dist/lib/session/active.d.ts +10 -0
  68. package/dist/lib/session/active.js +67 -4
  69. package/dist/lib/session/db.d.ts +6 -0
  70. package/dist/lib/session/db.js +83 -6
  71. package/dist/lib/session/discover.d.ts +13 -1
  72. package/dist/lib/session/discover.js +91 -3
  73. package/dist/lib/session/parse.js +11 -2
  74. package/dist/lib/session/prompt.d.ts +7 -0
  75. package/dist/lib/session/prompt.js +37 -0
  76. package/dist/lib/session/provenance.d.ts +7 -0
  77. package/dist/lib/session/render.js +18 -16
  78. package/dist/lib/session/state.d.ts +4 -0
  79. package/dist/lib/session/state.js +93 -11
  80. package/dist/lib/session/types.d.ts +25 -1
  81. package/dist/lib/session/types.js +8 -0
  82. package/dist/lib/state.d.ts +7 -3
  83. package/dist/lib/state.js +20 -5
  84. package/dist/lib/types.d.ts +10 -0
  85. package/package.json +1 -1
@@ -32,14 +32,15 @@ import { filterTeamSessions } from '../lib/session/team-filter.js';
32
32
  import { parseSession } from '../lib/session/parse.js';
33
33
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
34
34
  import { formatRelativeTime } from '../lib/session/relative-time.js';
35
- import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
35
+ import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkUrl } from '../lib/session/render.js';
36
+ import { linearIssueUrl } from '../lib/session/linear.js';
36
37
  import { renderMarkdown } from '../lib/markdown.js';
37
38
  import { AGENTS, colorAgent, resolveAgentName } from '../lib/agents.js';
38
39
  import { getShimsDir } from '../lib/state.js';
39
40
  import { fuzzyMatch, FUZZY_PRESETS } from '../lib/fuzzy.js';
40
41
  import { resolveVersionAliasLoose } from '../lib/versions.js';
41
42
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
42
- import { sessionPicker, buildPreview } from './sessions-picker.js';
43
+ import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
43
44
  import { setHelpSections } from '../lib/help.js';
44
45
  import { registerSessionsTailCommand } from './sessions-tail.js';
45
46
  import { registerSessionsSyncCommand } from './sessions-sync.js';
@@ -226,15 +227,24 @@ export function cleanPreview(text) {
226
227
  .trim();
227
228
  }
228
229
  /**
229
- * Build the live description for an active session: prefer the state engine's
230
- * preview (the latest turn), then a user label, then the first-prompt topic.
230
+ * Build the live description for an active session: checklist progress (when
231
+ * present) plus the state engine's preview (the latest turn), a user label, or
232
+ * the first-prompt topic. Used by both the flat listing's `doing` cell and as
233
+ * the snippet half of the --active row (identity is layered on in printActiveRow).
234
+ *
235
+ * Covers every ActiveSession context: terminal (interactive), headless, teams,
236
+ * cloud, and sub-agent rows that share the same ActiveSession.todos field.
231
237
  */
232
238
  function buildSessionDescription(s) {
239
+ const todo = formatTodoCompact(s.todos);
233
240
  if (s.context === 'cloud') {
234
- return cleanPreview(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
241
+ const base = s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
242
+ return cleanPreview([todo, base].filter(Boolean).join(' · '));
235
243
  }
236
244
  if (s.context === 'teams') {
237
245
  const parts = [s.teamName];
246
+ if (todo)
247
+ parts.push(todo);
238
248
  if (s.preview)
239
249
  parts.push(s.preview);
240
250
  else if (s.label)
@@ -243,8 +253,53 @@ function buildSessionDescription(s) {
243
253
  parts.push(s.topic);
244
254
  return cleanPreview(parts.filter(Boolean).join(' · '));
245
255
  }
246
- // Terminal or headless: prefer the live preview, then label, then topic.
247
- return cleanPreview(s.preview || s.label || s.topic || '');
256
+ // Terminal, headless, or sub-agent: todos + live preview, then label, then topic.
257
+ const base = s.preview || s.label || s.topic || '';
258
+ return cleanPreview([todo, base].filter(Boolean).join(' · '));
259
+ }
260
+ /**
261
+ * Identity + checklist + live snippet for an --active / cross-machine row.
262
+ * Surfaces agent-adjacent identity the flat table already has (label, project)
263
+ * with a clickable project when a GitHub URL is resolvable, then the checklist
264
+ * tally and the latest-turn snippet.
265
+ *
266
+ * Free-text fields are cleaned individually; OSC 8 hyperlinks are applied
267
+ * *after* cleaning so `cleanPreview` does not strip the clickable targets
268
+ * (RUSH-2045 review). Ticket is clickable via {@link signalBadges}, not here,
269
+ * so the id is not printed twice.
270
+ */
271
+ export function formatActiveRowDescription(s) {
272
+ const parts = [];
273
+ const pushText = (t) => {
274
+ const c = t ? cleanPreview(t) : '';
275
+ if (c)
276
+ parts.push(c);
277
+ };
278
+ if (s.context === 'teams' && s.teamName)
279
+ pushText(s.teamName);
280
+ if (s.label)
281
+ pushText(s.label);
282
+ // Project = basename(cwd), same derivation as serializeActiveSessionsForJson.
283
+ const project = s.cwd ? path.basename(s.cwd) : '';
284
+ if (project && project !== s.label && project !== s.teamName) {
285
+ const label = cleanPreview(project);
286
+ const repoUrl = githubRepoUrlFromCwd(s.cwd);
287
+ parts.push(repoUrl ? linkUrl(repoUrl, label) : label);
288
+ }
289
+ const todo = formatTodoCompact(s.todos);
290
+ if (todo)
291
+ parts.push(todo);
292
+ // Latest-turn snippet — avoid repeating label/topic when already used as identity.
293
+ if (s.context === 'cloud') {
294
+ pushText(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
295
+ }
296
+ else if (s.preview) {
297
+ pushText(s.preview);
298
+ }
299
+ else if (!s.label && s.topic) {
300
+ pushText(s.topic);
301
+ }
302
+ return parts.filter(Boolean).join(' · ');
248
303
  }
249
304
  /** Short human word for a session's activity (falls back to the coarse status). */
250
305
  function activityLabel(s) {
@@ -325,10 +380,15 @@ function signalBadges(s) {
325
380
  parts.push(chalk.yellow('ask'));
326
381
  else if (s.awaitingReason === 'permission')
327
382
  parts.push(chalk.yellow('perm'));
328
- if (s.ticket)
329
- parts.push(chalk.cyan(s.ticket.id));
330
- if (s.pr)
331
- parts.push(chalk.blue(`PR#${s.pr.number ?? '?'}`));
383
+ if (s.ticket) {
384
+ // Clickable when Linear workspace is resolvable (same helper as the picker header).
385
+ const url = linearIssueUrl(s.ticket.id);
386
+ parts.push(chalk.cyan(url ? linkUrl(url, s.ticket.id) : s.ticket.id));
387
+ }
388
+ if (s.pr) {
389
+ const label = `PR#${s.pr.number ?? '?'}`;
390
+ parts.push(chalk.blue(s.pr.url ? linkUrl(s.pr.url, label) : label));
391
+ }
332
392
  if (s.worktree)
333
393
  parts.push(chalk.magenta(`wt:${s.worktree.slug}`));
334
394
  return parts.join(' ');
@@ -343,8 +403,10 @@ function signalBadges(s) {
343
403
  function locatorBadge(s) {
344
404
  const p = s.provenance;
345
405
  const parts = [];
406
+ // An ssh-launched session shows where it was launched FROM when the client IP
407
+ // resolves to a registered device (`ssh←zion`); bare `ssh` when it doesn't.
346
408
  if (p?.transport === 'ssh')
347
- parts.push(chalk.red('ssh'));
409
+ parts.push(chalk.red(p.origin ? `ssh←${p.origin.device}` : 'ssh'));
348
410
  if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
349
411
  parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
350
412
  // For a tmux-hosted session, say which app+tab is looking at it right now
@@ -373,13 +435,17 @@ function locatorBadge(s) {
373
435
  * terminal width so the row never wraps.
374
436
  */
375
437
  function printActiveRow(s, indent) {
438
+ // shortId (8-char) · agent · host · status · badges · identity+todos+snippet
376
439
  const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', 9));
377
440
  const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
378
441
  const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
379
442
  const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
380
443
  const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
381
444
  const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
382
- const desc = buildSessionDescription(s) || '-';
445
+ // Identity (label/project/ticket clickable) + checklist + live snippet.
446
+ // Cross-machine rows use the same path — remote ActiveSession fields arrive
447
+ // via the SSH fan-out already populated (including todos when the peer has them).
448
+ const desc = formatActiveRowDescription(s) || '-';
383
449
  // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
384
450
  const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
385
451
  const room = Math.max(12, terminalWidth() - fixed - 1);
@@ -1247,9 +1313,12 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
1247
1313
  const tag = originTag(session) || teamTag(session);
1248
1314
  const label = session.label;
1249
1315
  const { glyph, preview } = liveGlyphAndPreview(live);
1250
- // A running session's live preview says what the agent is doing now; a
1251
- // resting one falls back to its opening topic.
1252
- const doing = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
1316
+ // A running session's live preview (via liveGlyphAndPreview buildSessionDescription)
1317
+ // already folds in ActiveSession.todos. For resting / not-live rows, surface
1318
+ // SessionMeta.todos when the scan attached it.
1319
+ const restingTodo = !live ? formatTodoCompact(session.todos) : '';
1320
+ const topicBase = tag ? `${tag}${session.topic ?? ''}` : session.topic;
1321
+ const doing = [restingTodo, preview || topicBase].filter(Boolean).join(' · ') || undefined;
1253
1322
  const wt = session.worktreeSlug ? chalk.magenta(`wt:${session.worktreeSlug}`) : '';
1254
1323
  // The machine column only earns its width when the listing spans more than one
1255
1324
  // box (i.e. the cross-machine fan-out folded remotes in) — same rule and
@@ -1285,7 +1354,11 @@ function treeSessionRow(session, live) {
1285
1354
  const tag = originTag(session) || teamTag(session);
1286
1355
  const label = session.label;
1287
1356
  const { glyph, preview } = liveGlyphAndPreview(live);
1288
- const topic = (preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic)) || '-';
1357
+ // Match flatSessionRow: live preview already folds ActiveSession.todos; resting
1358
+ // rows surface SessionMeta.todos when present (RUSH-2045).
1359
+ const restingTodo = !live ? formatTodoCompact(session.todos) : '';
1360
+ const topicBase = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
1361
+ const topic = [restingTodo, topicBase].filter(Boolean).join(' · ') || '-';
1289
1362
  const badges = signalBadges(metaSignals(session));
1290
1363
  const badgeW = badges ? stringWidth(badges) + 1 : 0;
1291
1364
  const head = label ? `${label} · ${topic}` : topic;
@@ -1652,10 +1725,18 @@ export function pickerColumnsFor(sessions) {
1652
1725
  showTicket: sessions.some((s) => ticketLabel(s) !== ''),
1653
1726
  };
1654
1727
  }
1655
- export function formatPickerLabel(s, query, cols = {}) {
1728
+ export function formatPickerLabel(s, query, cols = {}, ssh) {
1656
1729
  const agentColor = colorAgent(s.agent);
1657
1730
  const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
1658
1731
  const project = s.project || '-';
1732
+ // SSH-launch origin (live rows only): mirrors the flat listing's `ssh←<device>`
1733
+ // badge. Rendered as its OWN red segment before the topic cell — folding it into
1734
+ // the topic string loses the colour, because renderTopicCell strips ANSI and
1735
+ // re-whitens every slice. Its width is reserved from the topic budget below
1736
+ // (exactly like `wt`), so the fixed-width columns stay aligned.
1737
+ const sshPlain = ssh ? (ssh.device ? `ssh←${ssh.device} ` : 'ssh ') : '';
1738
+ const sshSeg = sshPlain ? chalk.red(sshPlain) : '';
1739
+ const sshW = sshPlain ? stringWidth(sshPlain) : 0;
1659
1740
  const tag = originTag(s) || teamTag(s);
1660
1741
  const label = s.label;
1661
1742
  const topic = tag ? `${tag}${s.topic ?? ''}` : s.topic;
@@ -1676,12 +1757,13 @@ export function formatPickerLabel(s, query, cols = {}) {
1676
1757
  const machineColW = cols.showMachine ? machineW : 0;
1677
1758
  const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
1678
1759
  const wtW = wt ? stringWidth(wt) + 1 : 0;
1679
- const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - stringWidth(when) - 1);
1760
+ const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - sshW - stringWidth(when) - 1);
1680
1761
  return (chalk.white(padRight(s.shortId, 10)) +
1681
1762
  agentColor(padRight(truncate(s.agent, 8), 9)) +
1682
1763
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
1683
1764
  machineCell +
1684
1765
  chalk.cyan(padRight(truncate(project, 14), 16)) +
1766
+ sshSeg +
1685
1767
  renderTopicCell(label, topic, query, topicW, topicW) +
1686
1768
  ticketCell +
1687
1769
  (wt ? wt + ' ' : '') +
@@ -2062,9 +2144,10 @@ function formatSearchMessage(options) {
2062
2144
  /**
2063
2145
  * The single entry point for turning a `sessions <query>` argument into rows.
2064
2146
  *
2065
- * A complete session id resolves by id alone. Anything else keeps the existing
2066
- * ladder: id lookup first (so a short id still wins), then the ranked
2067
- * metadata+content search.
2147
+ * An id-shaped query a complete id OR a hex short-id/prefix (looksLikeSessionId)
2148
+ * resolves by id alone, through the index, and never falls back to content
2149
+ * search (a bare id must not surface every transcript that merely mentions it).
2150
+ * A genuine search phrase keeps the ranked metadata+content search.
2068
2151
  */
2069
2152
  export function resolveSessionQuery(pool, query) {
2070
2153
  // Normalize ONCE here. isCompleteSessionId trims but resolveSessionById does
@@ -2076,12 +2159,16 @@ export function resolveSessionQuery(pool, query) {
2076
2159
  const byIdMatches = resolveSessionById(pool, normalized);
2077
2160
  if (byIdMatches.length > 0)
2078
2161
  return { matches: byIdMatches, byId: true, completeId };
2079
- if (completeId) {
2080
- // The pool is only what discoverSessions walked a minority of the index
2081
- // (measured: 2,798 of 7,614 rows), because it re-reads live agent homes and
2082
- // skips whole classes of indexed session. Declaring absence from the pool
2083
- // alone denied 1,315 sessions whose transcript is on this disk right now.
2084
- // Ask the index itself, the same authoritative lookup `fork` and `exec` use.
2162
+ if (looksLikeSessionId(normalized)) {
2163
+ // Any id-shaped query a complete id OR a bare hex short-id/prefix
2164
+ // resolves by id ONLY, never by content. The pool is a minority of the
2165
+ // index (measured: 2,798 of 7,614 rows) because it re-reads live agent homes
2166
+ // and skips whole classes of indexed session, so a pool miss isn't absence:
2167
+ // ask the index directly, the same authoritative lookup `fork` and `exec`
2168
+ // use. And an id that resolves to nothing must report "no session with that
2169
+ // id" — NOT fall back to fuzzy content search. A short id like "d3470b57"
2170
+ // otherwise surfaces every transcript that merely MENTIONS the string (a
2171
+ // resume prompt echoes the parent id into the body of many later sessions).
2085
2172
  return { matches: findSessionsById(normalized), byId: true, completeId };
2086
2173
  }
2087
2174
  return { matches: filterSessionsByQuery(pool, normalized), byId: false, completeId };
@@ -2237,7 +2324,7 @@ async function renderArtifactsGlobal(query, listAll, name, scope) {
2237
2324
  const { matches: queryMatches, byId, completeId } = resolveSessionQuery(allSessions, query);
2238
2325
  if (queryMatches.length === 0) {
2239
2326
  spinner.stop();
2240
- if (completeId)
2327
+ if (byId)
2241
2328
  notFoundByIdMessage(query).forEach(l => console.error(l));
2242
2329
  else
2243
2330
  console.error(chalk.red(`No session found matching: ${query}`));
@@ -2281,11 +2368,14 @@ async function renderOneSession(query, mode, scope) {
2281
2368
  let queryMatches = resolution.matches;
2282
2369
  let byId = resolution.byId;
2283
2370
  const completeId = resolution.completeId;
2284
- // Widen to the transcript content index only for a genuine search phrase. A
2285
- // complete id is unique, so widening could only ever surface a DIFFERENT
2286
- // session that happens to mention the id which is what made `sessions
2287
- // <uuid>` render an unrelated transcript.
2288
- if (queryMatches.length === 0 && !completeId) {
2371
+ // Widen to the transcript content index only for a genuine search phrase.
2372
+ // ANY id-shaped query (a complete id OR a hex short-id/prefix) names a
2373
+ // specific session; widening could only surface a DIFFERENT session that
2374
+ // happens to MENTION the id — which is what made `sessions <uuid>` render an
2375
+ // unrelated transcript and `sessions <shortid>` list every session that
2376
+ // echoes the id in a resume prompt. Gate on looksLikeSessionId, not just
2377
+ // completeId, so a short id resolves to "no match" rather than fuzzy content.
2378
+ if (queryMatches.length === 0 && !looksLikeSessionId(query)) {
2289
2379
  const contentResults = searchContentIndex(allSessions, query);
2290
2380
  if (contentResults.size > 0) {
2291
2381
  const matchedSessions = Array.from(contentResults.values())
@@ -2312,7 +2402,7 @@ async function renderOneSession(query, mode, scope) {
2312
2402
  process.exit(1);
2313
2403
  }
2314
2404
  }
2315
- else if (completeId) {
2405
+ else if (byId) {
2316
2406
  notFoundByIdMessage(query).forEach(l => console.error(l));
2317
2407
  process.exit(1);
2318
2408
  }
@@ -2377,10 +2467,10 @@ export function registerSessionsCommands(program) {
2377
2467
  .option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
2378
2468
  .option('--no-redact', 'Disable default secret redaction in rendered session output (--markdown and --json)')
2379
2469
  .option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
2380
- .option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools')
2470
+ .option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools. "user" is genuine user turns only, not harness-injected scaffolding (bash-input, system-reminder)')
2381
2471
  .option('--exclude <roles>', 'Exclude these roles (comma-separated): user, assistant, thinking, tools')
2382
- .option('--first <n>', 'Keep only the first N turns (a turn starts at each user message)')
2383
- .option('--last <n>', 'Keep only the last N turns (a turn starts at each user message)')
2472
+ .option('--first <n>', 'Keep only the first N turns (a turn starts at each genuine user message, not harness-injected scaffolding)')
2473
+ .option('--last <n>', 'Keep only the last N turns (a turn starts at each genuine user message, not harness-injected scaffolding)')
2384
2474
  .option('--artifacts', 'List all files written or edited during a session')
2385
2475
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
2386
2476
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
@@ -10,6 +10,7 @@
10
10
  * rsync / `agents sessions --host` resolve the same logical names.
11
11
  */
12
12
  import type { Command } from 'commander';
13
+ import { type FanOutDeviceTarget } from '../lib/devices/fleet.js';
13
14
  import { type CrabboxBox } from '../lib/crabbox/cli.js';
14
15
  /**
15
16
  * Live "Leased boxes" section for `agents devices` (F4, RUSH-1923), computed
@@ -19,5 +20,17 @@ import { type CrabboxBox } from '../lib/crabbox/cli.js';
19
20
  * the row formatting is deterministic in tests.
20
21
  */
21
22
  export declare function renderLeasedBoxesSection(boxes: CrabboxBox[], nowSecs: number): string[];
23
+ /**
24
+ * Race `fanOut` against an overall wall-clock deadline (RUSH-2041).
25
+ *
26
+ * If `fanOut` settles first the result passes through unchanged. If the
27
+ * deadline fires first every pending remote target is mapped to `failed` (or
28
+ * `skipped` for pre-skipped targets), so callers always get a complete result
29
+ * array and the command exits promptly rather than hanging.
30
+ *
31
+ * Exported so the unit test can exercise the real path with a hanging probe
32
+ * instead of reimplementing the logic.
33
+ */
34
+ export declare function raceFleetPingDeadline<T, Target extends FanOutDeviceTarget>(fanOut: Promise<import('../lib/devices/fleet.js').FanOutDeviceResult<T>[]>, remoteTargets: Target[], overallTimeoutMs: number): Promise<import('../lib/devices/fleet.js').FanOutDeviceResult<T>[]>;
22
35
  /** Register both `agents ssh` and `agents devices`. */
23
36
  export declare function registerSshCommands(program: Command): void;
@@ -441,13 +441,43 @@ async function probeRemoteAuth(target) {
441
441
  const isWin = /^win/i.test((target.platform ?? '').trim());
442
442
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
443
443
  const cmd = buildRemoteAgentsInvocation(['devices', 'ping', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
444
- const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 60000, multiplex: true });
444
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true });
445
445
  if (res.code !== 0) {
446
446
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
447
447
  }
448
448
  const parsed = JSON.parse(res.stdout);
449
449
  return parsed.rows ?? [];
450
450
  }
451
+ /**
452
+ * Race `fanOut` against an overall wall-clock deadline (RUSH-2041).
453
+ *
454
+ * If `fanOut` settles first the result passes through unchanged. If the
455
+ * deadline fires first every pending remote target is mapped to `failed` (or
456
+ * `skipped` for pre-skipped targets), so callers always get a complete result
457
+ * array and the command exits promptly rather than hanging.
458
+ *
459
+ * Exported so the unit test can exercise the real path with a hanging probe
460
+ * instead of reimplementing the logic.
461
+ */
462
+ export async function raceFleetPingDeadline(fanOut, remoteTargets, overallTimeoutMs) {
463
+ const overallDeadline = new Promise((_, reject) => setTimeout(() => reject(new Error('fleet ping overall deadline exceeded')), overallTimeoutMs));
464
+ try {
465
+ return await Promise.race([fanOut, overallDeadline]);
466
+ }
467
+ catch (err) {
468
+ // Overall deadline hit before all devices settled — mark every pending
469
+ // device as failed so the command exits promptly. Individual probes that
470
+ // already settled are not retrievable (Promise.all internals), so we
471
+ // record all remote targets as timed out / skipped.
472
+ const errMsg = err instanceof Error ? err.message : String(err);
473
+ return remoteTargets.map((t) => ({
474
+ name: t.name,
475
+ status: t.skip ? 'skipped' : 'failed',
476
+ reason: t.skip,
477
+ error: t.skip ? undefined : errMsg,
478
+ }));
479
+ }
480
+ }
451
481
  async function runFleetPing(opts) {
452
482
  const self = machineId();
453
483
  const cliVersion = getCliVersion();
@@ -484,9 +514,16 @@ async function runFleetPing(opts) {
484
514
  const spinner = isInteractiveTerminal() && !opts.json
485
515
  ? ora(`Pinging ${probeable} device${probeable === 1 ? '' : 's'}…`).start()
486
516
  : undefined;
517
+ // Per-device: 15 s (matches the version probe budget; enough for the ~8 s
518
+ // provider-fetch inside the remote local auth probe, with headroom).
519
+ // Overall: 30 s hard cap so the command can never outlast a reasonable
520
+ // budget when several devices are simultaneously unreachable (RUSH-2041).
521
+ const FLEET_PING_DEVICE_TIMEOUT_MS = 15_000;
522
+ const FLEET_PING_OVERALL_TIMEOUT_MS = 30_000;
487
523
  let remote;
488
524
  try {
489
- remote = await fanOutDevices(remoteTargets, probeRemoteAuth);
525
+ const fanOut = fanOutDevices(remoteTargets, probeRemoteAuth, { perDeviceTimeoutMs: FLEET_PING_DEVICE_TIMEOUT_MS });
526
+ remote = await raceFleetPingDeadline(fanOut, remoteTargets, FLEET_PING_OVERALL_TIMEOUT_MS);
490
527
  }
491
528
  finally {
492
529
  spinner?.stop();
package/dist/index.js CHANGED
@@ -64,7 +64,8 @@ if (process.argv[2] === SYNC_GET_CMD ||
64
64
  process.argv[2] === SYNC_LOCK_CMD) {
65
65
  const { runAgentGetSync, runAgentPingSync, runAgentLockSync } = await import('./lib/secrets/agent.js');
66
66
  const name = process.argv[3] ?? '';
67
- const code = process.argv[2] === SYNC_GET_CMD ? await runAgentGetSync(name)
67
+ const harness = process.argv[4] ?? 'cli';
68
+ const code = process.argv[2] === SYNC_GET_CMD ? await runAgentGetSync(name, harness)
68
69
  : process.argv[2] === SYNC_PING_CMD ? await runAgentPingSync()
69
70
  : await runAgentLockSync(name);
70
71
  process.exit(code);
@@ -1,6 +1,6 @@
1
1
  import type { EventRecord } from './events.js';
2
2
  /** Recognizable milestone events, ordered first in any activity lane. */
3
- export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created'
3
+ export type MilestoneEvent = 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created'
4
4
  /** Deliberate agent-authored progress post (`agents feed post`). */
5
5
  | 'status.posted';
6
6
  /** Routine activity events, collapsed to counts by readers. */
@@ -117,10 +117,11 @@ export declare function formatActivityLine(ev: ActivityEvent, opts?: {
117
117
  * line per event to ~/.agents/.history/activity/<sessionId>.jsonl.
118
118
  *
119
119
  * Matcher-gated to mutating/milestone tools (Bash|Task|ExitPlanMode|Write|
120
- * Edit|MultiEdit) so read-only tools never pay the hook cost. Fail-open: any
121
- * error is swallowed so a logging hiccup never blocks a tool call.
120
+ * Edit|MultiEdit|TodoWrite|update_plan|TaskUpdate|todo_write|TaskCreate) so
121
+ * read-only tools never pay the hook cost. Fail-open: any error is swallowed so
122
+ * a logging hiccup never blocks a tool call.
122
123
  */
123
- export declare const ACTIVITY_LOG_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Append agent-activity events for `agents feed` / `agents activity`.\n\nBound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,\nMultiEdit). One append-only file per session; read-only tools never trigger it\nbecause the manifest matcher excludes them.\n\nSub-agent gate: when the payload carries `agent_type`, this is a Task/Agent\nsub-agent -- skip so only the top-level agent logs its own activity.\n\nFail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport re\nimport sys\nimport json\nimport shlex\nimport socket\nfrom datetime import datetime, timezone\n\nMAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log\nMILESTONE_EVENTS = {\n \"plan.created\", \"pr.opened\", \"pr.merged\", \"worktree.created\",\n \"worktree.removed\", \"commit.created\", \"pushed\", \"subagent.spawned\",\n \"artifact.created\", \"status.posted\",\n}\n\n# Deliverable file types + locations -- a Write here is a recognizable artifact\n# (an HTML plan, a PDF report, a rendered image), not a routine code edit.\nARTIFACT_EXTS = {\n \".html\", \".htm\", \".pdf\", \".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\",\n \".webp\", \".mp4\", \".mov\", \".webm\", \".csv\", \".xlsx\", \".pptx\", \".docx\",\n}\nARTIFACT_DIR_HINTS = (\"/tmp/\", \"/downloads/\", \"/.agents/artifacts/\")\n\n\ndef is_artifact(file_path):\n low = (file_path or \"\").lower()\n if os.path.splitext(low)[1] in ARTIFACT_EXTS:\n return True\n return any(hint in low for hint in ARTIFACT_DIR_HINTS)\n\n\ndef first_line(text, limit=140):\n for raw in (text or \"\").splitlines():\n s = raw.strip().lstrip(\"#\").strip()\n if s:\n return s[:limit]\n return \"\"\n\n\ndef _subcommand(tokens, tool):\n \"\"\"First non-flag token after `tool`, i.e. its subcommand (skips -C <path>,\n -c <cfg>, and other leading flags). None if `tool` isn't the invoked command.\"\"\"\n try:\n i = tokens.index(tool) + 1\n except ValueError:\n return None, []\n while i < len(tokens):\n t = tokens[i]\n if t in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n i += 2 # flag that consumes the next token\n continue\n if t.startswith(\"-\"):\n i += 1\n continue\n return t, tokens[i + 1:]\n return None, []\n\n\ndef classify_bash(command):\n \"\"\"Return the milestone event for a git/gh command, else None. Tokenizes so a\n path like `git diff -- src/commit.ts` is not mistaken for a commit.\"\"\"\n try:\n tokens = shlex.split(command or \"\")\n except Exception:\n tokens = (command or \"\").split()\n\n verb, rest = _subcommand(tokens, \"git\")\n if verb == \"worktree\":\n sub = rest[0] if rest else \"\"\n if sub == \"add\":\n return \"worktree.created\"\n if sub == \"remove\":\n return \"worktree.removed\"\n elif verb == \"commit\":\n return \"commit.created\"\n elif verb == \"push\":\n return \"pushed\"\n\n verb, rest = _subcommand(tokens, \"gh\")\n if verb == \"pr\" and rest:\n if rest[0] == \"create\":\n return \"pr.opened\"\n if rest[0] == \"merge\":\n return \"pr.merged\"\n return None\n\n\ndef extract_url(tool_response):\n \"\"\"Pull the first https URL out of a Bash tool response (stdout).\"\"\"\n text = \"\"\n if isinstance(tool_response, dict):\n text = str(tool_response.get(\"stdout\") or tool_response.get(\"output\") or \"\")\n elif isinstance(tool_response, str):\n text = tool_response\n m = re.search(r\"https?://\\S+\", text)\n return m.group(0).rstrip(\").,\") if m else None\n\n\ndef build_event(payload, hook_event):\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n tool_response = payload.get(\"tool_response\", {})\n\n event = None\n detail = None\n url = None\n\n if hook_event == \"PreToolUse\":\n if tool_name == \"ExitPlanMode\":\n event = \"plan.created\"\n detail = first_line(tool_input.get(\"plan\", \"\")) or \"plan presented\"\n elif tool_name == \"Task\":\n event = \"subagent.spawned\"\n role = tool_input.get(\"subagent_type\") or \"agent\"\n desc = tool_input.get(\"description\") or tool_input.get(\"prompt\") or \"\"\n detail = (role + \": \" + first_line(desc)).strip(\": \").strip()\n elif hook_event == \"PostToolUse\":\n if tool_name == \"Bash\":\n event = classify_bash(tool_input.get(\"command\", \"\"))\n if event:\n detail = first_line(tool_input.get(\"command\", \"\"))\n url = extract_url(tool_response)\n elif tool_name in (\"Write\", \"Edit\", \"MultiEdit\"):\n fp = tool_input.get(\"file_path\") or tool_input.get(\"path\") or \"\"\n # A freshly-written deliverable is a milestone; edits and code\n # writes stay routine and collapse to a count.\n if tool_name == \"Write\" and is_artifact(fp):\n event = \"artifact.created\"\n else:\n event = \"file.edited\"\n detail = os.path.basename(fp) if fp else tool_name\n\n if not event:\n return None, tool_name\n\n tier = \"milestone\" if event in MILESTONE_EVENTS else \"activity\"\n record = {\n \"v\": 1,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"event\": event,\n \"tier\": tier,\n }\n if detail:\n record[\"detail\"] = detail\n if url:\n record[\"url\"] = url\n record[\"tool\"] = tool_name\n return record, tool_name\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate -- only the top-level agent logs.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n hook_event = payload.get(\"hook_event_name\", \"\")\n record, tool_name = build_event(payload, hook_event)\n if not record:\n return\n\n safe_session = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id) or \"unknown\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n activity_dir = os.path.join(home, \".agents\", \".history\", \"activity\")\n target = os.path.join(activity_dir, safe_session + \".jsonl\")\n\n # Identity (mirrors 10-feed-publish.py).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", hostname.split(\".\")[0].strip().lower()) or \"unknown\"\n\n record[\"sessionId\"] = session_id\n record[\"mailboxId\"] = mailbox_id\n record[\"host\"] = host\n record[\"runtime\"] = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n if cwd:\n record[\"cwd\"] = cwd\n agent = os.environ.get(\"AGENTS_AGENT_NAME\") or \"claude\"\n record[\"agent\"] = agent\n\n try:\n os.makedirs(activity_dir, exist_ok=True)\n # Cap growth: once over the size limit, keep only milestones.\n if record[\"tier\"] != \"milestone\":\n try:\n if os.path.getsize(target) > MAX_LOG_BYTES:\n return\n except OSError:\n pass\n with open(target, \"a\") as f:\n f.write(json.dumps(record) + \"\\n\")\n except Exception:\n pass # fail open\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
124
+ export declare const ACTIVITY_LOG_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Append agent-activity events for `agents feed` / `agents activity`.\n\nBound to PreToolUse (ExitPlanMode, Task) and PostToolUse (Bash, Write, Edit,\nMultiEdit, TodoWrite, update_plan, TaskUpdate, todo_write, TaskCreate). One\nappend-only file per session; read-only tools never trigger it because the\nmanifest matcher excludes them.\n\nSub-agent gate: when the payload carries `agent_type`, this is a Task/Agent\nsub-agent -- skip so only the top-level agent logs its own activity.\n\nFail-open: ANY error is swallowed so a logging hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport re\nimport sys\nimport json\nimport shlex\nimport socket\nfrom datetime import datetime, timezone\n\nMAX_LOG_BYTES = 5 * 1024 * 1024 # cap a pathological session's log\nMILESTONE_EVENTS = {\n \"plan.created\", \"pr.opened\", \"pr.merged\", \"worktree.created\",\n \"worktree.removed\", \"commit.created\", \"pushed\", \"subagent.spawned\",\n \"artifact.created\", \"task.completed\", \"checklist.created\", \"status.posted\",\n}\n\n# Deliverable file types + locations -- a Write here is a recognizable artifact\n# (an HTML plan, a PDF report, a rendered image), not a routine code edit.\nARTIFACT_EXTS = {\n \".html\", \".htm\", \".pdf\", \".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\",\n \".webp\", \".mp4\", \".mov\", \".webm\", \".csv\", \".xlsx\", \".pptx\", \".docx\",\n}\nARTIFACT_DIR_HINTS = (\"/tmp/\", \"/downloads/\", \"/.agents/artifacts/\")\n\n# Checklist tools across harnesses. The value is the key that holds the item list.\nCHECKLIST_TOOLS = {\n \"TodoWrite\": \"todos\",\n \"todo_write\": \"todos\",\n \"TaskUpdate\": \"tasks\",\n \"update_plan\": \"plan\",\n}\n\n\ndef is_artifact(file_path):\n low = (file_path or \"\").lower()\n if os.path.splitext(low)[1] in ARTIFACT_EXTS:\n return True\n return any(hint in low for hint in ARTIFACT_DIR_HINTS)\n\n\ndef first_line(text, limit=140):\n for raw in (text or \"\").splitlines():\n s = raw.strip().lstrip(\"#\").strip()\n if s:\n return s[:limit]\n return \"\"\n\n\ndef _subcommand(tokens, tool):\n \"\"\"First non-flag token after `tool`, i.e. its subcommand (skips -C <path>,\n -c <cfg>, and other leading flags). None if `tool` isn't the invoked command.\"\"\"\n try:\n i = tokens.index(tool) + 1\n except ValueError:\n return None, []\n while i < len(tokens):\n t = tokens[i]\n if t in (\"-C\", \"-c\", \"--git-dir\", \"--work-tree\"):\n i += 2 # flag that consumes the next token\n continue\n if t.startswith(\"-\"):\n i += 1\n continue\n return t, tokens[i + 1:]\n return None, []\n\n\ndef classify_bash(command):\n \"\"\"Return the milestone event for a git/gh command, else None. Tokenizes so a\n path like `git diff -- src/commit.ts` is not mistaken for a commit.\"\"\"\n try:\n tokens = shlex.split(command or \"\")\n except Exception:\n tokens = (command or \"\").split()\n\n verb, rest = _subcommand(tokens, \"git\")\n if verb == \"worktree\":\n sub = rest[0] if rest else \"\"\n if sub == \"add\":\n return \"worktree.created\"\n if sub == \"remove\":\n return \"worktree.removed\"\n elif verb == \"commit\":\n return \"commit.created\"\n elif verb == \"push\":\n return \"pushed\"\n\n verb, rest = _subcommand(tokens, \"gh\")\n if verb == \"pr\" and rest:\n if rest[0] == \"create\":\n return \"pr.opened\"\n if rest[0] == \"merge\":\n return \"pr.merged\"\n return None\n\n\ndef extract_url(tool_response):\n \"\"\"Pull the first https URL out of a Bash tool response (stdout).\"\"\"\n text = \"\"\n if isinstance(tool_response, dict):\n text = str(tool_response.get(\"stdout\") or tool_response.get(\"output\") or \"\")\n elif isinstance(tool_response, str):\n text = tool_response\n m = re.search(r\"https?://\\S+\", text)\n return m.group(0).rstrip(\").,\") if m else None\n\n\ndef _checklist_items(tool_input, kind):\n \"\"\"Extract normalized checklist items from tool input.\"\"\"\n if not isinstance(tool_input, dict):\n return []\n if kind == \"todos\":\n arr = tool_input.get(\"todos\") or []\n elif kind == \"tasks\":\n arr = tool_input.get(\"tasks\") or []\n if not arr and \"taskId\" in tool_input:\n arr = [tool_input]\n elif kind == \"plan\":\n arr = tool_input.get(\"plan\") or []\n else:\n return []\n if not isinstance(arr, list):\n return []\n\n items = []\n for t in arr:\n if not isinstance(t, dict):\n continue\n subject = (\n t.get(\"content\") or\n t.get(\"text\") or\n t.get(\"step\") or\n t.get(\"title\") or\n t.get(\"description\") or\n t.get(\"activeForm\") or\n \"\"\n )\n if not isinstance(subject, str):\n subject = str(subject)\n subject = subject.strip()\n item_id = t.get(\"id\") or t.get(\"taskId\") or subject\n if not item_id:\n continue\n status = str(t.get(\"status\", \"\") or \"\").lower()\n items.append({\"id\": item_id, \"subject\": subject, \"status\": status})\n return items\n\n\ndef _read_transcript_checklists(transcript_path, current_tool, current_items):\n \"\"\"Tail-read the session transcript and return the previous checklist state.\n\n The most recent checklist entry is assumed to be the current tool call if\n it carries the same ids; skip that one and return the next older checklist\n state (or None if there isn't one).\n \"\"\"\n if not transcript_path or not os.path.exists(transcript_path):\n return None\n try:\n size = os.path.getsize(transcript_path)\n start = max(0, size - 512 * 1024)\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n f.seek(start)\n if start > 0:\n f.readline() # drop a leading partial line\n lines = f.readlines()\n except Exception:\n return None\n\n current_ids = {str(item.get(\"id\")) for item in current_items if item.get(\"id\")}\n skipped_current = False\n for line in reversed(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n if isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n elif record.get(\"tool_name\"):\n tool_uses = [record]\n\n for tu in reversed(tool_uses):\n if not isinstance(tu, dict):\n continue\n name = tu.get(\"name\") or tu.get(\"tool_name\") or \"\"\n if name not in CHECKLIST_TOOLS:\n continue\n kind = CHECKLIST_TOOLS[name]\n args = tu.get(\"input\") or tu.get(\"tool_input\") or tu.get(\"arguments\") or {}\n if isinstance(args, str):\n try:\n args = json.loads(args)\n except Exception:\n continue\n items = _checklist_items(args, kind)\n if not items:\n continue\n ids = {str(item.get(\"id\")) for item in items if item.get(\"id\")}\n # Skip the most recent matching checklist entry once; that is the\n # current call already reflected in the transcript.\n if not skipped_current and name == current_tool and ids == current_ids:\n skipped_current = True\n continue\n return items\n return None\n\n\ndef _has_previous_task_create(transcript_path, current_tool_use_id=\"\"):\n \"\"\"Return True if the transcript contains a TaskCreate before the current one.\"\"\"\n if not transcript_path or not os.path.exists(transcript_path):\n return False\n try:\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n lines = f.readlines()\n except Exception:\n return False\n\n for line in reversed(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n msg = record.get(\"message\", {})\n if isinstance(msg, dict) and isinstance(msg.get(\"content\"), list):\n tool_uses = [\n c for c in msg[\"content\"]\n if isinstance(c, dict) and c.get(\"type\") == \"tool_use\"\n ]\n elif isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n\n for tu in reversed(tool_uses):\n if not isinstance(tu, dict):\n continue\n if (tu.get(\"name\") or tu.get(\"tool_name\")) == \"TaskCreate\":\n if tu.get(\"id\") != current_tool_use_id:\n return True\n return False\n\n\ndef _claude_task_state(transcript_path, exclude_task_id=None):\n \"\"\"Fold Claude TaskCreate/TaskUpdate calls into a task id -> subject/status map.\n\n TaskCreate provides the subject (and id via toolUseResult); TaskUpdate\n provides the status. If exclude_task_id is given, the last TaskUpdate for\n that id is skipped (it is the current call already reflected in the\n transcript).\n \"\"\"\n state = {}\n if not transcript_path or not os.path.exists(transcript_path):\n return state\n try:\n with open(transcript_path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n lines = f.readlines()\n except Exception:\n return state\n\n updates = [] # (task_id, status, line_index)\n for idx, line in enumerate(lines):\n line = line.strip()\n if not line:\n continue\n try:\n record = json.loads(line)\n except Exception:\n continue\n if not isinstance(record, dict):\n continue\n\n tool_uses = []\n msg = record.get(\"message\", {})\n if isinstance(msg, dict) and isinstance(msg.get(\"content\"), list):\n tool_uses = [\n c for c in msg[\"content\"]\n if isinstance(c, dict) and c.get(\"type\") == \"tool_use\"\n ]\n elif isinstance(record.get(\"tool_use\"), list):\n tool_uses = record[\"tool_use\"]\n elif record.get(\"name\"):\n tool_uses = [record]\n\n for tu in tool_uses:\n if not isinstance(tu, dict):\n continue\n name = tu.get(\"name\") or \"\"\n args = tu.get(\"input\") or tu.get(\"tool_input\") or {}\n if isinstance(args, str):\n try:\n args = json.loads(args)\n except Exception:\n continue\n if name == \"TaskCreate\":\n subject = (\n args.get(\"subject\") or\n args.get(\"description\") or\n args.get(\"title\") or\n \"\"\n )\n if subject:\n # Link subject to the tool_use id so the result can map it.\n tool_id = tu.get(\"id\")\n if tool_id:\n state.setdefault(\"__pending_subject\", {})[tool_id] = subject\n\n tool_result = record.get(\"toolUseResult\") or {}\n if not isinstance(tool_result, dict):\n continue\n task = tool_result.get(\"task\")\n if isinstance(task, dict):\n task_id = str(task.get(\"id\") or task.get(\"taskId\") or \"\")\n if task_id:\n state.setdefault(task_id, {})\n if task.get(\"subject\"):\n state[task_id][\"subject\"] = task[\"subject\"]\n if task.get(\"status\"):\n state[task_id][\"status\"] = str(task.get(\"status\")).lower()\n # Link any pending subject from the matching tool_use id.\n tool_use_id = record.get(\"tool_use_id\") or \"\"\n pending = state.get(\"__pending_subject\", {})\n if tool_use_id and tool_use_id in pending:\n state[task_id][\"subject\"] = pending[tool_use_id]\n task_id = str(tool_result.get(\"taskId\") or \"\")\n status_change = tool_result.get(\"statusChange\") or {}\n status = status_change.get(\"to\") or tool_result.get(\"status\")\n if task_id and status:\n state.setdefault(task_id, {})\n state[task_id][\"status\"] = str(status).lower()\n updates.append((task_id, str(status).lower(), idx))\n\n if exclude_task_id and updates:\n for i in range(len(updates) - 1, -1, -1):\n if updates[i][0] == exclude_task_id:\n del updates[i]\n break\n # Rebuild statuses from remaining updates.\n for task_id in list(state.keys()):\n if task_id.startswith(\"__\"):\n continue\n if \"subject\" not in state[task_id]:\n del state[task_id]\n else:\n state[task_id].pop(\"status\", None)\n for task_id, status, _ in updates:\n if task_id in state:\n state[task_id][\"status\"] = status\n\n # Drop tasks removed from the checklist (deleted/cancelled): they are gone\n # from the user-visible list, so they must not count toward the N/M total.\n for task_id in list(state.keys()):\n if task_id.startswith(\"__\"):\n continue\n if state[task_id].get(\"status\") in (\"deleted\", \"cancelled\", \"canceled\", \"removed\"):\n del state[task_id]\n\n # Drop internal bookkeeping.\n state.pop(\"__pending_subject\", None)\n return state\n\n\ndef _checklist_events(payload, hook_event):\n \"\"\"Return a list of (event, detail) tuples for checklist tool calls.\"\"\"\n if hook_event != \"PostToolUse\":\n return []\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n\n if tool_name == \"TaskCreate\":\n subject = (\n tool_input.get(\"subject\") or\n tool_input.get(\"description\") or\n tool_input.get(\"title\") or\n \"task\"\n )\n # Only announce the checklist on the very first task creation.\n current_tool_use_id = payload.get(\"tool_use_id\", \"\")\n transcript_path = payload.get(\"transcript_path\")\n if _has_previous_task_create(transcript_path, current_tool_use_id):\n return []\n return [(\"checklist.created\", subject)]\n\n if tool_name not in CHECKLIST_TOOLS:\n return []\n\n kind = CHECKLIST_TOOLS[tool_name]\n items = _checklist_items(tool_input, kind)\n if not items:\n return []\n\n # Full-list tools (TodoWrite, update_plan) send the whole checklist every\n # time. Per-task update tools (TaskUpdate) send only the changed item, so\n # totals and subject must be resolved from the previous full-list state.\n list_key = {\"todos\": \"todos\", \"tasks\": \"tasks\", \"plan\": \"plan\"}.get(kind)\n is_full_list = list_key is not None and list_key in tool_input\n\n previous = _read_transcript_checklists(\n payload.get(\"transcript_path\"), tool_name, items\n )\n\n if not is_full_list and previous:\n total = len(previous)\n previous_done = {\n str(i.get(\"id\")) for i in previous\n if i.get(\"status\") == \"completed\" and i.get(\"id\")\n }\n done_count = len(previous_done)\n events = []\n for item in items:\n if item.get(\"status\") != \"completed\":\n continue\n item_id = str(item.get(\"id\"))\n if item_id in previous_done:\n continue\n subject = item.get(\"subject\")\n if not subject:\n for p in previous:\n if str(p.get(\"id\")) == item_id and p.get(\"subject\"):\n subject = p[\"subject\"]\n break\n if not subject:\n subject = \"task\"\n done_count += 1\n events.append((\"task.completed\", f\"{subject} {done_count}/{total} done\"))\n return events\n\n # For TaskUpdate without a previous full-list state, fold the Claude\n # TaskCreate/TaskUpdate history to resolve subject and N/M.\n if tool_name == \"TaskUpdate\" and previous is None:\n task_id = str(items[0].get(\"id\")) if items else \"\"\n task_state = _claude_task_state(\n payload.get(\"transcript_path\"), exclude_task_id=task_id\n )\n if task_state:\n total = len(task_state)\n previous_done = {\n tid for tid, info in task_state.items()\n if info.get(\"status\") == \"completed\"\n }\n done_count = len(previous_done)\n if task_id in task_state and items[0].get(\"status\") == \"completed\" and task_id not in previous_done:\n subject = task_state[task_id].get(\"subject\") or \"task\"\n return [(\"task.completed\", f\"{subject} {done_count + 1}/{total} done\")]\n return []\n\n total = len(items)\n completed = [i for i in items if i.get(\"status\") == \"completed\"]\n\n if previous is None:\n # First checklist call in this session.\n events = []\n events.append((\"checklist.created\", f\"{total} task{'s' if total != 1 else ''}\"))\n for item in completed:\n events.append((\n \"task.completed\",\n f\"{item['subject']} {len(completed)}/{total} done\",\n ))\n return events\n\n previous_done = {\n str(i.get(\"id\")) for i in previous\n if i.get(\"status\") == \"completed\" and i.get(\"id\")\n }\n newly_completed = [\n i for i in completed\n if str(i.get(\"id\")) not in previous_done\n ]\n if not newly_completed:\n return []\n\n done_count = len(completed)\n events = []\n for item in newly_completed:\n events.append((\n \"task.completed\",\n f\"{item['subject']} {done_count}/{total} done\",\n ))\n return events\n\n\ndef _make_record(event, detail, tool_name):\n tier = \"milestone\" if event in MILESTONE_EVENTS else \"activity\"\n record = {\n \"v\": 1,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"event\": event,\n \"tier\": tier,\n }\n if detail:\n record[\"detail\"] = detail\n record[\"tool\"] = tool_name\n return record\n\n\ndef build_event(payload, hook_event):\n tool_name = payload.get(\"tool_name\", \"\")\n tool_input = payload.get(\"tool_input\", {}) or {}\n tool_response = payload.get(\"tool_response\", {})\n\n # Checklist completions first -- cheap guard above already filtered tool name.\n checklist = _checklist_events(payload, hook_event)\n if checklist:\n return [_make_record(event, detail, tool_name) for event, detail in checklist], tool_name\n\n event = None\n detail = None\n url = None\n\n if hook_event == \"PreToolUse\":\n if tool_name == \"ExitPlanMode\":\n event = \"plan.created\"\n detail = first_line(tool_input.get(\"plan\", \"\")) or \"plan presented\"\n elif tool_name == \"Task\":\n event = \"subagent.spawned\"\n role = tool_input.get(\"subagent_type\") or \"agent\"\n desc = tool_input.get(\"description\") or tool_input.get(\"prompt\") or \"\"\n detail = (role + \": \" + first_line(desc)).strip(\": \").strip()\n elif hook_event == \"PostToolUse\":\n if tool_name == \"Bash\":\n event = classify_bash(tool_input.get(\"command\", \"\"))\n if event:\n detail = first_line(tool_input.get(\"command\", \"\"))\n url = extract_url(tool_response)\n elif tool_name in (\"Write\", \"Edit\", \"MultiEdit\"):\n fp = tool_input.get(\"file_path\") or tool_input.get(\"path\") or \"\"\n # A freshly-written deliverable is a milestone; edits and code\n # writes stay routine and collapse to a count.\n if tool_name == \"Write\" and is_artifact(fp):\n event = \"artifact.created\"\n else:\n event = \"file.edited\"\n detail = os.path.basename(fp) if fp else tool_name\n\n if not event:\n return [], tool_name\n\n record = _make_record(event, detail, tool_name)\n if url:\n record[\"url\"] = url\n return [record], tool_name\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate -- only the top-level agent logs.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n hook_event = payload.get(\"hook_event_name\", \"\")\n records, tool_name = build_event(payload, hook_event)\n if not records:\n return\n\n safe_session = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id) or \"unknown\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n activity_dir = os.path.join(home, \".agents\", \".history\", \"activity\")\n target = os.path.join(activity_dir, safe_session + \".jsonl\")\n\n # Identity (mirrors 10-feed-publish.py).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", hostname.split(\".\")[0].strip().lower()) or \"unknown\"\n\n for record in records:\n record[\"sessionId\"] = session_id\n record[\"mailboxId\"] = mailbox_id\n record[\"host\"] = host\n record[\"runtime\"] = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n if cwd:\n record[\"cwd\"] = cwd\n agent = os.environ.get(\"AGENTS_AGENT_NAME\") or \"claude\"\n record[\"agent\"] = agent\n\n try:\n os.makedirs(activity_dir, exist_ok=True)\n # Cap growth: once over the size limit, keep only milestones.\n try:\n over_limit = os.path.getsize(target) > MAX_LOG_BYTES\n except OSError:\n over_limit = False\n with open(target, \"a\") as f:\n for record in records:\n if over_limit and record.get(\"tier\") != \"milestone\":\n continue\n f.write(json.dumps(record) + \"\\n\")\n except Exception:\n pass # fail open\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
124
125
  /** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
125
126
  export declare const ACTIVITY_HOOK_DEFINITIONS: Record<string, Record<string, unknown>>;
126
127
  /**