@phnx-labs/agents-cli 1.20.90 → 1.20.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/exec.d.ts +14 -0
  9. package/dist/commands/exec.js +144 -14
  10. package/dist/commands/feed.js +77 -4
  11. package/dist/commands/hooks.js +22 -6
  12. package/dist/commands/perf.d.ts +14 -0
  13. package/dist/commands/perf.js +221 -0
  14. package/dist/commands/projects.d.ts +12 -0
  15. package/dist/commands/projects.js +358 -0
  16. package/dist/commands/routines.js +30 -24
  17. package/dist/commands/secrets.d.ts +43 -4
  18. package/dist/commands/secrets.js +217 -32
  19. package/dist/commands/send.d.ts +5 -1
  20. package/dist/commands/send.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +15 -0
  22. package/dist/commands/sessions-picker.js +107 -13
  23. package/dist/commands/sessions-resume.d.ts +2 -0
  24. package/dist/commands/sessions-resume.js +9 -1
  25. package/dist/commands/sessions.d.ts +10 -5
  26. package/dist/commands/sessions.js +65 -27
  27. package/dist/index.js +19 -3
  28. package/dist/lib/activity.d.ts +80 -13
  29. package/dist/lib/activity.js +418 -74
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/catchup.d.ts +105 -0
  33. package/dist/lib/catchup.js +160 -0
  34. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  35. package/dist/lib/channels/providers/desktop.js +132 -0
  36. package/dist/lib/channels/providers/index.js +2 -0
  37. package/dist/lib/daemon.js +74 -13
  38. package/dist/lib/devices/registry.d.ts +14 -0
  39. package/dist/lib/devices/registry.js +37 -0
  40. package/dist/lib/events.d.ts +12 -0
  41. package/dist/lib/events.js +122 -9
  42. package/dist/lib/exec.js +10 -0
  43. package/dist/lib/feed-broadcast.d.ts +47 -0
  44. package/dist/lib/feed-broadcast.js +65 -1
  45. package/dist/lib/feed-post.d.ts +10 -0
  46. package/dist/lib/feed-post.js +9 -3
  47. package/dist/lib/feed.d.ts +47 -1
  48. package/dist/lib/feed.js +38 -0
  49. package/dist/lib/hooks/cache.d.ts +2 -0
  50. package/dist/lib/hooks/cache.js +24 -4
  51. package/dist/lib/hosts/remote-cmd.js +4 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  54. package/dist/lib/menubar/install-menubar.js +20 -6
  55. package/dist/lib/overdue.d.ts +14 -0
  56. package/dist/lib/overdue.js +37 -1
  57. package/dist/lib/perf/db.d.ts +25 -0
  58. package/dist/lib/perf/db.js +290 -0
  59. package/dist/lib/perf/spool.d.ts +18 -0
  60. package/dist/lib/perf/spool.js +79 -0
  61. package/dist/lib/perf/types.d.ts +45 -0
  62. package/dist/lib/perf/types.js +2 -0
  63. package/dist/lib/project-key.d.ts +44 -0
  64. package/dist/lib/project-key.js +79 -0
  65. package/dist/lib/project-root.js +16 -0
  66. package/dist/lib/project-status.d.ts +69 -0
  67. package/dist/lib/project-status.js +101 -0
  68. package/dist/lib/projects.d.ts +138 -0
  69. package/dist/lib/projects.js +301 -0
  70. package/dist/lib/remote-agents-json.d.ts +9 -0
  71. package/dist/lib/remote-agents-json.js +11 -5
  72. package/dist/lib/routines-project.js +6 -0
  73. package/dist/lib/routines.d.ts +30 -1
  74. package/dist/lib/routines.js +11 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/list-filter.d.ts +94 -0
  78. package/dist/lib/secrets/list-filter.js +245 -0
  79. package/dist/lib/session/bash-command.d.ts +53 -0
  80. package/dist/lib/session/bash-command.js +364 -0
  81. package/dist/lib/session/digest.d.ts +13 -0
  82. package/dist/lib/session/digest.js +48 -1
  83. package/dist/lib/session/discover.d.ts +1 -2
  84. package/dist/lib/session/discover.js +7 -24
  85. package/dist/lib/session/highlights.d.ts +82 -0
  86. package/dist/lib/session/highlights.js +251 -0
  87. package/dist/lib/session/parse.js +23 -1
  88. package/dist/lib/session/relative-time.d.ts +37 -0
  89. package/dist/lib/session/relative-time.js +96 -8
  90. package/dist/lib/session/remote-list.js +5 -2
  91. package/dist/lib/session/render.d.ts +9 -9
  92. package/dist/lib/session/render.js +112 -73
  93. package/dist/lib/session/types.d.ts +4 -1
  94. package/dist/lib/ssh-exec.d.ts +6 -0
  95. package/dist/lib/ssh-exec.js +10 -1
  96. package/dist/lib/startup/command-registry.d.ts +2 -0
  97. package/dist/lib/startup/command-registry.js +4 -0
  98. package/dist/lib/state.d.ts +11 -0
  99. package/dist/lib/state.js +16 -0
  100. package/dist/lib/terminal/backends/index.d.ts +10 -2
  101. package/dist/lib/terminal/backends/index.js +14 -2
  102. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  103. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  104. package/dist/lib/terminal/index.d.ts +2 -1
  105. package/dist/lib/terminal/index.js +2 -1
  106. package/dist/lib/terminal/preferred.d.ts +89 -0
  107. package/dist/lib/terminal/preferred.js +87 -0
  108. package/dist/lib/terminal/run-surface.d.ts +82 -0
  109. package/dist/lib/terminal/run-surface.js +146 -0
  110. package/dist/lib/terminal/types.d.ts +1 -1
  111. package/dist/lib/types.d.ts +1 -1
  112. package/package.json +4 -1
