@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
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Filters for `agents secrets list`.
3
+ *
4
+ * The listing had no filtering at all: `--host`/`--device` pick a machine and
5
+ * `--json` picks a format, but nothing selected over the bundles themselves. On
6
+ * a fleet with fifty-odd bundles the answerable questions — which ones read with
7
+ * no Touch ID at all, which hold a raw literal, which have already expired, what
8
+ * has not been touched in three months — meant piping the table through grep, or
9
+ * were simply unanswerable.
10
+ *
11
+ * Everything here is pure: parsing produces a `SecretsListFilter`, and
12
+ * `bundleMatchesFilter` is a predicate over a bundle plus the ambient facts it
13
+ * cannot derive itself (which bundles the broker currently holds, and the
14
+ * current time). That keeps the whole surface unit-testable without a keychain.
15
+ *
16
+ * Shape follows the `agents sessions` house style: comma-separated lists, an
17
+ * unknown value is a loud error naming the valid set (never a silent ignore),
18
+ * and every axis narrows independently so they AND-compose.
19
+ */
20
+ import { parseCommaSeparatedList } from '../../commands/utils.js';
21
+ // From the leaf module, NOT discover.js — the latter imports `../sqlite.js`, so
22
+ // reaching through it for one parser would pull node:sqlite into every `agents
23
+ // secrets` invocation and print Node's SQLite ExperimentalWarning on stderr.
24
+ import { parseTimeFilter } from '../session/relative-time.js';
25
+ import { describeBundle, bundlePolicy, SECRET_TYPES, } from './bundles.js';
26
+ /** Ref kinds a var's value can have, from `describeBundle`. */
27
+ export const REF_KINDS = ['literal', 'keychain', 'env', 'file', 'exec'];
28
+ const POLICIES = ['always', 'hold', 'never'];
29
+ const BACKENDS = ['keychain', 'file', 'vault'];
30
+ /** Default window for `--expiring` with no argument — matches the EXPIRING column. */
31
+ export const DEFAULT_EXPIRING_DAYS = 30;
32
+ /**
33
+ * Validate one comma-separated enum list. An unknown value throws and names the
34
+ * whole valid set — a silent ignore would let `--policy hodl` quietly return
35
+ * every bundle, which reads as "nothing matches that" and is worse than an error.
36
+ * Values are lowercased, matching `parsePolicyOpt`'s handling of policy names.
37
+ */
38
+ export function parseEnumList(raw, flag, valid) {
39
+ const parts = parseCommaSeparatedList(raw).map((s) => s.toLowerCase());
40
+ if (parts.length === 0) {
41
+ throw new Error(`${flag} requires at least one value. Valid values: ${valid.join(', ')}`);
42
+ }
43
+ for (const p of parts) {
44
+ if (!valid.includes(p)) {
45
+ throw new Error(`Invalid value "${p}" for ${flag}. Valid values: ${valid.join(', ')}`);
46
+ }
47
+ }
48
+ // De-dup so `--policy hold,hold` behaves like `--policy hold`.
49
+ return [...new Set(parts)];
50
+ }
51
+ /** Parse `--expiring` — a bare flag means the default window, a value means N days. */
52
+ function parseExpiringDays(raw) {
53
+ if (raw === undefined || raw === false)
54
+ return undefined;
55
+ if (raw === true || raw === '')
56
+ return DEFAULT_EXPIRING_DAYS;
57
+ const n = Number(raw);
58
+ // 0 is rejected rather than accepted as a no-op: the window is `0 <= d < N`,
59
+ // so `--expiring 0` can never match anything, not even a key expiring today.
60
+ // A flag that silently returns nothing is worse than one that says why.
61
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
62
+ throw new Error(`Invalid --expiring '${raw}'. Use a whole number of days >= 1, e.g. --expiring 7. ` +
63
+ 'For keys that have already lapsed, use --expired.');
64
+ }
65
+ return n;
66
+ }
67
+ /** Build a validated filter from commander's option bag. Throws on bad input. */
68
+ export function parseListFilters(opts, query) {
69
+ if (opts.held && opts.notHeld) {
70
+ throw new Error('--held and --not-held are mutually exclusive');
71
+ }
72
+ const filter = {};
73
+ const trimmedQuery = query?.trim();
74
+ if (trimmedQuery)
75
+ filter.query = trimmedQuery.toLowerCase();
76
+ if (opts.policy)
77
+ filter.policy = parseEnumList(opts.policy, '--policy', POLICIES);
78
+ if (opts.backend)
79
+ filter.backend = parseEnumList(opts.backend, '--backend', BACKENDS);
80
+ if (opts.type)
81
+ filter.type = parseEnumList(opts.type, '--type', SECRET_TYPES);
82
+ if (opts.kind)
83
+ filter.kind = parseEnumList(opts.kind, '--kind', REF_KINDS);
84
+ if (opts.held)
85
+ filter.held = true;
86
+ if (opts.notHeld)
87
+ filter.held = false;
88
+ if (opts.expired)
89
+ filter.expired = true;
90
+ const expiringDays = parseExpiringDays(opts.expiring);
91
+ if (expiringDays !== undefined)
92
+ filter.expiringDays = expiringDays;
93
+ if (opts.unused) {
94
+ // parseTimeFilter turns '90d' into "the epoch-ms 90 days ago"; a bundle is
95
+ // unused when its last_used predates that instant.
96
+ const cutoff = parseTimeFilter(opts.unused);
97
+ if (!cutoff) {
98
+ throw new Error(`Invalid --unused '${opts.unused}'. Use e.g. 30d, 4w, 3mo, or an ISO date.`);
99
+ }
100
+ filter.unusedBefore = cutoff;
101
+ }
102
+ return filter;
103
+ }
104
+ /** True when any axis is set — used to decide whether the empty state should
105
+ * explain itself rather than claim there are no bundles at all. */
106
+ export function filterIsActive(f) {
107
+ return Object.keys(f).length > 0;
108
+ }
109
+ /** Whole days from now until end-of-day UTC of an ISO date. Negative once past.
110
+ * Mirrors the `daysUntil` used by the human render so the filter and the column
111
+ * can never disagree about whether something has expired. */
112
+ function daysUntil(iso, now) {
113
+ const target = new Date(`${iso}T23:59:59Z`).getTime();
114
+ return Math.floor((target - now) / (24 * 60 * 60 * 1000));
115
+ }
116
+ /** Expiry tallies for one bundle: how many vars are already past, and how many
117
+ * fall due within `withinDays`. */
118
+ export function bundleExpiry(b, now, withinDays = DEFAULT_EXPIRING_DAYS) {
119
+ let expired = 0;
120
+ let soon = 0;
121
+ for (const m of Object.values(b.meta ?? {})) {
122
+ if (!m.expires)
123
+ continue;
124
+ const d = daysUntil(m.expires, now);
125
+ if (d < 0)
126
+ expired++;
127
+ else if (d < withinDays)
128
+ soon++;
129
+ }
130
+ return { expired, soon };
131
+ }
132
+ /** Does this bundle satisfy every set axis? Pure. */
133
+ export function bundleMatchesFilter(b, f, ctx) {
134
+ if (f.query) {
135
+ const haystack = `${b.name} ${b.description ?? ''}`.toLowerCase();
136
+ if (!haystack.includes(f.query))
137
+ return false;
138
+ }
139
+ if (f.policy && !f.policy.includes(bundlePolicy(b)))
140
+ return false;
141
+ if (f.backend && !f.backend.includes(b.backend ?? 'keychain'))
142
+ return false;
143
+ if (f.held !== undefined) {
144
+ // A lapsed entry is not held — same liveness rule the POLICY column uses.
145
+ const exp = ctx.held.get(b.name);
146
+ const isHeld = exp !== undefined && exp > ctx.now;
147
+ if (isHeld !== f.held)
148
+ return false;
149
+ }
150
+ if (f.type) {
151
+ const types = Object.values(b.meta ?? {}).map((m) => m.type).filter(Boolean);
152
+ if (!types.some((t) => f.type.includes(t)))
153
+ return false;
154
+ }
155
+ if (f.kind) {
156
+ const kinds = describeBundle(b).map((e) => e.kind);
157
+ if (!kinds.some((k) => f.kind.includes(k)))
158
+ return false;
159
+ }
160
+ if (f.expired || f.expiringDays !== undefined) {
161
+ const { expired, soon } = bundleExpiry(b, ctx.now, f.expiringDays ?? DEFAULT_EXPIRING_DAYS);
162
+ if (f.expired && expired === 0)
163
+ return false;
164
+ if (f.expiringDays !== undefined && soon === 0)
165
+ return false;
166
+ }
167
+ if (f.unusedBefore !== undefined) {
168
+ // Never used counts as unused — it is the strongest form of the answer.
169
+ if (b.last_used && new Date(b.last_used).getTime() >= f.unusedBefore)
170
+ return false;
171
+ }
172
+ return true;
173
+ }
174
+ /** Sort fields for `--sort`. `name` is the default and matches `listBundles()`. */
175
+ export const SORT_FIELDS = ['name', 'used', 'created', 'updated', 'expiry'];
176
+ export function parseSortField(raw) {
177
+ if (!raw)
178
+ return 'name';
179
+ const v = raw.toLowerCase();
180
+ if (!SORT_FIELDS.includes(v)) {
181
+ throw new Error(`Invalid --sort '${raw}'. Valid values: ${SORT_FIELDS.join(', ')}`);
182
+ }
183
+ return v;
184
+ }
185
+ /** Epoch-ms of a bundle's soonest expiry, or Infinity when nothing expires — so
186
+ * `--sort expiry` puts the most urgent first and never-expiring bundles last. */
187
+ function soonestExpiry(b) {
188
+ let soonest = Infinity;
189
+ for (const m of Object.values(b.meta ?? {})) {
190
+ if (!m.expires)
191
+ continue;
192
+ const t = new Date(`${m.expires}T23:59:59Z`).getTime();
193
+ if (t < soonest)
194
+ soonest = t;
195
+ }
196
+ return soonest;
197
+ }
198
+ /** Sort a copy. Time fields are most-recent-first (the useful direction for
199
+ * "what did I touch lately"); `expiry` is soonest-first; ties fall back to name
200
+ * so the order is stable. */
201
+ export function sortBundles(bundles, field) {
202
+ const stamp = (iso) => (iso ? new Date(iso).getTime() : 0);
203
+ const byName = (a, z) => a.name.localeCompare(z.name);
204
+ const out = [...bundles];
205
+ if (field === 'name')
206
+ return out.sort(byName);
207
+ out.sort((a, z) => {
208
+ if (field === 'expiry') {
209
+ const d = soonestExpiry(a) - soonestExpiry(z);
210
+ return d !== 0 ? d : byName(a, z);
211
+ }
212
+ const key = field === 'used' ? 'last_used' : field === 'created' ? 'created_at' : 'updated_at';
213
+ const d = stamp(z[key]) - stamp(a[key]);
214
+ return d !== 0 ? d : byName(a, z);
215
+ });
216
+ return out;
217
+ }
218
+ /** Human summary of the active filters, for the empty state. `sessions` only
219
+ * echoes --project/--all on a miss, which leaves you guessing which flag emptied
220
+ * the list; naming every active axis is the difference between "nothing matched"
221
+ * and knowing what to relax. */
222
+ export function describeFilter(f) {
223
+ const parts = [];
224
+ if (f.query)
225
+ parts.push(`matching "${f.query}"`);
226
+ if (f.policy)
227
+ parts.push(`policy ${f.policy.join('/')}`);
228
+ if (f.backend)
229
+ parts.push(`backend ${f.backend.join('/')}`);
230
+ if (f.type)
231
+ parts.push(`type ${f.type.join('/')}`);
232
+ if (f.kind)
233
+ parts.push(`kind ${f.kind.join('/')}`);
234
+ if (f.held === true)
235
+ parts.push('currently held');
236
+ if (f.held === false)
237
+ parts.push('not currently held');
238
+ if (f.expired)
239
+ parts.push('with an expired key');
240
+ if (f.expiringDays !== undefined)
241
+ parts.push(`expiring within ${f.expiringDays}d`);
242
+ if (f.unusedBefore !== undefined)
243
+ parts.push('unused since the given cutoff');
244
+ return parts.join(', ');
245
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Parses the raw command strings agents pass to Bash tool calls into structured
3
+ * metadata: the executable, category, subcommand, and a display summary. Used by
4
+ * session rendering and the activity-log hook so `agents sessions` and
5
+ * `agents activity` can summarize what actually happened instead of printing a
6
+ * wall of shell.
7
+ */
8
+ export type BashCategory = 'vcs' | 'build-test' | 'install' | 'remote' | 'http' | 'media' | 'upscaling' | 'metadata' | 'probe' | 'search' | 'shell' | 'wait' | 'other';
9
+ export interface BashToolInfo {
10
+ category: BashCategory;
11
+ signal: 'high' | 'mid' | 'low';
12
+ action: string;
13
+ aliases?: string[];
14
+ }
15
+ export interface BashCommandInfo {
16
+ tool: string;
17
+ category: BashCategory;
18
+ subcommand: string;
19
+ action: string;
20
+ summary: string;
21
+ signal: 'high' | 'mid' | 'low';
22
+ }
23
+ export declare function unwrapCommand(cmd: string): string;
24
+ /**
25
+ * Split a possibly-compound Bash command into simple commands, then tokenize each
26
+ * with shlex. Returns an empty array for empty input.
27
+ */
28
+ export declare function tokenizeBash(cmd: string): string[][];
29
+ /**
30
+ * Classify the first simple command in a Bash string. Returns coarse metadata
31
+ * (tool name, category, subcommand, human action) used for summaries and
32
+ * activity logging. Unknown executables fall back to `other`.
33
+ */
34
+ export declare function classifyBashCommand(command: string): BashCommandInfo;
35
+ /**
36
+ * Stable bucket key for grouping similar Bash commands in summaries. Commands run
37
+ * through a remote wrapper (ssh/scp/rsync) get an `ssh→` prefix on the inner key,
38
+ * so `ssh host "git push"` buckets as `ssh→git push`, distinct from a local push.
39
+ */
40
+ export declare function bucketKey(command: string): string;
41
+ /**
42
+ * Detect high-signal Bash-driven milestones (video renders, upscales, metadata
43
+ * edits, git commits/pushes/worktrees, PR opens). Returns null for routine
44
+ * commands.
45
+ */
46
+ export declare function detectBashMilestone(command: string): {
47
+ event: string;
48
+ detail: string;
49
+ } | null;
50
+ /**
51
+ * Human-readable category label for renderers.
52
+ */
53
+ export declare function categoryLabel(category: BashCategory): string;
@@ -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
+ }