@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
@@ -4,6 +4,19 @@
4
4
  * offline-cache banner (`remote.ts`) share one formatter — `sessions.ts` imports
5
5
  * `remote.ts`, so a back-import would cycle.
6
6
  */
7
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
8
+ /**
9
+ * Calendar label for a timestamp older than the relative window. A 2-digit year
10
+ * is appended outside the current year so "Jun 28" is never ambiguous across
11
+ * years (e.g. "Jun 28 '25").
12
+ */
13
+ function calendarLabel(thenMs, nowMs) {
14
+ const d = new Date(thenMs);
15
+ const label = `${MONTHS[d.getMonth()]} ${d.getDate()}`;
16
+ return d.getFullYear() === new Date(nowMs).getFullYear()
17
+ ? label
18
+ : `${label} '${String(d.getFullYear()).slice(-2)}`;
19
+ }
7
20
  export function formatRelativeTime(isoTimestamp) {
8
21
  const now = Date.now();
9
22
  const then = new Date(isoTimestamp).getTime();
@@ -21,12 +34,87 @@ export function formatRelativeTime(isoTimestamp) {
21
34
  return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
22
35
  if (diffDays < 7)
23
36
  return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
24
- // Older: show the date. Append a 2-digit year for anything outside the current
25
- // year so "Jun 28" is never ambiguous across years (e.g. "Jun 28 '25").
26
- const d = new Date(then);
27
- const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
28
- const label = `${months[d.getMonth()]} ${d.getDate()}`;
29
- return d.getFullYear() === new Date(now).getFullYear()
30
- ? label
31
- : `${label} '${String(d.getFullYear()).slice(-2)}`;
37
+ // Older: show the date.
38
+ return calendarLabel(then, now);
39
+ }
40
+ /**
41
+ * Compact age — `now`, `45m`, `3h`, `2d`, then the calendar date. This is the
42
+ * *creation* half of a session's time cell: it renders right next to the long
43
+ * last-activity label, and one row can afford one long form, not two.
44
+ */
45
+ export function formatCompactAge(isoTimestamp) {
46
+ const now = Date.now();
47
+ const then = new Date(isoTimestamp).getTime();
48
+ if (isNaN(then))
49
+ return isoTimestamp;
50
+ const diffMs = now - then;
51
+ const diffMin = Math.floor(diffMs / 60_000);
52
+ const diffHrs = Math.floor(diffMs / 3_600_000);
53
+ const diffDays = Math.floor(diffMs / 86_400_000);
54
+ if (diffMin < 1)
55
+ return 'now';
56
+ if (diffMin < 60)
57
+ return `${diffMin}m`;
58
+ if (diffHrs < 24)
59
+ return `${diffHrs}h`;
60
+ if (diffDays < 7)
61
+ return `${diffDays}d`;
62
+ return calendarLabel(then, now);
63
+ }
64
+ /**
65
+ * Shortest creation→last-activity gap worth rendering as two fields. Below it
66
+ * both halves name the same minute, so the row would carry a duplicate instead
67
+ * of information.
68
+ */
69
+ const SPAN_MIN_MS = 60_000;
70
+ /**
71
+ * Split a session's timestamps into the two fields a listing renders: when it
72
+ * was created and when it was last active. One last-activity label answers
73
+ * neither "is this the old session I'm looking for" nor "how long did it run" —
74
+ * a session touched an hour ago may have started last week.
75
+ */
76
+ export function sessionAgeParts(createdIso, lastActivityIso) {
77
+ const lastIso = lastActivityIso ?? createdIso;
78
+ const last = formatRelativeTime(lastIso);
79
+ const createdMs = new Date(createdIso).getTime();
80
+ const lastMs = new Date(lastIso).getTime();
81
+ if (isNaN(createdMs) || isNaN(lastMs) || lastMs - createdMs < SPAN_MIN_MS)
82
+ return { last };
83
+ return { created: formatCompactAge(createdIso), last };
84
+ }
85
+ /**
86
+ * Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
87
+ * milliseconds. Backs `--since` on `sessions`, `cost`, `teams`, and `output`, and
88
+ * `--unused` on `secrets list`.
89
+ *
90
+ * It lives here, in a module with no imports, rather than in `discover.ts` where
91
+ * it started: `discover.ts` loads `../sqlite.js`, so importing this one function
92
+ * from there pulls `node:sqlite` into the caller's module graph and Node prints
93
+ * `ExperimentalWarning: SQLite …` on stderr. That is invisible in a command that
94
+ * already touches the session DB, and a broken contract in one that doesn't —
95
+ * `monitors --json` asserts a clean stderr. `discover.ts` re-exports it, so
96
+ * existing importers are unaffected.
97
+ */
98
+ export function parseTimeFilter(input) {
99
+ // Units: m=minute, h=hour, d=day, w=week, mo=month(30d), y=year(365d). `mo`
100
+ // must precede the single-letter alternatives so "1mo" isn't read as "1m"+"o".
101
+ const relativeMatch = input.match(/^(\d+)(mo|[mhdwy])$/i);
102
+ if (relativeMatch) {
103
+ const value = parseInt(relativeMatch[1], 10);
104
+ const unit = relativeMatch[2].toLowerCase();
105
+ if (unit === 'm')
106
+ return Date.now() - value * 60_000;
107
+ if (unit === 'h')
108
+ return Date.now() - value * 3_600_000;
109
+ if (unit === 'd')
110
+ return Date.now() - value * 86_400_000;
111
+ if (unit === 'w')
112
+ return Date.now() - value * 7 * 86_400_000;
113
+ if (unit === 'mo')
114
+ return Date.now() - value * 30 * 86_400_000;
115
+ if (unit === 'y')
116
+ return Date.now() - value * 365 * 86_400_000;
117
+ }
118
+ const ts = new Date(input).getTime();
119
+ return Number.isNaN(ts) ? 0 : ts;
32
120
  }
@@ -18,7 +18,7 @@ import chalk from 'chalk';
18
18
  import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
19
  import { sshTargetFor } from '../devices/connect.js';
20
20
  import { resolveExplicitTargets } from '../devices/resolve-target.js';
21
- import { loadDevices, isControlDevice } from '../devices/registry.js';
21
+ import { loadDevices, isControlDevice, isDialableDevice } from '../devices/registry.js';
22
22
  import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
23
23
  import { machineId, normalizeHost } from './sync/config.js';
24
24
  import { NO_FANOUT_ENV } from './remote-active.js';
@@ -163,7 +163,10 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
163
163
  return { sessions: [], deviceCount: 0, unreachable: ['device registry'] };
164
164
  }
