@standardagents/code 0.0.0-dev.fffff

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.
@@ -0,0 +1,570 @@
1
+ /**
2
+ * Host-side executors for forwarded coding tools. These run on the user's
3
+ * machine. Paths are resolved relative to the project directory; everything is
4
+ * best-effort and returns a structured {@link HostResult} rather than throwing.
5
+ */
6
+ import fs from "node:fs";
7
+ import fsp from "node:fs/promises";
8
+ import path from "node:path";
9
+ import crypto from "node:crypto";
10
+ import { spawn } from "node:child_process";
11
+ import type { HostResult } from "./types.ts";
12
+ import { ProcessRegistry, LOG_DIR, type ProcessEntry } from "./process-registry.ts";
13
+ import type { McpManager } from "./mcp.ts";
14
+ import { saveMcpServer, removeMcpServer, serverFromCommand } from "./mcp-config.ts";
15
+
16
+ /**
17
+ * How long to watch a freshly-started background process before declaring it
18
+ * "running". Fast failures (port already in use, missing binary, bad flag) exit
19
+ * within milliseconds; this window catches them so we never report or track a
20
+ * process that never actually came up.
21
+ */
22
+ const STARTUP_GRACE_MS = 600;
23
+
24
+ /** True if a pid is still alive (signal-0 probe; EPERM = alive but not ours). */
25
+ function isAlive(pid: number): boolean {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ } catch (err) {
30
+ return (err as NodeJS.ErrnoException).code === "EPERM";
31
+ }
32
+ }
33
+
34
+ /** Read the last `n` non-empty lines of a log file (empty string if unreadable). */
35
+ async function readLogTail(logPath: string, n: number): Promise<string> {
36
+ try {
37
+ const content = await fsp.readFile(logPath, "utf8");
38
+ return content.split("\n").filter(Boolean).slice(-n).join("\n");
39
+ } catch {
40
+ return "";
41
+ }
42
+ }
43
+
44
+ export class HostTools {
45
+ constructor(
46
+ private projectDir: string,
47
+ private registry?: ProcessRegistry,
48
+ private threadId?: string,
49
+ private machine?: string,
50
+ private mcp?: McpManager,
51
+ /** Called after the MCP server set changes so the catalog can be re-published. */
52
+ private onMcpCatalogChange?: () => void
53
+ ) {}
54
+
55
+ /** Resolve a user/model-supplied path against the project directory. */
56
+ resolve(p: string | undefined): string {
57
+ if (!p || p === ".") return this.projectDir;
58
+ return path.resolve(this.projectDir, p);
59
+ }
60
+
61
+ /** True when the resolved path escapes the project directory. */
62
+ isOutsideProject(p: string | undefined): boolean {
63
+ const abs = this.resolve(p);
64
+ const rel = path.relative(this.projectDir, abs);
65
+ return rel.startsWith("..") || path.isAbsolute(rel);
66
+ }
67
+
68
+ async execute(tool: string, args: Record<string, unknown>): Promise<HostResult> {
69
+ try {
70
+ switch (tool) {
71
+ case "read_file":
72
+ return await this.readFile(args);
73
+ case "list_dir":
74
+ return await this.listDir(args);
75
+ case "grep":
76
+ return await this.grep(args);
77
+ case "glob":
78
+ return await this.glob(args);
79
+ case "write_file":
80
+ return await this.writeFile(args);
81
+ case "edit_file":
82
+ return await this.editFile(args);
83
+ case "bash":
84
+ return await this.bash(args);
85
+ case "delete":
86
+ return await this.deletePath(args);
87
+ case "background_process": {
88
+ // One tool, dispatched by action: start launches + verifies a process;
89
+ // list/logs/stop inspect or terminate tracked ones.
90
+ const action = String(args.action || "list");
91
+ if (action === "start") return await this.runBackground(args);
92
+ return await this.backgroundProcesses(args);
93
+ }
94
+ case "mcp": {
95
+ // Forwarded MCP tool: the CLI is the MCP host, so the negotiation and
96
+ // execution happen here against the locally-connected server.
97
+ if (!this.mcp) {
98
+ return { ok: false, error: "MCP is not available in this session." };
99
+ }
100
+ // Removing a server also drops it from the stored config and republishes
101
+ // the catalog, so it doesn't linger or reconnect.
102
+ if (String(args.action) === "remove") {
103
+ const name = String(args.server || "");
104
+ if (!name) return { ok: false, error: "remove requires a 'server' name." };
105
+ this.mcp.disconnect(name);
106
+ removeMcpServer(name);
107
+ this.onMcpCatalogChange?.();
108
+ return { ok: true, result: JSON.stringify({ removed: name }) };
109
+ }
110
+ return await this.mcp.dispatch(args);
111
+ }
112
+ case "install_mcp":
113
+ return await this.installMcp(args);
114
+ default:
115
+ return { ok: false, error: `Unknown tool: ${tool}` };
116
+ }
117
+ } catch (err) {
118
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
119
+ }
120
+ }
121
+
122
+ private async readFile(args: Record<string, unknown>): Promise<HostResult> {
123
+ const file = this.resolve(String(args.path || ""));
124
+ const stat = await fsp.stat(file).catch(() => null);
125
+ if (!stat) return { ok: false, error: `File not found: ${args.path}` };
126
+ if (stat.isDirectory()) return { ok: false, error: `${args.path} is a directory` };
127
+ if (stat.size > 2_000_000) return { ok: false, error: `File too large (${stat.size} bytes)` };
128
+
129
+ const content = await fsp.readFile(file, "utf8");
130
+ const lines = content.split("\n");
131
+ const offset = typeof args.offset === "number" ? Math.max(1, args.offset) : 1;
132
+ const limit = typeof args.limit === "number" ? args.limit : lines.length;
133
+ const slice = lines.slice(offset - 1, offset - 1 + limit);
134
+ const numbered = slice.map((l, i) => `${offset + i}\t${l}`).join("\n");
135
+ return { ok: true, result: numbered || "(empty file)" };
136
+ }
137
+
138
+ private async listDir(args: Record<string, unknown>): Promise<HostResult> {
139
+ const dir = this.resolve(args.path ? String(args.path) : undefined);
140
+ const entries = await fsp.readdir(dir, { withFileTypes: true });
141
+ const sorted = entries
142
+ .filter((e) => e.name !== ".git" && e.name !== "node_modules")
143
+ .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
144
+ const lines = sorted.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
145
+ const header = `${path.relative(this.projectDir, dir) || "."} (${lines.length} entries)`;
146
+ return { ok: true, result: `${header}\n${lines.join("\n")}` };
147
+ }
148
+
149
+ private async grep(args: Record<string, unknown>): Promise<HostResult> {
150
+ const pattern = String(args.pattern || "");
151
+ if (!pattern) return { ok: false, error: "pattern is required" };
152
+ const searchPath = this.resolve(args.path ? String(args.path) : undefined);
153
+ const rgArgs = ["--line-number", "--no-heading", "--color", "never", "--max-count", "200"];
154
+ if (args.ignore_case) rgArgs.push("-i");
155
+ if (args.glob) rgArgs.push("--glob", String(args.glob));
156
+ rgArgs.push("--", pattern, searchPath);
157
+ const rg = await this.run("rg", rgArgs, this.projectDir, 30000);
158
+ if (rg.code === 127) {
159
+ return { ok: false, error: "ripgrep (rg) not found on host; install it for grep." };
160
+ }
161
+ // rg exits 1 with no matches — treat as a successful empty result.
162
+ const out = rg.stdout.trim();
163
+ return { ok: true, result: out || "(no matches)" };
164
+ }
165
+
166
+ private async glob(args: Record<string, unknown>): Promise<HostResult> {
167
+ const pattern = String(args.pattern || "");
168
+ if (!pattern) return { ok: false, error: "pattern is required" };
169
+ const base = this.resolve(args.path ? String(args.path) : undefined);
170
+ const rg = await this.run("rg", ["--files", "--glob", pattern, base], this.projectDir, 30000);
171
+ if (rg.code === 127) {
172
+ // Fallback: native recursive walk with a simple glob match.
173
+ const matches = await this.walkGlob(base, pattern);
174
+ return { ok: true, result: matches.slice(0, 300).join("\n") || "(no files)" };
175
+ }
176
+ const rel = rg.stdout
177
+ .trim()
178
+ .split("\n")
179
+ .filter(Boolean)
180
+ .map((p) => path.relative(this.projectDir, p))
181
+ .slice(0, 300);
182
+ return { ok: true, result: rel.join("\n") || "(no files)" };
183
+ }
184
+
185
+ private async writeFile(args: Record<string, unknown>): Promise<HostResult> {
186
+ const file = this.resolve(String(args.path || ""));
187
+ const content = String(args.content ?? "");
188
+ await fsp.mkdir(path.dirname(file), { recursive: true });
189
+ const existed = fs.existsSync(file);
190
+ await fsp.writeFile(file, content, "utf8");
191
+ return {
192
+ ok: true,
193
+ result: `${existed ? "Overwrote" : "Created"} ${path.relative(this.projectDir, file)} (${Buffer.byteLength(content)} bytes)`,
194
+ };
195
+ }
196
+
197
+ private async editFile(args: Record<string, unknown>): Promise<HostResult> {
198
+ const file = this.resolve(String(args.path || ""));
199
+ const oldStr = String(args.old_string ?? "");
200
+ const newStr = String(args.new_string ?? "");
201
+ const replaceAll = args.replace_all === true;
202
+ const stat = await fsp.stat(file).catch(() => null);
203
+ if (!stat) return { ok: false, error: `File not found: ${args.path}` };
204
+
205
+ const content = await fsp.readFile(file, "utf8");
206
+ if (oldStr === "") return { ok: false, error: "old_string cannot be empty" };
207
+ const count = content.split(oldStr).length - 1;
208
+ if (count === 0) return { ok: false, error: "old_string not found in file (it must match exactly)." };
209
+ if (count > 1 && !replaceAll) {
210
+ return { ok: false, error: `old_string is not unique (${count} matches). Add more context or set replace_all.` };
211
+ }
212
+ const updated = replaceAll ? content.split(oldStr).join(newStr) : content.replace(oldStr, newStr);
213
+ await fsp.writeFile(file, updated, "utf8");
214
+ return { ok: true, result: `Edited ${path.relative(this.projectDir, file)} (${count} replacement${count === 1 ? "" : "s"})` };
215
+ }
216
+
217
+ private async deletePath(args: Record<string, unknown>): Promise<HostResult> {
218
+ const target = this.resolve(String(args.path || ""));
219
+ const recursive = args.recursive === true;
220
+ const stat = await fsp.stat(target).catch(() => null);
221
+ if (!stat) return { ok: false, error: `Path not found: ${args.path}` };
222
+ if (stat.isDirectory() && !recursive) {
223
+ return { ok: false, error: `${args.path} is a directory; set recursive to delete it.` };
224
+ }
225
+ await fsp.rm(target, { recursive, force: false });
226
+ return { ok: true, result: `Deleted ${path.relative(this.projectDir, target) || target}` };
227
+ }
228
+
229
+ private async bash(args: Record<string, unknown>): Promise<HostResult> {
230
+ const command = String(args.command || "");
231
+ if (!command.trim()) return { ok: false, error: "command is required" };
232
+ const cwd = args.cwd ? this.resolve(String(args.cwd)) : this.projectDir;
233
+ const timeout = typeof args.timeout_ms === "number" ? args.timeout_ms : 120000;
234
+ const res = await this.run("bash", ["-lc", command], cwd, timeout);
235
+ const combined = [res.stdout, res.stderr].filter(Boolean).join("\n").trim();
236
+ const truncated = combined.length > 30000 ? combined.slice(0, 30000) + "\n…(truncated)" : combined;
237
+ if (res.timedOut) {
238
+ return { ok: false, error: `Command timed out after ${timeout}ms.\n${truncated}` };
239
+ }
240
+ const status = `exit code ${res.code}`;
241
+ return {
242
+ ok: res.code === 0,
243
+ result: `${truncated || "(no output)"}\n[${status}]`,
244
+ error: res.code === 0 ? undefined : `Command failed (${status}).\n${truncated}`,
245
+ };
246
+ }
247
+
248
+ /** Start a tracked long-running process, detached, with output to a log file. */
249
+ private async runBackground(args: Record<string, unknown>): Promise<HostResult> {
250
+ const command = String(args.command || "");
251
+ if (!command.trim()) return { ok: false, error: "command is required" };
252
+ const cwd = args.cwd ? this.resolve(String(args.cwd)) : this.projectDir;
253
+ const id = crypto.randomUUID().slice(0, 8);
254
+ const logPath = path.join(LOG_DIR, `${id}.log`);
255
+
256
+ let out: number;
257
+ try {
258
+ await fsp.mkdir(LOG_DIR, { recursive: true });
259
+ out = fs.openSync(logPath, "a");
260
+ } catch (err) {
261
+ return { ok: false, error: `Could not open log file: ${err instanceof Error ? err.message : String(err)}` };
262
+ }
263
+
264
+ let child;
265
+ try {
266
+ child = spawn("bash", ["-lc", command], { cwd, detached: true, stdio: ["ignore", out, out] });
267
+ } catch (err) {
268
+ fs.closeSync(out);
269
+ return { ok: false, error: `Failed to start: ${err instanceof Error ? err.message : String(err)}` };
270
+ }
271
+ fs.closeSync(out); // the child holds its own copy of the fd
272
+ const pid = child.pid;
273
+ if (!pid) return { ok: false, error: "Process failed to start (no pid)." };
274
+
275
+ // Verify the process actually STAYED UP before we record it. A command that
276
+ // fails fast (a port already in use, a missing binary, a bad flag) exits
277
+ // within milliseconds — we don't want to report a phantom "running" process
278
+ // or persist it to the registry. Watch for an early exit during a short
279
+ // grace window, then surface the captured output so the agent can see why.
280
+ let earlyExit: number | null | undefined;
281
+ const onEarlyExit = (code: number | null) => {
282
+ earlyExit = code;
283
+ };
284
+ child.on("exit", onEarlyExit);
285
+ await new Promise((r) => setTimeout(r, STARTUP_GRACE_MS));
286
+
287
+ if (earlyExit !== undefined || !isAlive(pid)) {
288
+ const tail = await readLogTail(logPath, 15);
289
+ const code = earlyExit ?? "unknown";
290
+ return {
291
+ ok: false,
292
+ error:
293
+ `The process exited immediately (exit code ${code}) — it did not stay running, ` +
294
+ `so nothing was started or tracked.` +
295
+ (tail ? `\n\nOutput:\n${tail}` : " No output was captured."),
296
+ };
297
+ }
298
+
299
+ // It survived the grace window — now it's safe to detach and record it.
300
+ child.removeListener("exit", onEarlyExit);
301
+ child.unref();
302
+
303
+ if (this.registry) {
304
+ const entry: ProcessEntry = {
305
+ id,
306
+ pid,
307
+ command,
308
+ description: typeof args.description === "string" ? args.description : undefined,
309
+ cwd,
310
+ machine: this.machine ?? "",
311
+ logPath,
312
+ startedAt: Date.now(),
313
+ status: "running",
314
+ };
315
+ // Persist to the thread's KV (server-side) so the process is visible on
316
+ // resume from any machine.
317
+ await this.registry.add(entry);
318
+ child.on("exit", (code) => void this.registry?.markExited(id, code));
319
+ }
320
+
321
+ return {
322
+ ok: true,
323
+ result: JSON.stringify(
324
+ {
325
+ id,
326
+ pid,
327
+ status: "running",
328
+ logPath,
329
+ note: `Started in the background as process ${id} (pid ${pid}) and confirmed running. Output is logged to ${logPath}. Use background_process (list/logs/stop) to check on it or stop it.`,
330
+ },
331
+ null,
332
+ 2
333
+ ),
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Install + connect an MCP server from a name + launch command (e.g.
339
+ * "npx -y @playwright/mcp@latest"). Saves it to the user's MCP config,
340
+ * launches it, runs the handshake, and republishes the catalog on success so
341
+ * the agent immediately sees its tools. The connect IS the install — for `npx`
342
+ * commands the package is fetched on first run.
343
+ */
344
+ private async installMcp(args: Record<string, unknown>): Promise<HostResult> {
345
+ if (!this.mcp) return { ok: false, error: "MCP is not available in this session." };
346
+ const name = String(args.name || "");
347
+ const command = String(args.command || "");
348
+ if (!name || !command) {
349
+ return { ok: false, error: "install_mcp needs a `name` and a `command` (e.g. \"npx -y @playwright/mcp@latest\")." };
350
+ }
351
+
352
+ let env: Record<string, string> | undefined;
353
+ if (typeof args.env_json === "string" && args.env_json.trim()) {
354
+ try {
355
+ const parsed = JSON.parse(args.env_json);
356
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) env = parsed as Record<string, string>;
357
+ else return { ok: false, error: "env_json must encode a JSON object of environment variables." };
358
+ } catch (e) {
359
+ return { ok: false, error: `env_json is not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
360
+ }
361
+ }
362
+
363
+ const cfg = serverFromCommand(name, command, env);
364
+ if (!cfg) return { ok: false, error: `Could not parse the command: "${command}".` };
365
+
366
+ saveMcpServer(cfg);
367
+ try {
368
+ const client = await this.mcp.connect(cfg);
369
+ this.onMcpCatalogChange?.();
370
+ const tools = client.tools.map((t) => t.name);
371
+ return {
372
+ ok: true,
373
+ result: JSON.stringify(
374
+ {
375
+ server: cfg.name,
376
+ command: `${cfg.command} ${cfg.args.join(" ")}`.trim(),
377
+ connected: true,
378
+ toolCount: tools.length,
379
+ tools,
380
+ note:
381
+ `Installed and connected MCP server "${cfg.name}" with ${tools.length} tool${tools.length === 1 ? "" : "s"}` +
382
+ `${tools.length ? `: ${tools.join(", ")}` : ""}. They are available now and will auto-connect next session.`,
383
+ },
384
+ null,
385
+ 2
386
+ ),
387
+ };
388
+ } catch (err) {
389
+ this.onMcpCatalogChange?.();
390
+ return {
391
+ ok: false,
392
+ error: `Saved MCP server "${cfg.name}" but it failed to start: ${err instanceof Error ? err.message : String(err)}`,
393
+ };
394
+ }
395
+ }
396
+
397
+ /** List / inspect logs of / stop tracked background processes. */
398
+ private async backgroundProcesses(args: Record<string, unknown>): Promise<HostResult> {
399
+ const action = String(args.action || "list");
400
+ if (!this.registry) {
401
+ return { ok: true, result: "Background-process tracking is not available in this session." };
402
+ }
403
+
404
+ if (action === "list") {
405
+ const procs = await this.registry.list();
406
+ if (!procs.length) return { ok: true, result: "No background processes for this session." };
407
+ const lines = procs.map((p) => {
408
+ const age = relativeAge(p.startedAt);
409
+ const status =
410
+ p.status === "running"
411
+ ? "running"
412
+ : `${p.status}${typeof p.exitCode === "number" ? ` (exit ${p.exitCode})` : ""}`;
413
+ return `${p.id} [${status}] pid ${p.pid} started ${age}\n ${p.command}`;
414
+ });
415
+ return { ok: true, result: lines.join("\n") };
416
+ }
417
+
418
+ const id = String(args.id || "");
419
+ const proc = await this.registry.get(id);
420
+ if (!proc) return { ok: false, error: `No background process with id ${id}` };
421
+
422
+ if (action === "logs") {
423
+ const maxLines = typeof args.lines === "number" ? args.lines : 60;
424
+ let content = "";
425
+ try {
426
+ content = await fsp.readFile(proc.logPath, "utf8");
427
+ } catch {
428
+ return { ok: true, result: `(no output captured yet for ${id})` };
429
+ }
430
+ const tail = content.split("\n").slice(-maxLines).join("\n");
431
+ return { ok: true, result: tail || `(no output yet for ${id})` };
432
+ }
433
+
434
+ if (action === "stop") {
435
+ if (proc.status !== "running") {
436
+ return { ok: true, result: `Process ${id} is already ${proc.status}.` };
437
+ }
438
+ try {
439
+ process.kill(-proc.pid, "SIGTERM");
440
+ setTimeout(() => {
441
+ try {
442
+ process.kill(-proc.pid, "SIGKILL");
443
+ } catch {
444
+ // already gone
445
+ }
446
+ }, 3000);
447
+ } catch {
448
+ try {
449
+ process.kill(proc.pid, "SIGKILL");
450
+ } catch {
451
+ // already gone
452
+ }
453
+ }
454
+ await this.registry.markStopped(id);
455
+ return { ok: true, result: `Stopped background process ${id} (pid ${proc.pid}).` };
456
+ }
457
+
458
+ return { ok: false, error: `Unknown action: ${action}` };
459
+ }
460
+
461
+ private run(
462
+ cmd: string,
463
+ args: string[],
464
+ cwd: string,
465
+ timeoutMs: number
466
+ ): Promise<{ stdout: string; stderr: string; code: number; timedOut: boolean }> {
467
+ return new Promise((resolve) => {
468
+ let stdout = "";
469
+ let stderr = "";
470
+ let timedOut = false;
471
+ let exitCode = 0;
472
+ let settled = false;
473
+ // Cap captured output so a chatty/looping command can't grow memory
474
+ // without bound (which previously could take the whole CLI down).
475
+ const MAX_OUTPUT = 256 * 1024;
476
+
477
+ let child;
478
+ try {
479
+ // Own process group so a timeout can kill the whole tree, and so a
480
+ // backgrounded server keeps running after the CLI exits.
481
+ child = spawn(cmd, args, { cwd, detached: true });
482
+ } catch {
483
+ resolve({ stdout: "", stderr: "", code: 127, timedOut: false });
484
+ return;
485
+ }
486
+ child.unref();
487
+
488
+ let graceTimer: ReturnType<typeof setTimeout> | null = null;
489
+ const settle = (code: number) => {
490
+ if (settled) return;
491
+ settled = true;
492
+ clearTimeout(timer);
493
+ if (graceTimer) clearTimeout(graceTimer);
494
+ resolve({ stdout, stderr, code, timedOut });
495
+ };
496
+
497
+ const timer = setTimeout(() => {
498
+ timedOut = true;
499
+ try {
500
+ if (child.pid) process.kill(-child.pid, "SIGKILL");
501
+ } catch {
502
+ try {
503
+ child.kill("SIGKILL");
504
+ } catch {
505
+ // already gone
506
+ }
507
+ }
508
+ // Settle even if `close` never arrives (a detached child may hold stdio).
509
+ setTimeout(() => settle(exitCode), 250);
510
+ }, timeoutMs);
511
+
512
+ child.stdout?.on("data", (d) => {
513
+ if (stdout.length < MAX_OUTPUT) stdout += d.toString();
514
+ });
515
+ child.stderr?.on("data", (d) => {
516
+ if (stderr.length < MAX_OUTPUT) stderr += d.toString();
517
+ });
518
+ child.on("error", (err: NodeJS.ErrnoException) => {
519
+ if (!stderr) stderr = String(err);
520
+ settle(err.code === "ENOENT" ? 127 : 1);
521
+ });
522
+ // Normal completion: stdio drained.
523
+ child.on("close", (code) => settle(code ?? exitCode));
524
+ // The process exited but stdio may stay open if it backgrounded a child
525
+ // that inherited the pipes (e.g. `server &`). Resolve shortly after exit
526
+ // so background commands return promptly instead of hanging on `close`.
527
+ child.on("exit", (code) => {
528
+ exitCode = code ?? 0;
529
+ graceTimer = setTimeout(() => settle(exitCode), 250);
530
+ });
531
+ });
532
+ }
533
+
534
+ private async walkGlob(base: string, pattern: string): Promise<string[]> {
535
+ const re = globToRegExp(pattern);
536
+ const out: string[] = [];
537
+ const walk = async (dir: string) => {
538
+ const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
539
+ for (const e of entries) {
540
+ if (e.name === ".git" || e.name === "node_modules") continue;
541
+ const full = path.join(dir, e.name);
542
+ if (e.isDirectory()) await walk(full);
543
+ else {
544
+ const rel = path.relative(this.projectDir, full);
545
+ if (re.test(rel) || re.test(e.name)) out.push(rel);
546
+ }
547
+ }
548
+ };
549
+ await walk(base);
550
+ return out;
551
+ }
552
+ }
553
+
554
+ function relativeAge(startedAt: number): string {
555
+ const diff = (Date.now() - startedAt) / 1000;
556
+ if (diff < 60) return `${Math.floor(diff)}s ago`;
557
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
558
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
559
+ return `${Math.floor(diff / 86400)}d ago`;
560
+ }
561
+
562
+ function globToRegExp(glob: string): RegExp {
563
+ const escaped = glob
564
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
565
+ .replace(/\*\*/g, " ")
566
+ .replace(/\*/g, "[^/]*")
567
+ .replace(/ /g, ".*")
568
+ .replace(/\?/g, ".");
569
+ return new RegExp(`^${escaped}$`);
570
+ }