@phnx-labs/agents-cli 1.20.91 → 1.20.93

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 (105) 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/events.js +91 -1
  9. package/dist/commands/exec.d.ts +14 -0
  10. package/dist/commands/exec.js +144 -14
  11. package/dist/commands/projects.d.ts +22 -0
  12. package/dist/commands/projects.js +539 -0
  13. package/dist/commands/secrets.d.ts +17 -0
  14. package/dist/commands/secrets.js +198 -7
  15. package/dist/commands/send.d.ts +14 -12
  16. package/dist/commands/send.js +105 -35
  17. package/dist/commands/sessions-picker.d.ts +15 -0
  18. package/dist/commands/sessions-picker.js +37 -12
  19. package/dist/commands/sessions-resume.d.ts +2 -0
  20. package/dist/commands/sessions-resume.js +9 -1
  21. package/dist/commands/sessions.d.ts +10 -5
  22. package/dist/commands/sessions.js +65 -27
  23. package/dist/commands/sync.js +9 -3
  24. package/dist/commands/view.js +4 -0
  25. package/dist/index.js +18 -1
  26. package/dist/lib/activity.d.ts +77 -12
  27. package/dist/lib/activity.js +424 -74
  28. package/dist/lib/beta.d.ts +1 -1
  29. package/dist/lib/beta.js +1 -1
  30. package/dist/lib/channels/send.d.ts +83 -0
  31. package/dist/lib/channels/send.js +112 -0
  32. package/dist/lib/devices/registry.d.ts +14 -0
  33. package/dist/lib/devices/registry.js +37 -0
  34. package/dist/lib/events-ingest.d.ts +46 -0
  35. package/dist/lib/events-ingest.js +182 -0
  36. package/dist/lib/events.d.ts +15 -3
  37. package/dist/lib/events.js +55 -3
  38. package/dist/lib/feed-post.js +8 -2
  39. package/dist/lib/hosts/remote-cmd.js +4 -0
  40. package/dist/lib/linear-project-counts.d.ts +62 -0
  41. package/dist/lib/linear-project-counts.js +122 -0
  42. package/dist/lib/linear-projects.d.ts +50 -0
  43. package/dist/lib/linear-projects.js +114 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  46. package/dist/lib/menubar/install-menubar.js +20 -6
  47. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  48. package/dist/lib/menubar/notify-desktop.js +8 -2
  49. package/dist/lib/project-key.d.ts +44 -0
  50. package/dist/lib/project-key.js +79 -0
  51. package/dist/lib/project-probe.d.ts +75 -0
  52. package/dist/lib/project-probe.js +160 -0
  53. package/dist/lib/project-resources.d.ts +8 -0
  54. package/dist/lib/project-resources.js +31 -3
  55. package/dist/lib/project-root.js +16 -0
  56. package/dist/lib/project-status.d.ts +100 -0
  57. package/dist/lib/project-status.js +182 -0
  58. package/dist/lib/projects.d.ts +144 -0
  59. package/dist/lib/projects.js +313 -0
  60. package/dist/lib/remote-agents-json.d.ts +9 -0
  61. package/dist/lib/remote-agents-json.js +11 -5
  62. package/dist/lib/routine-notify.d.ts +11 -0
  63. package/dist/lib/routine-notify.js +22 -0
  64. package/dist/lib/run-notify.js +3 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/audit.d.ts +1 -1
  68. package/dist/lib/secrets/audit.js +53 -10
  69. package/dist/lib/secrets/list-filter.d.ts +20 -5
  70. package/dist/lib/secrets/list-filter.js +22 -6
  71. package/dist/lib/secrets/usage-db.d.ts +106 -0
  72. package/dist/lib/secrets/usage-db.js +236 -0
  73. package/dist/lib/session/bash-command.d.ts +53 -0
  74. package/dist/lib/session/bash-command.js +364 -0
  75. package/dist/lib/session/digest.d.ts +6 -0
  76. package/dist/lib/session/digest.js +19 -0
  77. package/dist/lib/session/relative-time.d.ts +23 -0
  78. package/dist/lib/session/relative-time.js +60 -8
  79. package/dist/lib/session/remote-active.d.ts +5 -1
  80. package/dist/lib/session/remote-active.js +4 -1
  81. package/dist/lib/session/remote-list.js +5 -2
  82. package/dist/lib/session/render.d.ts +2 -9
  83. package/dist/lib/session/render.js +25 -56
  84. package/dist/lib/sqlite.js +28 -1
  85. package/dist/lib/ssh-exec.d.ts +6 -0
  86. package/dist/lib/ssh-exec.js +10 -1
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/state.d.ts +14 -0
  90. package/dist/lib/state.js +19 -0
  91. package/dist/lib/terminal/backends/index.d.ts +10 -2
  92. package/dist/lib/terminal/backends/index.js +14 -2
  93. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  94. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  95. package/dist/lib/terminal/index.d.ts +2 -1
  96. package/dist/lib/terminal/index.js +2 -1
  97. package/dist/lib/terminal/preferred.d.ts +89 -0
  98. package/dist/lib/terminal/preferred.js +87 -0
  99. package/dist/lib/terminal/run-surface.d.ts +82 -0
  100. package/dist/lib/terminal/run-surface.js +146 -0
  101. package/dist/lib/terminal/types.d.ts +1 -1
  102. package/dist/lib/types.d.ts +6 -5
  103. package/dist/lib/versions.d.ts +6 -0
  104. package/dist/lib/versions.js +6 -4
  105. package/package.json +2 -1