165
165
  for (const d of Object.values(reg)) {
166
- if (d.tailscale?.online !== true)
166
+ // Live SSH-probe verdict first, cached tailscale snapshot only as a
167
+ // fallback — see isDialableDevice. A manually-registered device has no
168
+ // tailscale peer entry, so gating on `online` alone hid its sessions.
169
+ if (!isDialableDevice(d))
167
170
  continue;
168
171
  if (normalizeHost(d.name) === self)
169
172
  continue;
@@ -7,10 +7,18 @@
7
7
  * transcript. Filtering by role and turn slicing is handled here as well.
8
8
  */
9
9
  import type { SessionEvent, SessionMeta } from './types.js';
10
+ import { unwrapCommand, bucketKey } from './bash-command.js';
10
11
  /**
11
12
  * Return absPath relative to cwd; fall back to ~/… then absolute.
12
13
  */
13
14
  export declare function relativeToCwd(absPath: string, cwd?: string): string;
15
+ /**
16
+ * Display form for a touched path: cwd-relative first; then collapse any
17
+ * `.agents/worktrees/<slug>` segment (in-cwd OR outside) to `⧉ <slug>/…` so
18
+ * group labels stay on one line instead of `~/src/…/.agents/worktrees/<slug>/…`;
19
+ * else home-collapse.
20
+ */
21
+ export declare function displayPath(absPath: string, cwd?: string): string;
14
22
  /**
15
23
  * Wrap a filesystem path label in an OSC 8 `file://` hyperlink when the terminal
16
24
  * supports it. Degrades to the plain label otherwise.
@@ -21,18 +29,11 @@ export declare function linkPath(absPath: string, label: string): string;
21
29
  * issue, …) when the terminal supports it. Degrades to the plain label otherwise.
22
30
  */
23
31
  export declare function linkUrl(url: string, label: string): string;
24
- /**
25
- * Unwrap wrapper prefixes to find the actual executable.
26
- */
27
- export declare function unwrapCommand(cmd: string): string;
28
32
  /**
29
33
  * Normalize a command so trivial flag/pipe variations collapse to the same key.
30
34
  */
31
35
  export declare function normalizeForDedup(cmd: string): string;
32
- /**
33
- * Return the bucket key for a command (used for grouping within a category).
34
- */
35
- export declare function bucketKey(cmd: string): string;
36
+ export { unwrapCommand, bucketKey };
36
37
  interface CmdRun {
37
38
  normalized: string;
38
39
  raw: string;
@@ -125,4 +126,3 @@ export declare function renderConversationMarkdown(events: SessionEvent[], opts?
125
126
  export declare function renderJson(events: SessionEvent[], meta?: SessionMeta, opts?: {
126
127
  redact?: boolean;
127
128
  }): string;
128
- export {};
@@ -13,6 +13,8 @@ import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
13
13
  import { renderMarkdown } from '../markdown.js';
14
14
  import { redactSecrets } from '../redact.js';
15
15
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from './digest.js';
16
+ import { classifyBashCommand, unwrapCommand, bucketKey } from './bash-command.js';
17
+ import { extractArtifacts, extractHooks, extractLinks, extractSkills } from './highlights.js';
16
18
  import { extractTodoProgressFromEvents } from './state.js';
17
19
  // ── Path helpers ──────────────────────────────────────────────────────────────
18
20
  /**
@@ -29,6 +31,31 @@ export function relativeToCwd(absPath, cwd) {
29
31
  }
30
32
  return absPath;
31
33
  }
34
+ /**
35
+ * Display form for a touched path: cwd-relative first; then collapse any
36
+ * `.agents/worktrees/<slug>` segment (in-cwd OR outside) to `⧉ <slug>/…` so
37
+ * group labels stay on one line instead of `~/src/…/.agents/worktrees/<slug>/…`;
38
+ * else home-collapse.
39
+ */
40
+ export function displayPath(absPath, cwd) {
41
+ const rel = relativeToCwd(absPath, cwd);
42
+ const norm = rel.replace(/\\/g, '/');
43
+ const wt = norm.match(/(^|\/)\.agents\/worktrees\/([^/]+)/);
44
+ if (wt) {
45
+ const after = norm.slice(norm.indexOf(wt[0]) + wt[0].length).replace(/^\//, '');
46
+ return after ? `⧉ ${wt[2]}/${after}` : `⧉ ${wt[2]}`;
47
+ }
48
+ return rel;
49
+ }
50
+ /** One checklist line with a status marker: `[x] done` / `[>] doing` / `[ ] todo`. */
51
+ function renderTodoMarker(item) {
52
+ const text = item.content;
53
+ if (item.status === 'completed')
54
+ return chalk.green('[x]') + ' ' + chalk.gray(text);
55
+ if (item.status === 'in_progress')
56
+ return chalk.yellow('[>]') + ' ' + chalk.white(text);
57
+ return chalk.gray('[ ]') + ' ' + chalk.white(text);
58
+ }
32
59
  /** Best-effort feature-detect for OSC 8 hyperlink support in the current TTY. */
33
60
  function supportsHyperlinks() {
34
61
  return Boolean(process.stdout.isTTY &&
@@ -56,28 +83,6 @@ export function linkUrl(url, label) {
56
83
  return supportsHyperlinks() ? osc8(url, label) : label;
57
84
  }
58
85
  // ── Command grouping ──────────────────────────────────────────────────────────
59
- /**
60
- * Unwrap wrapper prefixes to find the actual executable.
61
- */
62
- export function unwrapCommand(cmd) {
63
- const ssh = cmd.match(/^ssh\s+\S+\s+"(.+)"\s*(?:\|.*)?$/);
64
- if (ssh)
65
- return unwrapCommand(ssh[1]);
66
- const lead = cmd.match(/^(?:sudo|env\s+\S+=\S+|time)\s+(.+)/);
67
- if (lead)
68
- return unwrapCommand(lead[1]);
69
- // Strip shell-style leading env assignments: `PATH=/x CMD ...`, `FOO=bar BAR=baz CMD ...`
70
- const shellEnv = cmd.match(/^(?:[A-Z_][A-Z0-9_]*=\S+\s+)+(\S.*)$/);
71
- if (shellEnv)
72
- return unwrapCommand(shellEnv[1]);
73
- const cd = cmd.match(/^cd\s+\S+\s*&&\s*(.+)/);
74
- if (cd)
75
- return unwrapCommand(cd[1]);
76
- const npx = cmd.match(/^npx\s+(.+)/);
77
- if (npx)
78
- return unwrapCommand(npx[1]);
79
- return cmd;
80
- }
81
86
  /**
82
87
  * Normalize a command so trivial flag/pipe variations collapse to the same key.
83
88
  */
@@ -94,45 +99,35 @@ export function normalizeForDedup(cmd) {
94
99
  }
95
100
  return s.trim();
96
101
  }
97
- /** Command classification categories with signal levels for summary rendering. */
98
- const CATEGORIES = [
99
- { name: 'Probes', match: t => ['ls', 'cat', 'head', 'tail', 'wc', 'stat', 'file', 'which', 'tree', 'pwd'].includes(t), signal: 'low' },
100
- { name: 'Search', match: t => ['grep', 'rg', 'ag', 'fd', 'find'].includes(t), signal: 'low' },
101
- { name: 'Build/test', match: t => ['make', 'cargo', 'pytest', 'go', 'bun', 'npm', 'pnpm', 'yarn', 'tsc', 'vitest', 'tsx', 'node', 'python', 'python3', 'jest'].includes(t), signal: 'high' },
102
- { name: 'Install', match: t => ['brew', 'pip', 'apt', 'apk'].includes(t), signal: 'high' },
103
- { name: 'VCS', match: t => ['git', 'gh'].includes(t), signal: 'mid' },
104
- { name: 'HTTP', match: t => ['curl', 'wget', 'rush', 'http'].includes(t), signal: 'mid' },
105
- { name: 'Remote', match: t => ['ssh', 'scp', 'rsync'].includes(t), signal: 'mid' },
106
- { name: 'Shell', match: t => ['rm', 'mv', 'cp', 'mkdir', 'touch', 'echo', 'printf', 'chmod', 'ln', 'awk', 'sed', 'tee', 'xargs', 'for'].includes(t), signal: 'low' },
107
- { name: 'Wait', match: t => ['sleep', 'wait'].includes(t), signal: 'low' },
108
- ];
109
- /** CLI tools whose subcommand (second token) is included in the bucket key. */
110
- const TWO_LEVEL_TOKENS = new Set([
111
- 'git', 'gh', 'bun', 'npm', 'cargo', 'docker', 'kubectl', 'rush', 'openclaw', 'pnpm', 'yarn',
112
- ]);
113
- /**
114
- * Return the bucket key for a command (used for grouping within a category).
115
- */
116
- export function bucketKey(cmd) {
117
- const unwrapped = unwrapCommand(cmd);
118
- const tokens = unwrapped.trim().split(/\s+/);
119
- const first = tokens[0] ?? 'other';
120
- const isRemote = cmd.trim().startsWith('ssh ') || cmd.trim().startsWith('scp ');
121
- if (TWO_LEVEL_TOKENS.has(first) && tokens[1]) {
122
- const key = `${first} ${tokens[1]}`;
123
- return isRemote ? `ssh\u2192${key}` : key;
124
- }
125
- return isRemote ? `ssh\u2192${first}` : first;
126
- }
102
+ // Re-export the shared classifier/bucketing for callers in this module's surface.
103
+ // `bucketKey` is the single source of truth in bash-command.ts (correct subcommand
104
+ // scan + `ssh\u2192` remote prefix); render must not keep a divergent copy.
105
+ export { unwrapCommand, bucketKey };
106
+ const CATEGORY_NAMES = {
107
+ vcs: 'VCS',
108
+ 'build-test': 'Build/test',
109
+ install: 'Install',
110
+ remote: 'Remote',
111
+ http: 'HTTP',
112
+ media: 'Media',
113
+ upscaling: 'Upscaling',
114
+ metadata: 'Metadata',
115
+ probe: 'Probes',
116
+ search: 'Search',
117
+ shell: 'Shell',
118
+ wait: 'Wait',
119
+ other: 'Other',
120
+ };
127
121
  function categoryOf(cmd) {
128
122
  const rawFirst = cmd.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
129
123
  // Remote wrappers: classify as Remote regardless of inner command.
130
124
  if (['ssh', 'scp', 'rsync'].includes(rawFirst)) {
131
- return CATEGORIES.find(c => c.name === 'Remote') ?? null;
125
+ return { name: 'Remote', signal: 'mid' };
132
126
  }
133
- const unwrapped = unwrapCommand(cmd);
134
- const first = unwrapped.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
135
- return CATEGORIES.find(c => c.match(first)) ?? null;
127
+ const info = classifyBashCommand(cmd);
128
+ if (info.category === 'other')
129
+ return null;
130
+ return { name: CATEGORY_NAMES[info.category], signal: info.signal };
136
131
  }
137
132
  /**
138
133
  * Collapse consecutive same-normalized commands within a 60-second window
@@ -387,11 +382,12 @@ function pickDistinct(samples, max) {
387
382
  return result.length > 0 ? result : samples.slice(0, max);
388
383
  }
389
384
  // ── File grouping ─────────────────────────────────────────────────────────────
390
- /** Group file paths by their parent directory, relative to cwd. */
385
+ /** Group file paths by their parent directory, in display form (cwd-relative,
386
+ * worktree-collapsed, home-collapsed). */
391
387
  function groupByParentDir(paths, cwd) {
392
388
  const groups = new Map();
393
389
  for (const p of paths) {
394
- const rel = relativeToCwd(p, cwd);
390
+ const rel = displayPath(p, cwd);
395
391
  const slashIdx = rel.lastIndexOf('/');
396
392
  const dir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '.';
397
393
  const base = slashIdx >= 0 ? rel.slice(slashIdx + 1) : rel;
@@ -454,17 +450,17 @@ const OP_MARK = { created: '+', modified: '~', deleted: '−' };
454
450
  * create/modify/delete lifecycle, plus a `+N ~N −N` summary. Replaces the old
455
451
  * flat "Modified" list. Returns true if anything was rendered.
456
452
  */
457
- function renderChangesSection(lines, events, cwd) {
453
+ function renderChangesSection(lines, allChanges, cwd) {
458
454
  // In-project changes only; edits outside cwd (e.g. /tmp) keep their own
459
455
  // "External edits" section so they don't clutter the project's changeset.
460
456
  const inCwd = (p) => !cwd || !p.startsWith('/') || p.startsWith(cwd + '/');
461
- const changes = classifyFileChanges(events).filter(ch => inCwd(ch.path));
457
+ const changes = allChanges.filter(ch => inCwd(ch.path));
462
458
  if (changes.length === 0)
463
459
  return false;
464
460
  const c = changeCounts(changes);
465
461
  const opByRel = new Map();
466
462
  for (const ch of changes)
467
- opByRel.set(relativeToCwd(ch.path, cwd), ch.op);
463
+ opByRel.set(displayPath(ch.path, cwd), ch.op);
468
464
  const summary = [
469
465
  c.created ? chalk.green(`+${c.created}`) : '',
470
466
  c.modified ? chalk.yellow(`~${c.modified}`) : '',
@@ -528,8 +524,8 @@ export function renderSummary(events, cwd) {
528
524
  const filesModifiedExternal = new Set();
529
525
  // Commands with timestamps
530
526
  const cmdList = [];
531
- // Plan items
532
- const todoItems = extractTodoProgressFromEvents(events)?.items.map(item => item.content) ?? [];
527
+ // Plan items (checklist entries keep their status for [x]/[>]/[ ] markers)
528
+ const todoItems = extractTodoProgressFromEvents(events)?.items ?? [];
533
529
  let exitPlanContent = null;
534
530
  let planFilePath = null;
535
531
  // Subagent spawns
@@ -559,7 +555,7 @@ export function renderSummary(events, cwd) {
559
555
  }
560
556
  else {
561
557
  (isInsideCwd(p) || !cwd ? filesModifiedAbs : filesModifiedExternal).add(p);
562
- recentActivity.push({ kind: 'edit', label: relativeToCwd(p, cwd), ts, absPath: p });
558
+ recentActivity.push({ kind: 'edit', label: displayPath(p, cwd), ts, absPath: p });
563
559
  }
564
560
  }
565
561
  }
@@ -624,11 +620,11 @@ export function renderSummary(events, cwd) {
624
620
  filesReadAbs.delete(p);
625
621
  for (const p of filesModifiedExternal)
626
622
  filesReadAbs.delete(p);
627
- // Build abs→rel mapping for linkPath
623
+ // Build abs→display mapping for linkPath
628
624
  const buildAbsMap = (absSet) => {
629
625
  const m = new Map();
630
626
  for (const abs of absSet) {
631
- const rel = relativeToCwd(abs, cwd);
627
+ const rel = displayPath(abs, cwd);
632
628
  m.set(rel, abs);
633
629
  }
634
630
  return m;
@@ -668,22 +664,27 @@ export function renderSummary(events, cwd) {
668
664
  }
669
665
  lines.push('');
670
666
  }
671
- // 3. Plan
667
+ // 3. Plan — the plan document (ExitPlanMode text / plan file) AND the live
668
+ // checklist. Both render: the checklist used to be hidden whenever plan text
669
+ // existed, which read as "this session had no todos".
672
670
  if (todoItems.length > 0 || exitPlanContent || planFilePath) {
673
671
  lines.push(chalk.bold('Plan'));
674
672
  if (planFilePath) {
675
673
  const home = process.env.HOME ?? '';
676
- const displayPath = home && planFilePath.startsWith(home) ? planFilePath.replace(home, '~') : planFilePath;
677
- lines.push(' ' + chalk.cyan(displayPath));
674
+ const planPathDisplay = home && planFilePath.startsWith(home) ? planFilePath.replace(home, '~') : planFilePath;
675
+ lines.push(' ' + chalk.cyan(planPathDisplay));
678
676
  }
679
677
  if (exitPlanContent) {
680
678
  const planLines = exitPlanContent.split('\n').slice(0, 10);
681
679
  for (const l of planLines)
682
680
  lines.push(' ' + l);
683
681
  }
684
- else if (todoItems.length > 0) {
682
+ if (todoItems.length > 0) {
685
683
  for (const item of todoItems.slice(0, 20)) {
686
- lines.push(' · ' + item);
684
+ lines.push(' ' + renderTodoMarker(item));
685
+ }
686
+ if (todoItems.length > 20) {
687
+ lines.push(' ' + chalk.gray(`… (${todoItems.length - 20} more)`));
687
688
  }
688
689
  }
689
690
  lines.push('');
@@ -698,6 +699,30 @@ export function renderSummary(events, cwd) {
698
699
  }
699
700
  lines.push('');
700
701
  }
702
+ // 4b. Highlights — what the session USED (skills, hooks) and the references
703
+ // it mentioned (links). Shared extraction with the picker preview so the two
704
+ // renders never disagree.
705
+ const skills = extractSkills(events);
706
+ if (skills.length > 0) {
707
+ const shown = skills.map(s => chalk.white(s.name) + (s.count > 1 ? chalk.gray(` ×${s.count}`) : ''));
708
+ lines.push(chalk.bold('Skills') + chalk.gray(` (${skills.length})`) + ' ' + shown.join(chalk.gray(' · ')));
709
+ lines.push('');
710
+ }
711
+ const hooks = extractHooks(events);
712
+ if (hooks.length > 0) {
713
+ const shown = hooks.map(h => chalk.white(h.name) + (h.count > 1 ? chalk.gray(` ×${h.count}`) : '') + (h.failed ? chalk.red(` (${h.failed} failed)`) : ''));
714
+ lines.push(chalk.bold('Hooks') + chalk.gray(` (${hooks.length})`) + ' ' + shown.join(chalk.gray(' · ')));
715
+ lines.push('');
716
+ }
717
+ const links = extractLinks(events);
718
+ if (links.length > 0) {
719
+ // Width-capped like the picker's Dirs line: a link-heavy session must not
720
+ // wrap the summary pane.
721
+ const shown = links.slice(0, 6).map(l => chalk.blue(linkUrl(l.url, l.label)));
722
+ const more = links.length > 6 ? chalk.gray(` · +${links.length - 6} more`) : '';
723
+ lines.push(chalk.bold('Links') + chalk.gray(` (${links.length})`) + ' ' + shown.join(chalk.gray(' · ')) + more);
724
+ lines.push('');
725
+ }
701
726
  // 5. Errors (moved up from the bottom: it describes failed attempts, not the
702
727
  // session's final state. Sitting at the bottom previously made early errors
703
728
  // look recent, which confused readers.)
@@ -713,8 +738,22 @@ export function renderSummary(events, cwd) {
713
738
  lines.push('');
714
739
  }
715
740
  // 6. Changes — files grouped by directory with create/modify/delete lifecycle
716
- // (replaces the old flat "Modified" + "External edits" lists).
717
- renderChangesSection(lines, events, cwd);
741
+ // (replaces the old flat "Modified" + "External edits" lists). Classified
742
+ // once here and shared with the Artifacts section below.
743
+ const allChanges = classifyFileChanges(events);
744
+ renderChangesSection(lines, allChanges, cwd);
745
+ // 6a. Artifacts — documents the session PRODUCED (`.agents/artifacts|plans|
746
+ // reports`, other *.md/*.html creations), named and clickable. These drown in
747
+ // the Changeset's source churn, so they get their own section.
748
+ const artifacts = extractArtifacts(allChanges);
749
+ if (artifacts.length > 0) {
750
+ lines.push(chalk.bold('Artifacts') + chalk.gray(` (${artifacts.length})`));
751
+ for (const a of artifacts) {
752
+ const tag = a.bucket === 'docs' ? '' : chalk.gray(` (${a.bucket})`);
753
+ lines.push(' ' + chalk.green('+') + ' ' + chalk.cyan(linkPath(a.path, a.basename)) + tag);
754
+ }
755
+ lines.push('');
756
+ }
718
757
  // 6b. Catch-up signals: last test/build verdict, then the tool histogram.
719
758
  renderTestsLine(lines, events);
720
759
  renderToolsSection(lines, computeSummaryStats(events));
@@ -18,7 +18,7 @@ export declare const SESSION_AGENTS: SessionAgentId[];
18
18
  export declare function isSessionTrackedAgent(agent: string): agent is SessionAgentId;
19
19
  /** A single normalized event within a session (message, tool call, thinking, etc.). */
20
20
  export interface SessionEvent {
21
- type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment';
21
+ type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment' | 'hook';
22
22
  agent: SessionAgentId;
23
23
  timestamp: string;
24
24
  role?: 'user' | 'assistant';
@@ -49,6 +49,9 @@ export interface SessionEvent {
49
49
  name?: string;
50
50
  mediaType?: string;
51
51
  sizeBytes?: number;
52
+ hookName?: string;
53
+ /** Lifecycle event the hook fired on (SessionStart, PreToolUse, …). */
54
+ hookEvent?: string;
52
55
  }
53
56
  /** A displayable file attachment discovered in a session transcript. */
54
57
  export interface SessionAttachment {
@@ -73,6 +73,12 @@ export declare function sshExec(target: string, remoteCmd: string, opts?: SshExe
73
73
  /**
74
74
  * Async variant of {@link sshExec}. Same hardened argv composition, but uses
75
75
  * child_process.spawn so fleet fan-outs can probe multiple hosts concurrently.
76
+ *
77
+ * A timeout-bearing call uses a fresh ssh connection (`multiplex: false`) even
78
+ * when the caller requests multiplexing: a control-master outlives the local
79
+ * client, so killing the local ssh process on timeout would leave the remote
80
+ * command running. With a direct connection, terminating the local child tears
81
+ * down the remote side (RUSH-2114).
76
82
  */
77
83
  export declare function sshExecAsync(target: string, remoteCmd: string, opts?: SshExecOptions): Promise<SshExecResult>;
78
84
  export interface SshExecRawResult {
@@ -128,10 +128,19 @@ export function sshExec(target, remoteCmd, opts = {}) {
128
128
  /**
129
129
  * Async variant of {@link sshExec}. Same hardened argv composition, but uses
130
130
  * child_process.spawn so fleet fan-outs can probe multiple hosts concurrently.
131
+ *
132
+ * A timeout-bearing call uses a fresh ssh connection (`multiplex: false`) even
133
+ * when the caller requests multiplexing: a control-master outlives the local
134
+ * client, so killing the local ssh process on timeout would leave the remote
135
+ * command running. With a direct connection, terminating the local child tears
136
+ * down the remote side (RUSH-2114).
131
137
  */
132
138
  export function sshExecAsync(target, remoteCmd, opts = {}) {
133
139
  assertValidSshTarget(target);
134
- const mux = opts.multiplex === false ? [] : controlOpts();
140
+ // Control-master connections defeat local timeouts the master keeps the
141
+ // remote command alive after we kill the client. Force a fresh connection
142
+ // whenever the caller asked for a timeout so the timeout actually stops work.
143
+ const mux = opts.multiplex === false || opts.timeoutMs ? [] : controlOpts();
135
144
  const args = [...sshConnectOpts(mux, opts.hostKeyOpts), ...(opts.extraSshArgs ?? []), target, remoteCmd];
136
145
  return new Promise((resolve) => {
137
146
  const child = spawn('ssh', args, {
@@ -44,6 +44,7 @@ export declare const loadExport: ModuleLoader;
44
44
  export declare const loadPackages: ModuleLoader;
45
45
  export declare const loadRoutines: ModuleLoader;
46
46
  export declare const loadMonitors: ModuleLoader;
47
+ export declare const loadProjects: ModuleLoader;
47
48
  export declare const loadRun: ModuleLoader;
48
49
  export declare const loadFork: ModuleLoader;
49
50
  export declare const loadDefaults: ModuleLoader;
@@ -70,6 +71,7 @@ export declare const loadDrive: ModuleLoader;
70
71
  export declare const loadFactory: ModuleLoader;
71
72
  export declare const loadUsage: ModuleLoader;
72
73
  export declare const loadCost: ModuleLoader;
74
+ export declare const loadPerf: ModuleLoader;
73
75
  export declare const loadOutput: ModuleLoader;
74
76
  export declare const loadBudget: ModuleLoader;
75
77
  export declare const loadAlias: ModuleLoader;
@@ -22,6 +22,7 @@ export const loadExport = async () => (await import('../../commands/export.js'))
22
22
  export const loadPackages = async () => (await import('../../commands/packages.js')).registerPackagesCommands;
23
23
  export const loadRoutines = async () => (await import('../../commands/routines.js')).registerRoutinesCommands;
24
24
  export const loadMonitors = async () => (await import('../../commands/monitors.js')).registerMonitorsCommands;
25
+ export const loadProjects = async () => (await import('../../commands/projects.js')).registerProjectsCommands;
25
26
  export const loadRun = async () => (await import('../../commands/exec.js')).registerRunCommand;
26
27
  export const loadFork = async () => (await import('../../commands/fork.js')).registerForkCommand;
27
28
  export const loadDefaults = async () => (await import('../../commands/defaults.js')).registerDefaultsCommands;
@@ -48,6 +49,7 @@ export const loadDrive = async () => (await import('../../commands/drive.js')).r
48
49
  export const loadFactory = async () => (await import('../../commands/factory.js')).registerFactoryCommands;
49
50
  export const loadUsage = async () => (await import('../../commands/usage.js')).registerUsageCommand;
50
51
  export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
52
+ export const loadPerf = async () => (await import('../../commands/perf.js')).registerPerfCommand;
51
53
  export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
52
54
  export const loadBudget = async () => (await import('../../commands/budget.js')).registerBudgetCommand;
53
55
  export const loadAlias = async () => (await import('../../commands/alias.js')).registerAliasCommand;
@@ -133,6 +135,7 @@ export const COMMAND_LOADERS = {
133
135
  install: [loadPackages],
134
136
  routines: [loadRoutines],
135
137
  monitors: [loadMonitors],
138
+ projects: [loadProjects],
136
139
  run: [loadRun],
137
140
  fork: [loadFork],
138
141
  defaults: [loadDefaults],
@@ -162,6 +165,7 @@ export const COMMAND_LOADERS = {
162
165
  factory: [loadFactory],
163
166
  usage: [loadUsage],
164
167
  cost: [loadCost],
168
+ perf: [loadPerf],
165
169
  output: [loadOutput],
166
170
  budget: [loadBudget],
167
171
  alias: [loadAlias],
@@ -109,6 +109,8 @@ export declare function getCacheDir(): string;
109
109
  export declare function getPackagesDir(): string;
110
110
  /** Path to routine YAML definitions (~/.agents/routines/). */
111
111
  export declare function getRoutinesDir(): string;
112
+ /** Path to named project definitions (~/.agents/projects/). */
113
+ export declare function getProjectsDir(): string;
112
114
  /**
113
115
  * Path to webhook handler YAML definitions (~/.agents/webhooks/). Handlers are
114
116
  * one-off triggers for agents/workflows/commands/routines, layered the same way
@@ -208,6 +210,15 @@ export declare function getCloudDir(): string;
208
210
  export declare function getTerminalsDir(): string;
209
211
  /** Path to runtime logs (~/.agents/.cache/logs/). */
210
212
  export declare function getLogsDir(): string;
213
+ /**
214
+ * Path to disposable performance samples (~/.agents/.cache/perf/).
215
+ * Holds `perf.db` + a hook-shim spool. Loss is acceptable — wipe freely.
216
+ */
217
+ export declare function getPerfDir(): string;
218
+ /** Path to the perf SQLite warehouse (~/.agents/.cache/perf/perf.db). */
219
+ export declare function getPerfDbPath(): string;
220
+ /** Path to the hook-shim NDJSON spool drained into perf.db on open. */
221
+ export declare function getPerfSpoolPath(): string;
211
222
  /** Path to per-process runtime state (~/.agents/.cache/state/). */
212
223
  export declare function getRuntimeStateDir(): string;
213
224
  /** Path to companion-extension scratch (~/.agents/.cache/companion/). */