@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.
- package/CHANGELOG.md +276 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +72 -6
- package/dist/commands/activity.js +198 -49
- package/dist/commands/beta.js +1 -0
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/exec.d.ts +14 -0
- package/dist/commands/exec.js +144 -14
- package/dist/commands/feed.js +77 -4
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- package/dist/commands/projects.d.ts +12 -0
- package/dist/commands/projects.js +358 -0
- package/dist/commands/routines.js +30 -24
- package/dist/commands/secrets.d.ts +43 -4
- package/dist/commands/secrets.js +217 -32
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-picker.d.ts +15 -0
- package/dist/commands/sessions-picker.js +107 -13
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +9 -1
- package/dist/commands/sessions.d.ts +10 -5
- package/dist/commands/sessions.js +65 -27
- package/dist/index.js +19 -3
- package/dist/lib/activity.d.ts +80 -13
- package/dist/lib/activity.js +418 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +9 -3
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +14 -4
- package/dist/lib/menubar/install-menubar.js +20 -6
- package/dist/lib/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -0
- package/dist/lib/project-key.d.ts +44 -0
- package/dist/lib/project-key.js +79 -0
- package/dist/lib/project-root.js +16 -0
- package/dist/lib/project-status.d.ts +69 -0
- package/dist/lib/project-status.js +101 -0
- package/dist/lib/projects.d.ts +138 -0
- package/dist/lib/projects.js +301 -0
- package/dist/lib/remote-agents-json.d.ts +9 -0
- package/dist/lib/remote-agents-json.js +11 -5
- package/dist/lib/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -0
- package/dist/lib/session/bash-command.d.ts +53 -0
- package/dist/lib/session/bash-command.js +364 -0
- package/dist/lib/session/digest.d.ts +13 -0
- package/dist/lib/session/digest.js +48 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/relative-time.d.ts +37 -0
- package/dist/lib/session/relative-time.js +96 -8
- package/dist/lib/session/remote-list.js +5 -2
- package/dist/lib/session/render.d.ts +9 -9
- package/dist/lib/session/render.js +112 -73
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/ssh-exec.d.ts +6 -0
- package/dist/lib/ssh-exec.js +10 -1
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/state.d.ts +11 -0
- package/dist/lib/state.js +16 -0
- package/dist/lib/terminal/backends/index.d.ts +10 -2
- package/dist/lib/terminal/backends/index.js +14 -2
- package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
- package/dist/lib/terminal/backends/terminal-app.js +73 -0
- package/dist/lib/terminal/index.d.ts +2 -1
- package/dist/lib/terminal/index.js +2 -1
- package/dist/lib/terminal/preferred.d.ts +89 -0
- package/dist/lib/terminal/preferred.js +87 -0
- package/dist/lib/terminal/run-surface.d.ts +82 -0
- package/dist/lib/terminal/run-surface.js +146 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/types.d.ts +1 -1
- 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
|
+
}
|