@@ -0,0 +1,364 @@
1
+ import { split as shlexSplit } from 'shlex';
2
+ const TOOL_REGISTRY = {
3
+ // VCS
4
+ git: { category: 'vcs', signal: 'mid', action: 'working in git' },
5
+ gh: { category: 'vcs', signal: 'mid', action: 'using GitHub CLI' },
6
+ // Build / test
7
+ bun: { category: 'build-test', signal: 'mid', action: 'running bun' },
8
+ npm: { category: 'build-test', signal: 'mid', action: 'running npm' },
9
+ pnpm: { category: 'build-test', signal: 'mid', action: 'running pnpm' },
10
+ yarn: { category: 'build-test', signal: 'mid', action: 'running yarn' },
11
+ vitest: { category: 'build-test', signal: 'mid', action: 'running vitest' },
12
+ jest: { category: 'build-test', signal: 'mid', action: 'running jest' },
13
+ mocha: { category: 'build-test', signal: 'mid', action: 'running mocha' },
14
+ pytest: { category: 'build-test', signal: 'mid', action: 'running pytest' },
15
+ cargo: { category: 'build-test', signal: 'mid', action: 'running cargo' },
16
+ go: { category: 'build-test', signal: 'mid', action: 'running go' },
17
+ tsc: { category: 'build-test', signal: 'mid', action: 'running tsc' },
18
+ tsx: { category: 'build-test', signal: 'mid', action: 'running tsx' },
19
+ node: { category: 'build-test', signal: 'low', action: 'running node' },
20
+ python: { category: 'build-test', signal: 'low', action: 'running python', aliases: ['python3'] },
21
+ make: { category: 'build-test', signal: 'mid', action: 'running make' },
22
+ // Install
23
+ brew: { category: 'install', signal: 'mid', action: 'installing with brew' },
24
+ pip: { category: 'install', signal: 'mid', action: 'installing with pip', aliases: ['pip3'] },
25
+ apt: { category: 'install', signal: 'mid', action: 'installing with apt' },
26
+ apk: { category: 'install', signal: 'mid', action: 'installing with apk' },
27
+ // Remote
28
+ ssh: { category: 'remote', signal: 'mid', action: 'using ssh' },
29
+ scp: { category: 'remote', signal: 'mid', action: 'using scp' },
30
+ rsync: { category: 'remote', signal: 'mid', action: 'using rsync' },
31
+ // HTTP
32
+ curl: { category: 'http', signal: 'mid', action: 'fetching with curl' },
33
+ wget: { category: 'http', signal: 'mid', action: 'fetching with wget' },
34
+ // Media
35
+ ffmpeg: { category: 'media', signal: 'high', action: 'using ffmpeg' },
36
+ ffprobe: { category: 'media', signal: 'mid', action: 'probing media' },
37
+ magick: { category: 'media', signal: 'mid', action: 'using ImageMagick' },
38
+ convert: { category: 'media', signal: 'mid', action: 'converting images' },
39
+ composite: { category: 'media', signal: 'mid', action: 'compositing images' },
40
+ montage: { category: 'media', signal: 'mid', action: 'montaging images' },
41
+ identify: { category: 'media', signal: 'mid', action: 'identifying images' },
42
+ // Upscaling
43
+ realesrgan: {
44
+ category: 'upscaling',
45
+ signal: 'high',
46
+ action: 'upscaling with realesrgan',
47
+ aliases: ['realesrgan-ncnn-vulkan'],
48
+ },
49
+ waifu2x: {
50
+ category: 'upscaling',
51
+ signal: 'high',
52
+ action: 'upscaling with waifu2x',
53
+ aliases: ['waifu2x-caffe', 'waifu2x-converter-cpp'],
54
+ },
55
+ swin2sr: { category: 'upscaling', signal: 'high', action: 'upscaling with swin2sr' },
56
+ resdet: { category: 'upscaling', signal: 'mid', action: 'detecting upscale' },
57
+ // Metadata
58
+ id3v2: { category: 'metadata', signal: 'mid', action: 'editing id3 tags' },
59
+ exiftool: { category: 'metadata', signal: 'mid', action: 'editing exif metadata' },
60
+ metaflac: { category: 'metadata', signal: 'mid', action: 'editing flac metadata' },
61
+ vorbiscomment: { category: 'metadata', signal: 'mid', action: 'editing vorbis comments' },
62
+ // Shell
63
+ rm: { category: 'shell', signal: 'low', action: 'removing files' },
64
+ mv: { category: 'shell', signal: 'low', action: 'moving files' },
65
+ cp: { category: 'shell', signal: 'low', action: 'copying files' },
66
+ mkdir: { category: 'shell', signal: 'low', action: 'making directories' },
67
+ touch: { category: 'shell', signal: 'low', action: 'touching files' },
68
+ echo: { category: 'shell', signal: 'low', action: 'echoing' },
69
+ printf: { category: 'shell', signal: 'low', action: 'printing' },
70
+ chmod: { category: 'shell', signal: 'low', action: 'changing permissions' },
71
+ ln: { category: 'shell', signal: 'low', action: 'linking files' },
72
+ awk: { category: 'shell', signal: 'low', action: 'running awk' },
73
+ sed: { category: 'shell', signal: 'low', action: 'running sed' },
74
+ tee: { category: 'shell', signal: 'low', action: 'teeing output' },
75
+ xargs: { category: 'shell', signal: 'low', action: 'running xargs' },
76
+ // Probes
77
+ ls: { category: 'probe', signal: 'low', action: 'listing files' },
78
+ cat: { category: 'probe', signal: 'low', action: 'reading files' },
79
+ head: { category: 'probe', signal: 'low', action: 'reading files' },
80
+ tail: { category: 'probe', signal: 'low', action: 'reading files' },
81
+ wc: { category: 'probe', signal: 'low', action: 'counting' },
82
+ stat: { category: 'probe', signal: 'low', action: 'statting files' },
83
+ file: { category: 'probe', signal: 'low', action: 'inspecting files' },
84
+ which: { category: 'probe', signal: 'low', action: 'locating binaries' },
85
+ tree: { category: 'probe', signal: 'low', action: 'listing files' },
86
+ pwd: { category: 'probe', signal: 'low', action: 'printing pwd' },
87
+ // Search
88
+ grep: { category: 'search', signal: 'low', action: 'searching' },
89
+ rg: { category: 'search', signal: 'low', action: 'searching with ripgrep' },
90
+ ag: { category: 'search', signal: 'low', action: 'searching with the silver searcher' },
91
+ fd: { category: 'search', signal: 'low', action: 'searching files' },
92
+ find: { category: 'search', signal: 'low', action: 'finding files' },
93
+ // Wait
94
+ sleep: { category: 'wait', signal: 'low', action: 'sleeping' },
95
+ wait: { category: 'wait', signal: 'low', action: 'waiting' },
96
+ };
97
+ /**
98
+ * Two-level tools (bucket key includes the subcommand) mapped to the flags that
99
+ * consume the *following* token as their value, per tool. The subcommand scan
100
+ * skips both a value flag and its argument, so `git -C /repo commit` → `commit`
101
+ * and `kubectl -n prod get` → `get`. Missing a value flag mis-reads the value as
102
+ * the subcommand; over-listing one only drops the subcommand (a safe tool-only
103
+ * bucket), so err toward listing. A tool with no such leading flags maps to an
104
+ * empty set. TWO_LEVEL_TOOLS is derived from these keys so the two never drift.
105
+ */
106
+ const VALUE_FLAGS = {
107
+ git: new Set(['-C', '-c', '--git-dir', '--work-tree']),
108
+ gh: new Set(['-R', '--repo']),
109
+ bun: new Set(['--cwd']),
110
+ npm: new Set(['--prefix', '-w', '--workspace']),
111
+ pnpm: new Set(['--filter', '-C', '--dir']),
112
+ yarn: new Set(['--cwd']),
113
+ cargo: new Set(['--manifest-path']),
114
+ docker: new Set(['-H', '--host', '-c', '--context', '--config', '-l', '--log-level']),
115
+ kubectl: new Set(['-n', '--namespace', '--kubeconfig', '--context', '--cluster', '--user', '-s', '--server', '--as', '--token', '--cache-dir', '--request-timeout']),
116
+ rush: new Set(),
117
+ openclaw: new Set(),
118
+ };
119
+ /** Tools whose bucket key includes the second token (subcommand). */
120
+ const TWO_LEVEL_TOOLS = new Set(Object.keys(VALUE_FLAGS));
121
+ /** Remote wrappers whose bucket key carries an `ssh→` prefix on the inner command. */
122
+ const REMOTE_WRAPPERS = new Set(['ssh', 'scp', 'rsync']);
123
+ const ALIAS_MAP = new Map();
124
+ for (const [name, info] of Object.entries(TOOL_REGISTRY)) {
125
+ ALIAS_MAP.set(name, name);
126
+ for (const alias of info.aliases ?? []) {
127
+ ALIAS_MAP.set(alias, name);
128
+ }
129
+ }
130
+ export function unwrapCommand(cmd) {
131
+ const s = cmd.trim();
132
+ // ssh host command
133
+ const ssh = s.match(/^ssh\s+\S+\s+["']?(.+?)["']?\s*(?:\|.*)?$/);
134
+ if (ssh)
135
+ return unwrapCommand(ssh[1]);
136
+ // VAR=value prefix (value may be a single- or double-quoted string with spaces)
137
+ const env = s.match(/^([A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)+(.+)$/);
138
+ if (env)
139
+ return unwrapCommand(env[2]);
140
+ // sudo / time prefix (value-taking flags like `-u user` consume their argument)
141
+ const prefix = s.match(/^(?:sudo|time)(?:\s+(?:-[uUgGhpCrtDR]\s+\S+|-\S+))*\s+(.+)$/);
142
+ if (prefix)
143
+ return unwrapCommand(prefix[1]);
144
+ // cd foo && command
145
+ const cd = s.match(/^cd\s+\S+\s*&&\s*(.+)$/);
146
+ if (cd)
147
+ return unwrapCommand(cd[1]);
148
+ // npx / bunx
149
+ const npx = s.match(/^(?:npx|bunx)\s+(?:-\S+\s+)*(.+)$/);
150
+ if (npx)
151
+ return unwrapCommand(npx[1]);
152
+ return s;
153
+ }
154
+ function splitOnOperators(cmd) {
155
+ const parts = [];
156
+ let current = '';
157
+ let quote = null;
158
+ let escaped = false;
159
+ let i = 0;
160
+ while (i < cmd.length) {
161
+ const ch = cmd[i];
162
+ if (escaped) {
163
+ current += ch;
164
+ escaped = false;
165
+ i += 1;
166
+ continue;
167
+ }
168
+ if (ch === '\\') {
169
+ current += ch;
170
+ escaped = true;
171
+ i += 1;
172
+ continue;
173
+ }
174
+ if (quote) {
175
+ current += ch;
176
+ if (ch === quote)
177
+ quote = null;
178
+ i += 1;
179
+ continue;
180
+ }
181
+ if (ch === '"' || ch === "'") {
182
+ current += ch;
183
+ quote = ch;
184
+ i += 1;
185
+ continue;
186
+ }
187
+ if (cmd.slice(i, i + 2) === '&&' || cmd.slice(i, i + 2) === '||') {
188
+ if (current.trim())
189
+ parts.push(current.trim());
190
+ current = '';
191
+ i += 2;
192
+ continue;
193
+ }
194
+ if (ch === '|' || ch === ';') {
195
+ if (current.trim())
196
+ parts.push(current.trim());
197
+ current = '';
198
+ i += 1;
199
+ continue;
200
+ }
201
+ current += ch;
202
+ i += 1;
203
+ }
204
+ if (current.trim())
205
+ parts.push(current.trim());
206
+ return parts;
207
+ }
208
+ /**
209
+ * Split a possibly-compound Bash command into simple commands, then tokenize each
210
+ * with shlex. Returns an empty array for empty input.
211
+ */
212
+ export function tokenizeBash(cmd) {
213
+ const unwrapped = unwrapCommand(cmd);
214
+ const segments = splitOnOperators(unwrapped);
215
+ const out = [];
216
+ for (const seg of segments) {
217
+ try {
218
+ const tokens = shlexSplit(seg);
219
+ if (tokens.length)
220
+ out.push(tokens);
221
+ }
222
+ catch {
223
+ // If shlex can't parse, fall back to whitespace split.
224
+ const tokens = seg.split(/\s+/).filter(Boolean);
225
+ if (tokens.length)
226
+ out.push(tokens);
227
+ }
228
+ }
229
+ return out;
230
+ }
231
+ /**
232
+ * First non-flag token after the executable — its subcommand. Skips leading
233
+ * flags, and skips the argument of a value-taking flag for that tool (see
234
+ * VALUE_FLAGS) so e.g. `git -C /repo commit` resolves to `commit`, not `-C`, and
235
+ * `kubectl -n prod get` resolves to `get`, not `prod`.
236
+ */
237
+ function scanSubcommand(tokens, tool) {
238
+ const valueFlags = VALUE_FLAGS[tool];
239
+ let i = 1;
240
+ while (i < tokens.length) {
241
+ const t = tokens[i];
242
+ if (t.startsWith('-')) {
243
+ i += valueFlags?.has(t) ? 2 : 1;
244
+ continue;
245
+ }
246
+ return t.toLowerCase();
247
+ }
248
+ return '';
249
+ }
250
+ /**
251
+ * Classify the first simple command in a Bash string. Returns coarse metadata
252
+ * (tool name, category, subcommand, human action) used for summaries and
253
+ * activity logging. Unknown executables fall back to `other`.
254
+ */
255
+ export function classifyBashCommand(command) {
256
+ const simpleCommands = tokenizeBash(command);
257
+ if (!simpleCommands.length) {
258
+ return { tool: 'other', category: 'other', subcommand: '', action: 'running command', summary: '', signal: 'low' };
259
+ }
260
+ const tokens = simpleCommands[0];
261
+ if (!tokens.length) {
262
+ return { tool: 'other', category: 'other', subcommand: '', action: 'running command', summary: '', signal: 'low' };
263
+ }
264
+ const first = tokens[0];
265
+ const baseRaw = first.replace(/^[./]+/, '').toLowerCase();
266
+ const base = baseRaw.endsWith('.exe') ? baseRaw.slice(0, -4) : baseRaw;
267
+ const canonical = ALIAS_MAP.get(base);
268
+ const info = canonical ? TOOL_REGISTRY[canonical] : undefined;
269
+ if (!info) {
270
+ // Unknown executable. A known two-level tool that isn't in the registry
271
+ // (docker/kubectl/rush/openclaw) still surfaces its subcommand so bucketing
272
+ // stays useful; the category stays honestly 'other'.
273
+ const subcommand = TWO_LEVEL_TOOLS.has(base) ? scanSubcommand(tokens, base) : '';
274
+ const summary = subcommand ? `${base} ${subcommand}` : base;
275
+ return { tool: base, category: 'other', subcommand, action: 'running command', summary, signal: 'low' };
276
+ }
277
+ const subcommand = canonical && TWO_LEVEL_TOOLS.has(canonical) ? scanSubcommand(tokens, canonical) : '';
278
+ const safeCanonical = canonical || base;
279
+ const summary = subcommand ? `${safeCanonical} ${subcommand}` : safeCanonical;
280
+ return {
281
+ tool: safeCanonical,
282
+ category: info.category,
283
+ subcommand,
284
+ action: info.action,
285
+ summary,
286
+ signal: info.signal,
287
+ };
288
+ }
289
+ /**
290
+ * Stable bucket key for grouping similar Bash commands in summaries. Commands run
291
+ * through a remote wrapper (ssh/scp/rsync) get an `ssh→` prefix on the inner key,
292
+ * so `ssh host "git push"` buckets as `ssh→git push`, distinct from a local push.
293
+ */
294
+ export function bucketKey(command) {
295
+ const info = classifyBashCommand(command);
296
+ const base = !info.tool
297
+ ? 'other'
298
+ : info.subcommand && TWO_LEVEL_TOOLS.has(info.tool)
299
+ ? `${info.tool} ${info.subcommand}`
300
+ : info.tool;
301
+ const first = command.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
302
+ return REMOTE_WRAPPERS.has(first) ? `ssh→${base}` : base;
303
+ }
304
+ /**
305
+ * Detect high-signal Bash-driven milestones (video renders, upscales, metadata
306
+ * edits, git commits/pushes/worktrees, PR opens). Returns null for routine
307
+ * commands.
308
+ */
309
+ export function detectBashMilestone(command) {
310
+ const info = classifyBashCommand(command);
311
+ const lower = (command || '').toLowerCase();
312
+ if (info.category === 'upscaling') {
313
+ return { event: 'image.upscaled', detail: info.action };
314
+ }
315
+ if (info.tool === 'ffmpeg') {
316
+ const hasOutput = /\s+\S+\.\w{2,5}\s*$/.test(command || '');
317
+ if (hasOutput || lower.includes('-c:v') || lower.includes('-codec') || lower.includes('libx264')) {
318
+ return { event: 'video.rendered', detail: 'ffmpeg render' };
319
+ }
320
+ return { event: 'video.converted', detail: 'ffmpeg' };
321
+ }
322
+ if (info.category === 'metadata') {
323
+ return { event: 'metadata.edited', detail: info.action };
324
+ }
325
+ if (info.tool === 'git') {
326
+ if (info.subcommand === 'commit')
327
+ return { event: 'commit.created', detail: 'git commit' };
328
+ if (info.subcommand === 'push')
329
+ return { event: 'pushed', detail: 'git push' };
330
+ if (info.subcommand === 'worktree') {
331
+ if (lower.includes('worktree add'))
332
+ return { event: 'worktree.created', detail: 'git worktree add' };
333
+ if (lower.includes('worktree remove'))
334
+ return { event: 'worktree.removed', detail: 'git worktree remove' };
335
+ }
336
+ }
337
+ if (info.tool === 'gh' && info.subcommand === 'pr') {
338
+ if (lower.includes('pr create'))
339
+ return { event: 'pr.opened', detail: 'gh pr create' };
340
+ if (lower.includes('pr merge'))
341
+ return { event: 'pr.merged', detail: 'gh pr merge' };
342
+ }
343
+ return null;
344
+ }
345
+ /**
346
+ * Human-readable category label for renderers.
347
+ */
348
+ export function categoryLabel(category) {
349
+ switch (category) {
350
+ case 'vcs': return 'Version control';
351
+ case 'build-test': return 'Build / test';
352
+ case 'install': return 'Install';
353
+ case 'remote': return 'Remote';
354
+ case 'http': return 'HTTP';
355
+ case 'media': return 'Media';
356
+ case 'upscaling': return 'Upscaling';
357
+ case 'metadata': return 'Metadata';
358
+ case 'probe': return 'Probes';
359
+ case 'search': return 'Search';
360
+ case 'shell': return 'Shell';
361
+ case 'wait': return 'Wait';
362
+ default: return 'Other';
363
+ }
364
+ }
@@ -42,6 +42,12 @@ export declare function toolHistogram(toolCounts: Record<string, number>, top?:
42
42
  tool: string;
43
43
  count: number;
44
44
  }>;
45
+ /**
46
+ * Aggregate recognized external tools invoked via Bash across a session.
47
+ * Unknown one-off commands are bucketed as `other` so the ranking surface
48
+ * (e.g. `agents activity --tools`) only surfaces repeatable tools.
49
+ */
50
+ export declare function bashToolCounts(events: SessionEvent[]): Record<string, number>;
45
51
  export interface TestResult {
46
52
  runner: string;
47
53
  passed?: number;
@@ -7,6 +7,7 @@
7
7
  * last test/build result. Consumed by the single-session view and the picker
8
8
  * preview. No I/O — fully unit-testable.
9
9
  */
10
+ import { bucketKey, classifyBashCommand } from './bash-command.js';
10
11
  // Tool vocab mirrors parse.ts / render.ts so classification matches what those
11
12
  // modules already recognize across Claude/Codex/others.
12
13
  const READ_TOOLS = new Set(['Read', 'read_file', 'view_file', 'cat_file', 'get_file']);
@@ -127,6 +128,24 @@ export function toolHistogram(toolCounts, top = 8) {
127
128
  .sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool))
128
129
  .slice(0, top);
129
130
  }
131
+ /**
132
+ * Aggregate recognized external tools invoked via Bash across a session.
133
+ * Unknown one-off commands are bucketed as `other` so the ranking surface
134
+ * (e.g. `agents activity --tools`) only surfaces repeatable tools.
135
+ */
136
+ export function bashToolCounts(events) {
137
+ const counts = {};
138
+ for (const e of events) {
139
+ if (e.type !== 'tool_use' || e._local)
140
+ continue;
141
+ if (e.tool !== 'Bash' || !e.command)
142
+ continue;
143
+ const info = classifyBashCommand(e.command);
144
+ const key = info.category === 'other' ? 'other' : bucketKey(e.command);
145
+ counts[key] = (counts[key] || 0) + 1;
146
+ }
147
+ return counts;
148
+ }
130
149
  /**
131
150
  * Recognized test/build runners → the label we show.
132
151
  *
@@ -5,6 +5,29 @@
5
5
  * `remote.ts`, so a back-import would cycle.
6
6
  */
7
7
  export declare function formatRelativeTime(isoTimestamp: string): string;
8
+ /**
9
+ * Compact age — `now`, `45m`, `3h`, `2d`, then the calendar date. This is the
10
+ * *creation* half of a session's time cell: it renders right next to the long
11
+ * last-activity label, and one row can afford one long form, not two.
12
+ */
13
+ export declare function formatCompactAge(isoTimestamp: string): string;
14
+ /** The two time fields a session listing shows for one row. */
15
+ export interface SessionAgeParts {
16
+ /**
17
+ * Compact age of the session's creation, set only when the session ran long
18
+ * enough (>= a minute) for it to say something `last` does not.
19
+ */
20
+ created?: string;
21
+ /** Last-activity label — the long relative form the listing sorts by. */
22
+ last: string;
23
+ }
24
+ /**
25
+ * Split a session's timestamps into the two fields a listing renders: when it
26
+ * was created and when it was last active. One last-activity label answers
27
+ * neither "is this the old session I'm looking for" nor "how long did it run" —
28
+ * a session touched an hour ago may have started last week.
29
+ */
30
+ export declare function sessionAgeParts(createdIso: string, lastActivityIso?: string): SessionAgeParts;
8
31
  /**
9
32
  * Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
10
33
  * milliseconds. Backs `--since` on `sessions`, `cost`, `teams`, and `output`, and
@@ -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,14 +34,53 @@ 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 };
32
84
  }
33
85
  /**
34
86
  * Parse a time filter string (relative like '7d' or an ISO timestamp) into epoch
@@ -25,5 +25,9 @@ export interface RemoteActiveResult {
25
25
  * (from `--host`), fan out to exactly those. Otherwise sweep the registered,
26
26
  * online devices from `ag devices`, excluding this machine and any without an
27
27
  * address. Results from all peers run in parallel and are flattened.
28
+ * `opts.quiet` suppresses the per-device stderr line for callers that report
29
+ * skipped peers once, compactly, themselves.
28
30
  */
29
- export declare function gatherRemoteActive(hosts?: string[]): Promise<RemoteActiveResult>;
31
+ export declare function gatherRemoteActive(hosts?: string[], opts?: {
32
+ quiet?: boolean;
33
+ }): Promise<RemoteActiveResult>;
@@ -56,13 +56,16 @@ export function parseRemoteActive(stdout, machine) {
56
56
  * (from `--host`), fan out to exactly those. Otherwise sweep the registered,
57
57
  * online devices from `ag devices`, excluding this machine and any without an
58
58
  * address. Results from all peers run in parallel and are flattened.
59
+ * `opts.quiet` suppresses the per-device stderr line for callers that report
60
+ * skipped peers once, compactly, themselves.
59
61
  */
60
- export async function gatherRemoteActive(hosts) {
62
+ export async function gatherRemoteActive(hosts, opts) {
61
63
  const result = await gatherRemoteAgentsJson({
62
64
  args: ['sessions', '--active', '--json'],
63
65
  noFanoutEnv: NO_FANOUT_ENV,
64
66
  hosts,
65
67
  parse: parseRemoteActive,
68
+ quiet: opts?.quiet,
66
69
  });
67
70
  return { sessions: result.items, deviceCount: result.deviceCount };
68
71
  }
@@ -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,6 +7,7 @@
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
  */
@@ -28,18 +29,11 @@ export declare function linkPath(absPath: string, label: string): string;
28
29
  * issue, …) when the terminal supports it. Degrades to the plain label otherwise.
29
30
  */
30
31
  export declare function linkUrl(url: string, label: string): string;
31
- /**
32
- * Unwrap wrapper prefixes to find the actual executable.
33
- */
34
- export declare function unwrapCommand(cmd: string): string;
35
32
  /**
36
33
  * Normalize a command so trivial flag/pipe variations collapse to the same key.
37
34
  */
38
35
  export declare function normalizeForDedup(cmd: string): string;
39
- /**
40
- * Return the bucket key for a command (used for grouping within a category).
41
- */
42
- export declare function bucketKey(cmd: string): string;
36
+ export { unwrapCommand, bucketKey };
43
37
  interface CmdRun {
44
38
  normalized: string;
45
39
  raw: string;
@@ -132,4 +126,3 @@ export declare function renderConversationMarkdown(events: SessionEvent[], opts?
132
126
  export declare function renderJson(events: SessionEvent[], meta?: SessionMeta, opts?: {
133
127
  redact?: boolean;
134
128
  }): string;
135
- export {};