@@ -36,6 +36,7 @@ export function registerSessionsResumeCommand(sessionsCmd) {
36
36
  .option('--ghostty', 'Force the Ghostty backend')
37
37
  .option('--tmux', 'Force the tmux backend')
38
38
  .option('--vscodium', 'Force the VSCodium agent-terminal backend (swarm-ext)')
39
+ .option('--terminal-app', 'Force macOS Terminal.app (no split support — panes become tabs)')
39
40
  .option('--splits', 'Pack two sessions side by side per tab (default: one tab per session)');
40
41
  setHelpSections(cmd, {
41
42
  examples: `
@@ -156,6 +157,12 @@ async function sessionsResumeAction(query, options) {
156
157
  // recovery; callers can explicitly opt into pairs of side-by-side panes.
157
158
  const packing = resolveResumePacking(options);
158
159
  const where = options.host ? `${backend} on ${options.host}` : backend;
160
+ // Terminal.app has no scriptable split, so its buildSplit opens a tab. Say so
161
+ // when the user actually asked for panes — the layout silently not happening
162
+ // is worse than one line of warning.
163
+ if (backend === 'terminal' && packing === 'two-per-tab') {
164
+ console.log(chalk.yellow('Terminal.app cannot split panes — opening one tab per session instead.'));
165
+ }
159
166
  console.log(chalk.gray(`Opening ${items.length} session${items.length === 1 ? '' : 's'} in ${where} (${packing})…`));
160
167
  const results = await openSurfaces(items.map((it) => ({ cwd: it.cwd, command: it.command })), { backend, host: options.host, packing });
161
168
  let opened = 0;
@@ -185,7 +192,8 @@ async function resolveBackend(options, ctx, count) {
185
192
  : options.ghostty ? 'ghostty'
186
193
  : options.tmux ? 'tmux'
187
194
  : options.vscodium ? 'vscodium-agent'
188
- : undefined;
195
+ : options.terminalApp ? 'terminal'
196
+ : undefined;
189
197
  if (forced)
190
198
  return forced;
191
199
  // Remote defaults to tmux (headless, no GUI session assumptions); override with a backend flag.
@@ -1,4 +1,5 @@
1
1
  import { type Command } from 'commander';
2
+ import { type ProjectDef } from '../lib/projects.js';
2
3
  import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types.js';
3
4
  import { type ActiveSession } from '../lib/session/active.js';
4
5
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
@@ -351,11 +352,15 @@ export declare function teamBadge(session: SessionMeta): {
351
352
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
352
353
  export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns, favorite?: boolean): string;
353
354
  /**
354
- * Group key for the overview: prefer the indexed project name; else fold the cwd
355
- * to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
356
- * repo, and a monorepo subdir falls back to its leaf dir basename. Pure.
355
+ * Group key for the overview: resolve the cwd through the same canonical
356
+ * resolver the `agents activity` timeline groups by — a defined project's name
357
+ * when `defs` contains the cwd (multi-repo projects read as one group), else
358
+ * the repo-level key, so a monorepo subdir (`<repo>/apps/cli`) reads as
359
+ * `<repo>` in both views. Falls back to the indexed project name (stamped at
360
+ * scan time) when the cwd carries nothing usable, e.g. a remote path this
361
+ * machine cannot see still folds by basename through the same resolver.
357
362
  */
358
- export declare function overviewProjectKey(s: Pick<SessionMeta, 'project' | 'cwd'>): string;
363
+ export declare function overviewProjectKey(s: Pick<SessionMeta, 'project' | 'cwd'>, defs?: ProjectDef[]): string;
359
364
  export interface OverviewGroup {
360
365
  key: string;
361
366
  total: number;
@@ -369,7 +374,7 @@ export interface OverviewGroup {
369
374
  * are ordered by their most-recent session so the newest-active project leads.
370
375
  * `perProjectCap = Infinity` expands every group. Pure — unit-tested.
371
376
  */
372
- export declare function buildOverviewGroups(pool: SessionMeta[], perProjectCap: number): {
377
+ export declare function buildOverviewGroups(pool: SessionMeta[], perProjectCap: number, defs?: ProjectDef[]): {
373
378
  groups: OverviewGroup[];
374
379
  projectCount: number;
375
380
  };
@@ -14,6 +14,8 @@ import { spawn } from 'child_process';
14
14
  import { Option } from 'commander';
15
15
  import chalk from 'chalk';
16
16
  import { truncate, padRight } from '../lib/format.js';
17
+ import { resolveProjectKey } from '../lib/project-key.js';
18
+ import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
17
19
  import ora from 'ora';
18
20
  import { SESSION_AGENTS } from '../lib/session/types.js';
19
21
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
@@ -32,7 +34,7 @@ import { findSessionsById, querySessions } from '../lib/session/db.js';
32
34
  import { filterTeamSessions, safeTeamText } from '../lib/session/team-filter.js';
33
35
  import { parseSession } from '../lib/session/parse.js';
34
36
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
35
- import { formatRelativeTime } from '../lib/session/relative-time.js';
37
+ import { formatRelativeTime, sessionAgeParts } from '../lib/session/relative-time.js';
36
38
  import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList, linkPath, linkUrl, shortenModel } from '../lib/session/render.js';
37
39
  import { linearIssueUrl } from '../lib/session/linear.js';
38
40
  import { renderMarkdown } from '../lib/markdown.js';
@@ -1768,6 +1770,33 @@ function metaSignals(s) {
1768
1770
  ticket: s.ticketId ? { id: s.ticketId } : undefined,
1769
1771
  };
1770
1772
  }
1773
+ /**
1774
+ * Narrowest topic a row is willing to render. The time cell drops its creation
1775
+ * field rather than squeeze the topic past this — a row that wraps is worse than
1776
+ * a row missing one field.
1777
+ */
1778
+ const MIN_TOPIC_W = 16;
1779
+ /**
1780
+ * The trailing time cell, as two fields: `3d → 2 hours ago` — when the session
1781
+ * was created, then when it was last active. One label answers neither "is this
1782
+ * the old session I'm looking for" nor "how long did it run", since a session
1783
+ * touched an hour ago may have started last week.
1784
+ *
1785
+ * Collapses to last-activity alone when the session ran under a minute (both
1786
+ * halves would name the same moment) or when `topicSlack` — the columns the row
1787
+ * has left for its topic — cannot spare the extra width. `extraW` is what the
1788
+ * creation field cost, for the caller to take off the topic budget.
1789
+ */
1790
+ function timeCell(age, topicSlack) {
1791
+ const lastOnly = { plain: age.last, text: chalk.gray(age.last), extraW: 0 };
1792
+ if (!age.created)
1793
+ return lastOnly;
1794
+ const prefix = `${age.created} → `;
1795
+ const extraW = stringWidth(prefix);
1796
+ if (topicSlack - extraW < MIN_TOPIC_W)
1797
+ return lastOnly;
1798
+ return { plain: prefix + age.last, text: chalk.dim(prefix) + chalk.gray(age.last), extraW };
1799
+ }
1771
1800
  /** One flat table row:
1772
1801
  * shortId · agent · version · model · project · [glyph] label·doing · [ticket] · [wt] · time
1773
1802
  * `doing` is the live preview when running, else the topic. The `ticket` column
@@ -1776,7 +1805,7 @@ function metaSignals(s) {
1776
1805
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
1777
1806
  export function flatSessionRow(session, live, showTicket = false, cols = {}, favorite = false) {
1778
1807
  const agentColor = colorAgent(session.agent);
1779
- const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
1808
+ const age = sessionAgeParts(session.timestamp, session.lastActivity);
1780
1809
  const project = session.project || '-';
1781
1810
  const tag = originTag(session) || teamTag(session);
1782
1811
  const label = session.label;
@@ -1813,12 +1842,15 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}, fav
1813
1842
  // Same conditional 2 cells as the picker's marker, for the same reason.
1814
1843
  const favW = cols.showFavorite ? 2 : 0;
1815
1844
  const favCell = cols.showFavorite ? (favorite ? chalk.yellow('★ ') : ' ') : '';
1816
- const fixedW = favW + (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + team.width + stringWidth(when) + 1;
1817
- const modelSlack = width - fixedW - 16;
1845
+ // Sized against the last-activity label alone, so the creation field is an
1846
+ // additive decision the row makes only once it knows what space is left.
1847
+ const fixedW = favW + (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + team.width + stringWidth(age.last) + 1;
1848
+ const modelSlack = width - fixedW - MIN_TOPIC_W;
1818
1849
  const modelW = requestedModelW <= modelSlack
1819
1850
  ? requestedModelW
1820
1851
  : modelSlack >= PICKER_MODEL_MIN ? modelSlack : 0;
1821
- const topicW = Math.max(16, width - fixedW - modelW);
1852
+ const when = timeCell(age, width - fixedW - modelW);
1853
+ const topicW = Math.max(MIN_TOPIC_W, width - fixedW - modelW - when.extraW);
1822
1854
  return (favCell +
1823
1855
  chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
1824
1856
  agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
@@ -1832,12 +1864,12 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}, fav
1832
1864
  renderTopicCell(label, doing, '', topicW, topicW) +
1833
1865
  ticketCell +
1834
1866
  (wt ? wt + ' ' : '') +
1835
- chalk.gray(when));
1867
+ when.text);
1836
1868
  }
1837
1869
  /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
1838
1870
  function treeSessionRow(session, live) {
1839
1871
  const agentColor = colorAgent(session.agent);
1840
- const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
1872
+ const age = sessionAgeParts(session.timestamp, session.lastActivity);
1841
1873
  const tag = originTag(session) || teamTag(session);
1842
1874
  const label = session.label;
1843
1875
  const { glyph, preview } = liveGlyphAndPreview(live);
@@ -1853,7 +1885,9 @@ function treeSessionRow(session, live) {
1853
1885
  const head = label ? `${label} · ${topic}` : topic;
1854
1886
  const { cell: statusCell, width: statusW } = liveStatusCell(live);
1855
1887
  const glyphW = glyph ? 2 : 0;
1856
- const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - glyphW - statusW - badgeW - team.width - stringWidth(when) - 1);
1888
+ const baseTopicW = terminalWidth() - (2 + 9 + 8) - glyphW - statusW - badgeW - team.width - stringWidth(age.last) - 1;
1889
+ const when = timeCell(age, baseTopicW);
1890
+ const topicW = Math.max(12, baseTopicW - when.extraW);
1857
1891
  return (' ' +
1858
1892
  chalk.dim(padToWidth(session.shortId, 9)) +
1859
1893
  agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
@@ -1862,7 +1896,7 @@ function treeSessionRow(session, live) {
1862
1896
  statusCell +
1863
1897
  teamSeg +
1864
1898
  padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
1865
- ' ' + chalk.gray(when));
1899
+ ' ' + when.text);
1866
1900
  }
1867
1901
  /**
1868
1902
  * Live-session index for enriching the default listing, or undefined when
@@ -1884,21 +1918,21 @@ async function maybeLiveIndex(options) {
1884
1918
  }
1885
1919
  }
1886
1920
  /**
1887
- * Group key for the overview: prefer the indexed project name; else fold the cwd
1888
- * to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
1889
- * repo, and a monorepo subdir falls back to its leaf dir basename. Pure.
1921
+ * Group key for the overview: resolve the cwd through the same canonical
1922
+ * resolver the `agents activity` timeline groups by — a defined project's name
1923
+ * when `defs` contains the cwd (multi-repo projects read as one group), else
1924
+ * the repo-level key, so a monorepo subdir (`<repo>/apps/cli`) reads as
1925
+ * `<repo>` in both views. Falls back to the indexed project name (stamped at
1926
+ * scan time) when the cwd carries nothing usable, e.g. a remote path this
1927
+ * machine cannot see still folds by basename through the same resolver.
1890
1928
  */
1891
- export function overviewProjectKey(s) {
1929
+ export function overviewProjectKey(s, defs) {
1930
+ const resolved = defs?.length ? resolveProjectNameForCwd(s.cwd, defs) : resolveProjectKey(s.cwd);
1931
+ if (resolved)
1932
+ return resolved;
1892
1933
  if (s.project && s.project.trim())
1893
1934
  return s.project.trim();
1894
- const cwd = (s.cwd ?? '').replace(/\/+$/, '');
1895
- if (!cwd)
1896
- return '(no project)';
1897
- const wt = cwd.match(/\/([^/]+)\/\.agents\/worktrees\//);
1898
- if (wt)
1899
- return wt[1];
1900
- const parts = cwd.split('/');
1901
- return parts[parts.length - 1] || cwd;
1935
+ return '(no project)';
1902
1936
  }
1903
1937
  /**
1904
1938
  * Turn a recency-descending pool into project groups: each group shows its
@@ -1906,10 +1940,10 @@ export function overviewProjectKey(s) {
1906
1940
  * are ordered by their most-recent session so the newest-active project leads.
1907
1941
  * `perProjectCap = Infinity` expands every group. Pure — unit-tested.
1908
1942
  */
1909
- export function buildOverviewGroups(pool, perProjectCap) {
1943
+ export function buildOverviewGroups(pool, perProjectCap, defs) {
1910
1944
  const byKey = new Map();
1911
1945
  for (const s of pool) {
1912
- const k = overviewProjectKey(s);
1946
+ const k = overviewProjectKey(s, defs);
1913
1947
  (byKey.get(k) ?? byKey.set(k, []).get(k)).push(s);
1914
1948
  }
1915
1949
  const cap = Math.max(1, perProjectCap);
@@ -1927,7 +1961,7 @@ export function buildOverviewGroups(pool, perProjectCap) {
1927
1961
  * `· N more` per project and a `+N more projects` when the list is capped.
1928
1962
  */
1929
1963
  function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
1930
- const { groups } = buildOverviewGroups(pool, opts.expand ? Infinity : opts.perProjectCap);
1964
+ const { groups } = buildOverviewGroups(pool, opts.expand ? Infinity : opts.perProjectCap, listProjectDefs());
1931
1965
  const shownGroups = opts.expand ? groups : groups.slice(0, OVERVIEW_MAX_PROJECTS);
1932
1966
  const hiddenProjects = groups.length - shownGroups.length;
1933
1967
  const total = pool.length;
@@ -2252,7 +2286,7 @@ export function liveHostLabel(a) {
2252
2286
  }
2253
2287
  export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite = false, live) {
2254
2288
  const agentColor = colorAgent(s.agent);
2255
- const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
2289
+ const age = sessionAgeParts(s.timestamp, s.lastActivity);
2256
2290
  const project = s.project || '-';
2257
2291
  // SSH-launch origin (live rows only): mirrors the flat listing's `ssh←<device>`
2258
2292
  // badge. Rendered as its OWN red segment before the topic cell — folding it into
@@ -2306,7 +2340,11 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite
2306
2340
  const status = cols.showStatus ? liveStatusCell(live) : { cell: '', width: 0 };
2307
2341
  const statusW = cols.showStatus ? LIVE_STATUS_W : 0;
2308
2342
  const statusCell = cols.showStatus ? (status.cell || ' '.repeat(LIVE_STATUS_W)) : '';
2309
- const topicW = Math.max(16, terminalWidth() - gutter - favW - statusW - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - team.width - stringWidth(when) - 1);
2343
+ // Sized against the last-activity label alone; the creation field is then an
2344
+ // additive decision made against whatever width is left (see the flat listing).
2345
+ const baseTopicW = terminalWidth() - gutter - favW - statusW - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - team.width - stringWidth(age.last) - 1;
2346
+ const when = timeCell(age, baseTopicW);
2347
+ const topicW = Math.max(MIN_TOPIC_W, baseTopicW - when.extraW);
2310
2348
  return (favCell +
2311
2349
  // Truncated, not just padded: an indexed shortId is always 8 chars, but a
2312
2350
  // live row with no session id is named by its pid or cloud task, which can
@@ -2323,7 +2361,7 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', favorite
2323
2361
  renderTopicCell(label, topic, query, topicW, topicW) +
2324
2362
  ticketCell +
2325
2363
  (wt ? wt + ' ' : '') +
2326
- chalk.gray(when));
2364
+ when.text);
2327
2365
  }
2328
2366
  /** Hints rotated above the picker so the flags/features stay discoverable. */
2329
2367
  const PICKER_TIPS = [
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadPerf, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadHq, loadFeed, loadActivity, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
@@ -173,11 +173,24 @@ program.hook('postAction', (_thisCommand, actionCommand) => {
173
173
  if (parts.length === 0)
174
174
  return;
175
175
  const started = auditStarts.get(actionCommand);
176
+ const durationMs = started !== undefined ? Date.now() - started : undefined;
177
+ const command = parts.join(' ');
176
178
  emit('command.end', {
177
179
  module: parts[0],
178
- command: parts.join(' '),
179
- ...(started !== undefined ? { durationMs: Date.now() - started } : {}),
180
+ command,
181
+ ...(durationMs !== undefined ? { durationMs } : {}),
180
182
  });
183
+ // Disposable perf warehouse — fail-soft spool append (no SQLite on this path).
184
+ if (durationMs !== undefined && parts[0] !== 'perf') {
185
+ void import('./lib/perf/spool.js').then(({ recordSample }) => {
186
+ recordSample({
187
+ kind: 'command.end',
188
+ label: command,
189
+ durationMs,
190
+ cwd: process.cwd(),
191
+ });
192
+ }).catch(() => { });
193
+ }
181
194
  }
182
195
  catch {
183
196
  // Best-effort completion record; the start line is the durable audit fact.
@@ -269,6 +282,7 @@ Credentials and profiles:
269
282
  Diagnostics:
270
283
  doctor [agent[@version]] Diagnose CLI availability, sync status, and resource divergence; --check for the CI drift gate
271
284
  usage [agent] Show rate-limit and quota usage per agent
285
+ perf Latency rollups (hooks, commands, runs) from the disposable perf warehouse
272
286
 
273
287
  Config sync:
274
288
  drive Sync session history across machines via rsync
@@ -857,6 +871,7 @@ async function registerAllEagerCommands() {
857
871
  await reg(loadPackages);
858
872
  await reg(loadRoutines);
859
873
  await reg(loadMonitors);
874
+ await reg(loadProjects);
860
875
  await reg(loadRun);
861
876
  await reg(loadFork);
862
877
  await reg(loadDefaults);
@@ -885,6 +900,7 @@ async function registerAllEagerCommands() {
885
900
  await reg(loadFactory);
886
901
  await reg(loadUsage);
887
902
  await reg(loadCost);
903
+ await reg(loadPerf);
888
904
  await reg(loadOutput);
889
905
  await reg(loadBudget);
890
906
  await reg(loadAlias);
@@ -1,10 +1,22 @@
1
1
  import type { EventRecord } from './events.js';
2
2
  /** Recognizable milestone events, ordered first in any activity lane. */
3
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
+ /** Bash-driven deliverables detected by command parsing. */
5
+ | 'video.rendered' | 'video.converted' | 'image.upscaled' | 'metadata.edited'
4
6
  /** Deliberate agent-authored progress post (`agents feed post`). */
5
- | 'status.posted';
7
+ | 'status.posted'
8
+ /**
9
+ * The same post, but the agent is STUCK (`agents feed post --blocked`).
10
+ *
11
+ * A distinct event rather than a flag on `status.posted` because it is a
12
+ * different kind of thing in the stream: a benign update is history the
13
+ * moment it lands, while a blocked post stays open until someone answers it.
14
+ * Readers that show "what needs a human" select on this; readers that show
15
+ * "what happened" get both.
16
+ */
17
+ | 'status.blocked';
6
18
  /** Routine activity events, collapsed to counts by readers. */
7
- export type ActivityKind = 'file.edited';
19
+ export type ActivityKind = 'file.edited' | 'bash.executed';
8
20
  export type ActivityEventKind = MilestoneEvent | ActivityKind;
9
21
  export type ActivityTier = 'milestone' | 'activity';
10
22
  /** Well-known attachment kinds; the field is an open string, not an enum. */
@@ -74,6 +86,10 @@ export interface ActivityEvent {
74
86
  terminalId?: string;
75
87
  /** `$TMUX_PANE` at launch when recorded. */
76
88
  tmuxPane?: string;
89
+ /** Bash command taxonomy (set for bash.executed events). */
90
+ category?: string;
91
+ bashTool?: string;
92
+ bashAction?: string;
77
93
  /** Generic artifacts attached to a deliberate progress post. */
78
94
  attachments?: Attachment[];
79
95
  }
@@ -213,19 +229,29 @@ export interface ActivitySessionHint {
213
229
  project?: string | null;
214
230
  }
215
231
  /**
216
- * Resolve a stable project/repo name from a working directory. A worktree cwd
217
- * (`…/<repo>/.agents/worktrees/<slug>[/sub]`) resolves to the repo dir name so
218
- * a worktree session groups with its own repo; any other path resolves to its
219
- * basename. Pure — no filesystem access, so it works for remote events too.
232
+ * Resolve a stable project/repo name from a working directory the same fold
233
+ * the `agents sessions` overview groups by ({@link projectKeyFromCwd}), so a
234
+ * project reads identically in both views.
220
235
  */
221
236
  export declare function projectFromCwd(cwd?: string | null): string | undefined;
222
237
  /**
223
238
  * Join session facts (ticket / project / execution host) onto each event by
224
- * `sessionId`. A hint wins; otherwise pre-baked enriched fields (from a remote
225
- * peer that already enriched its own stream) are preserved, and project/host
226
- * fall back to what the event itself carries (`cwd`, `host`). Pure.
239
+ * `sessionId`. A hint wins; otherwise a CANONICAL def match (`canonicalProject`)
240
+ * upgrades whatever the event carries; otherwise pre-baked enriched fields (from
241
+ * a remote peer that already enriched its own stream) are preserved, and
242
+ * project/host fall back to what the event itself carries (`cwd`, `host`).
243
+ *
244
+ * The def match ranks above the pre-baked stamp because the stamp may be stale
245
+ * (posted before the def existed) or skewed (a peer whose defs haven't synced);
246
+ * the match is pure prefix compare against local defs, so a different-home
247
+ * peer's path simply doesn't match and its stamp is honored as before.
248
+ *
249
+ * `resolveProject` is how a caller reading its OWN machine's logs upgrades the
250
+ * cwd fold to real repository detection ({@link resolveProjectKey}); it defaults
251
+ * to the pure {@link projectFromCwd}, which is all a path from another machine
252
+ * can be resolved with. Pure given pure resolvers.
227
253
  */
228
- export declare function enrichActivityEvents(events: EnrichedActivityEvent[], hints: ActivitySessionHint[]): EnrichedActivityEvent[];
254
+ export declare function enrichActivityEvents(events: EnrichedActivityEvent[], hints: ActivitySessionHint[], resolveProject?: (cwd?: string | null) => string | undefined, canonicalProject?: (cwd?: string | null) => string | undefined): EnrichedActivityEvent[];
229
255
  /**
230
256
  * Validate + host-tag one peer's `activity --json` payload for the fan-out
231
257
  * merge. Mirrors {@link parseLine}: skip anything missing `event`/`sessionId`/
@@ -241,6 +267,22 @@ export declare function parseActivityPayload(stdout: string, machine: string): E
241
267
  * `mergeFeedBlocks`).
242
268
  */
243
269
  export declare function mergeActivityEvents(...groups: EnrichedActivityEvent[][]): EnrichedActivityEvent[];
270
+ /**
271
+ * Apply `--limit` to the events a reader will actually SHOW.
272
+ *
273
+ * The default view collapses routine work (`file.edited`) to a count and shows
274
+ * milestones individually, so a plain `slice(limit)` spends the whole budget on
275
+ * churn: one busy machine editing 40 files hid every other device's PRs behind
276
+ * a single `file edited ×40` line. Milestones therefore carry the cap, and the
277
+ * routine events that ride along are the ones newer than the last milestone
278
+ * kept — so the collapsed counts describe exactly the window on screen.
279
+ *
280
+ * With `all` (routine shown inline) every event is displayed, so the cap is the
281
+ * plain slice again. Input must be newest-first; output preserves that order.
282
+ */
283
+ export declare function capActivityEvents(events: EnrichedActivityEvent[], limit: number, opts?: {
284
+ all?: boolean;
285
+ }): EnrichedActivityEvent[];
244
286
  export type ActivityGroupBy = 'project' | 'device' | 'agent';
245
287
  /** One bucket of the grouped view. */
246
288
  export interface ActivityGroup {
@@ -267,14 +309,39 @@ export declare function groupActivity(events: EnrichedActivityEvent[], by: Activ
267
309
  * no-op. Pure.
268
310
  */
269
311
  export declare function filterActivityEvents(events: EnrichedActivityEvent[], filter: string): EnrichedActivityEvent[];
312
+ /**
313
+ * Narrow events to one resolved project, exact match on the enriched label
314
+ * (unlike {@link filterActivityEvents}'s cross-field substring). This is the
315
+ * `--project` flag: the label is canonical (a defined project's name) once the
316
+ * reader's resolver has run, so a multi-repo project matches as one bucket. Pure.
317
+ */
318
+ export declare function filterActivityByProject(events: EnrichedActivityEvent[], project: string): EnrichedActivityEvent[];
270
319
  /** One rendered enriched line: the base activity line + `· project · ticket` tags. */
271
320
  export declare function formatEnrichedActivityLine(ev: EnrichedActivityEvent, opts?: {
272
321
  showHost?: boolean;
273
322
  showProject?: boolean;
274
323
  indent?: string;
275
324
  }): string;
276
- /** Human header for one grouped bucket: `<label> · N events · M milestones`. */
277
- export declare function formatActivityGroupHeader(group: ActivityGroup): string;
325
+ /** Max device names named in a group header before the rest collapse to `+N`. */
326
+ export declare const GROUP_HEADER_DEVICE_LIMIT = 3;
327
+ /**
328
+ * The distinct machines a group's events ran on, most-active first then
329
+ * alphabetical — so a project header can say WHERE the work happened without
330
+ * sub-grouping the timeline. Prefers the session-joined `executionHost` (where
331
+ * the process really lives) over the raw emitting `host`. Pure.
332
+ */
333
+ export declare function activityGroupDevices(events: EnrichedActivityEvent[]): string[];
334
+ /**
335
+ * The trailing facts for one grouped bucket: `N events · M milestones`, plus
336
+ * the machines the work ran on when `showDevices` is set (redundant when the
337
+ * grouping dimension IS the device, so the caller decides). The device list is
338
+ * capped at {@link GROUP_HEADER_DEVICE_LIMIT} with a `+N` tail, so a project
339
+ * touched by a dozen boxes stays one scannable line. Separate from the label so
340
+ * a renderer can color the two differently without re-splitting a string.
341
+ */
342
+ export declare function formatActivityGroupMeta(group: ActivityGroup, opts?: {
343
+ showDevices?: boolean;
344
+ }): string;
278
345
  /**
279
346
  * The activity-log hook (Python), sibling to 10-feed-publish.py. Classifies
280
347
  * PreToolUse/PostToolUse payloads into activity events and appends one JSONL
@@ -285,7 +352,7 @@ export declare function formatActivityGroupHeader(group: ActivityGroup): string;
285
352
  * read-only tools never pay the hook cost. Fail-open: any error is swallowed so
286
353
  * a logging hiccup never blocks a tool call.
287
354
  */
288
- 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";
355
+ export declare const ACTIVITY_LOG_HOOK_SCRIPT: string;
289
356
  /** Hook manifest entries (agents.yaml shape) for the activity-log hook. */
290
357
  export declare const ACTIVITY_HOOK_DEFINITIONS: Record<string, Record<string, unknown>>;
291
358
  /**