@steipete/oracle 0.4.4 → 0.5.0

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 (52) hide show
  1. package/README.md +11 -9
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +16 -48
  4. package/dist/scripts/agent-send.js +147 -0
  5. package/dist/scripts/docs-list.js +110 -0
  6. package/dist/scripts/git-policy.js +125 -0
  7. package/dist/scripts/runner.js +1378 -0
  8. package/dist/scripts/test-browser.js +103 -0
  9. package/dist/scripts/test-remote-chrome.js +68 -0
  10. package/dist/src/browser/actions/attachments.js +47 -16
  11. package/dist/src/browser/actions/promptComposer.js +29 -18
  12. package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
  13. package/dist/src/browser/chromeCookies.js +37 -6
  14. package/dist/src/browser/chromeLifecycle.js +166 -25
  15. package/dist/src/browser/config.js +25 -1
  16. package/dist/src/browser/constants.js +22 -3
  17. package/dist/src/browser/index.js +301 -21
  18. package/dist/src/browser/prompt.js +3 -1
  19. package/dist/src/browser/reattach.js +59 -0
  20. package/dist/src/browser/sessionRunner.js +15 -1
  21. package/dist/src/browser/windowsCookies.js +2 -1
  22. package/dist/src/cli/browserConfig.js +11 -0
  23. package/dist/src/cli/browserDefaults.js +41 -0
  24. package/dist/src/cli/detach.js +2 -2
  25. package/dist/src/cli/dryRun.js +4 -2
  26. package/dist/src/cli/engine.js +2 -2
  27. package/dist/src/cli/help.js +2 -2
  28. package/dist/src/cli/options.js +2 -1
  29. package/dist/src/cli/runOptions.js +1 -1
  30. package/dist/src/cli/sessionDisplay.js +98 -5
  31. package/dist/src/cli/sessionRunner.js +39 -6
  32. package/dist/src/cli/tui/index.js +15 -18
  33. package/dist/src/heartbeat.js +2 -2
  34. package/dist/src/oracle/background.js +10 -2
  35. package/dist/src/oracle/client.js +17 -0
  36. package/dist/src/oracle/config.js +10 -2
  37. package/dist/src/oracle/errors.js +24 -4
  38. package/dist/src/oracle/modelResolver.js +144 -0
  39. package/dist/src/oracle/oscProgress.js +1 -1
  40. package/dist/src/oracle/run.js +82 -34
  41. package/dist/src/oracle/runUtils.js +12 -8
  42. package/dist/src/remote/server.js +214 -23
  43. package/dist/src/sessionManager.js +5 -2
  44. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  45. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  46. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  47. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  48. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +0 -0
  49. package/package.json +47 -46
  50. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  51. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  52. package/vendor/oracle-notifier/README.md +0 -24
@@ -0,0 +1,1378 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Sweetistics runner wrapper: enforces timeouts, git policy, and trash-safe deletes before dispatching any repo command.
4
+ * When you tweak its behavior, add a short note to AGENTS.md via `./scripts/committer "docs: update AGENTS for runner" "AGENTS.md"` so other agents know the new expectations.
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import { cpSync, existsSync, renameSync, rmSync } from 'node:fs';
8
+ import { constants as osConstants } from 'node:os';
9
+ import { basename, isAbsolute, join, normalize, resolve } from 'node:path';
10
+ import process from 'node:process';
11
+ import { analyzeGitExecution, evaluateGitPolicies, } from './git-policy';
12
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
13
+ const EXTENDED_TIMEOUT_MS = 20 * 60 * 1000;
14
+ const LONG_TIMEOUT_MS = 25 * 60 * 1000; // Build + full-suite commands (Next.js build, test:all) routinely spike past 20 minutes—give them explicit headroom before tmux escalation.
15
+ const LINT_TIMEOUT_MS = 30 * 60 * 1000;
16
+ const LONG_RUN_REPORT_THRESHOLD_MS = 60 * 1000;
17
+ const ENABLE_DEBUG_LOGS = process.env.RUNNER_DEBUG === '1';
18
+ const MAX_SLEEP_SECONDS = 30;
19
+ const WRAPPER_COMMANDS = new Set([
20
+ 'sudo',
21
+ '/usr/bin/sudo',
22
+ 'env',
23
+ '/usr/bin/env',
24
+ 'command',
25
+ '/bin/command',
26
+ 'nohup',
27
+ '/usr/bin/nohup',
28
+ ]);
29
+ const SUMMARY_STYLE = resolveSummaryStyle(process.env.RUNNER_SUMMARY_STYLE);
30
+ // biome-ignore format: keep each keyword on its own line for grep-friendly diffs.
31
+ const LONG_SCRIPT_KEYWORDS = [
32
+ 'build',
33
+ 'test:all',
34
+ 'test:browser',
35
+ 'test:e2e',
36
+ 'test:e2e:headed',
37
+ 'vitest.browser',
38
+ 'vitest.browser.config.ts',
39
+ ];
40
+ const EXTENDED_SCRIPT_KEYWORDS = ['lint', 'test', 'playwright', 'check', 'docker'];
41
+ const SINGLE_TEST_SCRIPTS = new Set(['test:file']);
42
+ const SINGLE_TEST_FLAGS = new Set(['--run', '--filter']);
43
+ const TEST_BINARIES = new Set(['vitest', 'playwright', 'jest']);
44
+ const LINT_BINARIES = new Set(['eslint', 'biome', 'oxlint', 'knip']);
45
+ let cachedTrashCliCommand;
46
+ (async () => {
47
+ const commandArgs = parseArgs(process.argv.slice(2));
48
+ if (commandArgs.length === 0) {
49
+ printUsage('Missing command to execute.');
50
+ process.exit(1);
51
+ }
52
+ const workspaceDir = process.cwd();
53
+ const timeoutMs = determineEffectiveTimeoutMs(commandArgs);
54
+ const context = {
55
+ commandArgs,
56
+ workspaceDir,
57
+ timeoutMs,
58
+ };
59
+ const interception = await resolveCommandInterception(context);
60
+ if (interception.handled) {
61
+ return;
62
+ }
63
+ enforceGitPolicies(interception.gitContext);
64
+ await runCommand(context);
65
+ })().catch((error) => {
66
+ console.error('[runner] Unexpected failure:', error instanceof Error ? error.message : String(error));
67
+ process.exit(1);
68
+ });
69
+ // Parses the runner CLI args and rejects unsupported flags early.
70
+ function parseArgs(argv) {
71
+ const commandArgs = [];
72
+ let parsingOptions = true;
73
+ for (const token of argv) {
74
+ if (!parsingOptions) {
75
+ commandArgs.push(token);
76
+ continue;
77
+ }
78
+ if (token === '--') {
79
+ parsingOptions = false;
80
+ continue;
81
+ }
82
+ if (token === '--help' || token === '-h') {
83
+ printUsage();
84
+ process.exit(0);
85
+ }
86
+ if (token === '--timeout' || token.startsWith('--timeout=')) {
87
+ console.error('[runner] --timeout is no longer supported; rely on the automatic timeouts.');
88
+ process.exit(1);
89
+ }
90
+ parsingOptions = false;
91
+ commandArgs.push(token);
92
+ }
93
+ return commandArgs;
94
+ }
95
+ // Computes the timeout tier for the provided command tokens.
96
+ function determineEffectiveTimeoutMs(commandArgs) {
97
+ const strippedTokens = stripWrappersAndAssignments(commandArgs);
98
+ if (isTestRunnerSuiteInvocation(strippedTokens, 'integration')) {
99
+ return EXTENDED_TIMEOUT_MS;
100
+ }
101
+ if (referencesIntegrationSpec(strippedTokens)) {
102
+ return EXTENDED_TIMEOUT_MS;
103
+ }
104
+ if (shouldUseLintTimeout(commandArgs)) {
105
+ return LINT_TIMEOUT_MS;
106
+ }
107
+ if (shouldUseLongTimeout(commandArgs)) {
108
+ return LONG_TIMEOUT_MS;
109
+ }
110
+ if (shouldExtendTimeout(commandArgs) && !isSingleTestInvocation(commandArgs)) {
111
+ return EXTENDED_TIMEOUT_MS;
112
+ }
113
+ return DEFAULT_TIMEOUT_MS;
114
+ }
115
+ // Determines whether the command matches any keyword requiring extra time.
116
+ function shouldExtendTimeout(commandArgs) {
117
+ const tokens = stripWrappersAndAssignments(commandArgs);
118
+ if (tokens.length === 0) {
119
+ return false;
120
+ }
121
+ const [first, ...rest] = tokens;
122
+ if (!first) {
123
+ return false;
124
+ }
125
+ if (first === 'pnpm') {
126
+ return shouldExtendViaPnpm(rest);
127
+ }
128
+ if (first === 'bun') {
129
+ return shouldExtendViaBun(rest);
130
+ }
131
+ if (shouldExtendForScript(first) || TEST_BINARIES.has(first.toLowerCase())) {
132
+ return true;
133
+ }
134
+ return rest.some((token) => shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase()));
135
+ }
136
+ function shouldExtendViaPnpm(rest) {
137
+ if (rest.length === 0) {
138
+ return false;
139
+ }
140
+ const subcommand = rest[0];
141
+ if (!subcommand) {
142
+ return false;
143
+ }
144
+ if (subcommand === 'run') {
145
+ const script = rest[1];
146
+ return typeof script === 'string' && shouldExtendForScript(script);
147
+ }
148
+ if (subcommand === 'exec') {
149
+ const execTarget = rest[1];
150
+ if (execTarget && (shouldExtendForScript(execTarget) || TEST_BINARIES.has(execTarget.toLowerCase()))) {
151
+ return true;
152
+ }
153
+ return rest.slice(1).some((token) => shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase()));
154
+ }
155
+ return shouldExtendForScript(subcommand);
156
+ }
157
+ function shouldExtendViaBun(rest) {
158
+ if (rest.length === 0) {
159
+ return false;
160
+ }
161
+ const subcommand = rest[0];
162
+ if (!subcommand) {
163
+ return false;
164
+ }
165
+ if (subcommand === 'run') {
166
+ const script = rest[1];
167
+ return typeof script === 'string' && shouldExtendForScript(script);
168
+ }
169
+ if (subcommand === 'test') {
170
+ return true;
171
+ }
172
+ if (subcommand === 'x' || subcommand === 'bunx') {
173
+ const execTarget = rest[1];
174
+ if (execTarget && TEST_BINARIES.has(execTarget.toLowerCase())) {
175
+ return true;
176
+ }
177
+ }
178
+ return shouldExtendForScript(subcommand);
179
+ }
180
+ // Checks script names for long-running markers (lint/test/build/etc.).
181
+ function shouldExtendForScript(script) {
182
+ if (SINGLE_TEST_SCRIPTS.has(script)) {
183
+ return false;
184
+ }
185
+ return matchesScriptKeyword(script, EXTENDED_SCRIPT_KEYWORDS);
186
+ }
187
+ // Gives lint invocations the dedicated timeout bucket.
188
+ function shouldUseLintTimeout(commandArgs) {
189
+ const tokens = stripWrappersAndAssignments(commandArgs);
190
+ if (tokens.length === 0) {
191
+ return false;
192
+ }
193
+ const [first, ...rest] = tokens;
194
+ if (!first) {
195
+ return false;
196
+ }
197
+ if (first === 'pnpm') {
198
+ return shouldUseLintTimeoutViaPnpm(rest);
199
+ }
200
+ if (first === 'bun') {
201
+ return shouldUseLintTimeoutViaBun(rest);
202
+ }
203
+ return LINT_BINARIES.has(first.toLowerCase());
204
+ }
205
+ function shouldUseLintTimeoutViaPnpm(rest) {
206
+ if (rest.length === 0) {
207
+ return false;
208
+ }
209
+ const subcommand = rest[0];
210
+ if (!subcommand) {
211
+ return false;
212
+ }
213
+ if (subcommand === 'run') {
214
+ const script = rest[1];
215
+ return typeof script === 'string' && script.startsWith('lint');
216
+ }
217
+ if (subcommand === 'exec') {
218
+ const execTarget = rest[1];
219
+ if (execTarget && LINT_BINARIES.has(execTarget.toLowerCase())) {
220
+ return true;
221
+ }
222
+ return rest.slice(1).some((token) => LINT_BINARIES.has(token.toLowerCase()));
223
+ }
224
+ return LINT_BINARIES.has(subcommand.toLowerCase());
225
+ }
226
+ function shouldUseLintTimeoutViaBun(rest) {
227
+ if (rest.length === 0) {
228
+ return false;
229
+ }
230
+ const subcommand = rest[0];
231
+ if (!subcommand) {
232
+ return false;
233
+ }
234
+ if (subcommand === 'run') {
235
+ const script = rest[1];
236
+ return typeof script === 'string' && script.startsWith('lint');
237
+ }
238
+ if (subcommand === 'x' || subcommand === 'bunx') {
239
+ return rest.slice(1).some((token) => LINT_BINARIES.has(token.toLowerCase()));
240
+ }
241
+ return LINT_BINARIES.has(subcommand.toLowerCase());
242
+ }
243
+ // Detects when a user is running a single spec so we can keep the shorter timeout.
244
+ function isSingleTestInvocation(commandArgs) {
245
+ const tokens = stripWrappersAndAssignments(commandArgs);
246
+ if (tokens.length === 0) {
247
+ return false;
248
+ }
249
+ if (tokens.some((token) => SINGLE_TEST_FLAGS.has(token))) {
250
+ return true;
251
+ }
252
+ const [first, ...rest] = tokens;
253
+ if (!first) {
254
+ return false;
255
+ }
256
+ if (first === 'pnpm') {
257
+ return isSingleTestViaPnpm(rest);
258
+ }
259
+ if (first === 'bun') {
260
+ return isSingleTestViaBun(rest);
261
+ }
262
+ if (first === 'vitest') {
263
+ return rest.some((token) => SINGLE_TEST_FLAGS.has(token));
264
+ }
265
+ return SINGLE_TEST_SCRIPTS.has(first);
266
+ }
267
+ function isSingleTestViaPnpm(rest) {
268
+ if (rest.length === 0) {
269
+ return false;
270
+ }
271
+ const subcommand = rest[0];
272
+ if (!subcommand) {
273
+ return false;
274
+ }
275
+ if (subcommand === 'run') {
276
+ const script = rest[1];
277
+ return typeof script === 'string' && SINGLE_TEST_SCRIPTS.has(script);
278
+ }
279
+ if (subcommand === 'exec') {
280
+ return rest.slice(1).some((token) => SINGLE_TEST_FLAGS.has(token));
281
+ }
282
+ return SINGLE_TEST_SCRIPTS.has(subcommand);
283
+ }
284
+ function isSingleTestViaBun(rest) {
285
+ if (rest.length === 0) {
286
+ return false;
287
+ }
288
+ const subcommand = rest[0];
289
+ if (!subcommand) {
290
+ return false;
291
+ }
292
+ if (subcommand === 'run') {
293
+ const script = rest[1];
294
+ return typeof script === 'string' && SINGLE_TEST_SCRIPTS.has(script);
295
+ }
296
+ if (subcommand === 'test') {
297
+ return true;
298
+ }
299
+ if (subcommand === 'x' || subcommand === 'bunx') {
300
+ return rest.slice(1).some((token) => SINGLE_TEST_FLAGS.has(token));
301
+ }
302
+ return false;
303
+ }
304
+ // Normalizes potential file paths/flags to aid comparison across shells.
305
+ function normalizeForPathComparison(token) {
306
+ return token.replaceAll('\\', '/');
307
+ }
308
+ // Heuristically checks if a CLI token references an integration spec.
309
+ function tokenReferencesIntegrationTest(token) {
310
+ const normalized = normalizeForPathComparison(token);
311
+ if (normalized.includes('tests/integration/')) {
312
+ return true;
313
+ }
314
+ if (normalized.startsWith('--run=') || normalized.startsWith('--include=')) {
315
+ const value = normalized.split('=', 2)[1] ?? '';
316
+ return value.includes('tests/integration/');
317
+ }
318
+ return false;
319
+ }
320
+ // Scans the entire command for integration spec references.
321
+ function referencesIntegrationSpec(tokens) {
322
+ for (let index = 0; index < tokens.length; index += 1) {
323
+ const token = tokens[index];
324
+ if (!token) {
325
+ continue;
326
+ }
327
+ if (token === '--run' || token === '--include') {
328
+ const next = tokens[index + 1];
329
+ if (next && tokenReferencesIntegrationTest(next)) {
330
+ return true;
331
+ }
332
+ }
333
+ if (tokenReferencesIntegrationTest(token)) {
334
+ return true;
335
+ }
336
+ }
337
+ return false;
338
+ }
339
+ // Helper that matches a script token against a keyword allowlist.
340
+ function matchesScriptKeyword(script, keywords) {
341
+ const lowered = script.toLowerCase();
342
+ return keywords.some((keyword) => lowered === keyword || lowered.startsWith(`${keyword}:`));
343
+ }
344
+ // Removes wrapper binaries/env assignments so heuristics see the real command.
345
+ function stripWrappersAndAssignments(args) {
346
+ const tokens = [...args];
347
+ while (tokens.length > 0) {
348
+ const candidate = tokens[0];
349
+ if (!candidate) {
350
+ break;
351
+ }
352
+ if (!isEnvAssignment(candidate)) {
353
+ break;
354
+ }
355
+ tokens.shift();
356
+ }
357
+ while (tokens.length > 0) {
358
+ const wrapper = tokens[0];
359
+ if (!wrapper) {
360
+ break;
361
+ }
362
+ if (!WRAPPER_COMMANDS.has(wrapper)) {
363
+ break;
364
+ }
365
+ tokens.shift();
366
+ while (tokens.length > 0) {
367
+ const assignment = tokens[0];
368
+ if (!assignment) {
369
+ break;
370
+ }
371
+ if (!isEnvAssignment(assignment)) {
372
+ break;
373
+ }
374
+ tokens.shift();
375
+ }
376
+ }
377
+ return tokens;
378
+ }
379
+ // Checks whether a token is an inline environment variable assignment.
380
+ function isEnvAssignment(token) {
381
+ return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
382
+ }
383
+ // Detects `pnpm test:<suite>` style calls regardless of wrappers.
384
+ function isTestRunnerSuiteInvocation(tokens, suite) {
385
+ if (tokens.length === 0) {
386
+ return false;
387
+ }
388
+ const normalizedSuite = suite.toLowerCase();
389
+ for (let index = 0; index < tokens.length; index += 1) {
390
+ const token = tokens[index];
391
+ if (!token) {
392
+ continue;
393
+ }
394
+ const normalizedToken = token.replace(/^[./\\]+/, '');
395
+ if (normalizedToken === 'scripts/test-runner.ts' || normalizedToken.endsWith('/scripts/test-runner.ts')) {
396
+ const suiteToken = tokens[index + 1]?.toLowerCase();
397
+ if (suiteToken === normalizedSuite) {
398
+ return true;
399
+ }
400
+ }
401
+ }
402
+ return false;
403
+ }
404
+ // Grants the longest timeout to explicitly tagged long-running scripts.
405
+ function shouldUseLongTimeout(commandArgs) {
406
+ const tokens = stripWrappersAndAssignments(commandArgs);
407
+ if (tokens.length === 0) {
408
+ return false;
409
+ }
410
+ const first = tokens[0];
411
+ if (!first) {
412
+ return false;
413
+ }
414
+ const rest = tokens.slice(1);
415
+ const matches = (token) => matchesScriptKeyword(token, LONG_SCRIPT_KEYWORDS);
416
+ if (first === 'pnpm') {
417
+ if (rest.length === 0) {
418
+ return false;
419
+ }
420
+ const subcommand = rest[0];
421
+ if (!subcommand) {
422
+ return false;
423
+ }
424
+ if (subcommand === 'run') {
425
+ const script = rest[1];
426
+ if (script && matches(script)) {
427
+ return true;
428
+ }
429
+ }
430
+ else if (matches(subcommand)) {
431
+ return true;
432
+ }
433
+ for (const token of rest.slice(1)) {
434
+ if (matches(token)) {
435
+ return true;
436
+ }
437
+ }
438
+ return false;
439
+ }
440
+ if (matches(first)) {
441
+ return true;
442
+ }
443
+ for (const token of rest) {
444
+ if (matches(token)) {
445
+ return true;
446
+ }
447
+ }
448
+ return false;
449
+ }
450
+ // Kicks off the requested command with logging, timeouts, and monitoring.
451
+ async function runCommand(context) {
452
+ const { command, args, env } = buildExecutionParams(context.commandArgs, context.workspaceDir);
453
+ const commandLabel = formatDisplayCommand(context.commandArgs);
454
+ const startTime = Date.now();
455
+ const child = spawn(command, args, {
456
+ cwd: context.workspaceDir,
457
+ env,
458
+ stdio: ['inherit', 'pipe', 'pipe'],
459
+ });
460
+ if (isRunnerTmuxSession()) {
461
+ const childPidInfo = typeof child.pid === 'number' ? ` (pid ${child.pid})` : '';
462
+ console.error(`[runner] Watching ${commandLabel}${childPidInfo}. Wait for the closing sentinel before moving on.`);
463
+ }
464
+ const removeSignalHandlers = registerSignalForwarding(child);
465
+ if (child.stdout) {
466
+ child.stdout.on('data', (chunk) => {
467
+ process.stdout.write(chunk);
468
+ });
469
+ }
470
+ if (child.stderr) {
471
+ child.stderr.on('data', (chunk) => {
472
+ process.stderr.write(chunk);
473
+ });
474
+ }
475
+ let killTimer = null;
476
+ try {
477
+ const result = await new Promise((resolve, reject) => {
478
+ let timedOut = false;
479
+ const timeout = setTimeout(() => {
480
+ timedOut = true;
481
+ if (ENABLE_DEBUG_LOGS) {
482
+ console.error(`[runner] Command exceeded ${formatDuration(context.timeoutMs)}; sending SIGTERM.`);
483
+ }
484
+ if (!child.killed) {
485
+ child.kill('SIGTERM');
486
+ killTimer = setTimeout(() => {
487
+ if (!child.killed) {
488
+ child.kill('SIGKILL');
489
+ }
490
+ }, 5_000);
491
+ }
492
+ }, context.timeoutMs);
493
+ child.once('error', (error) => {
494
+ clearTimeout(timeout);
495
+ if (killTimer) {
496
+ clearTimeout(killTimer);
497
+ }
498
+ removeSignalHandlers();
499
+ reject(error);
500
+ });
501
+ child.once('exit', (code, signal) => {
502
+ clearTimeout(timeout);
503
+ if (killTimer) {
504
+ clearTimeout(killTimer);
505
+ }
506
+ removeSignalHandlers();
507
+ resolve({ exitCode: code ?? exitCodeFromSignal(signal), timedOut });
508
+ });
509
+ });
510
+ const { exitCode, timedOut } = result;
511
+ const elapsedMs = Date.now() - startTime;
512
+ if (timedOut) {
513
+ console.error(`[runner] Command terminated after ${formatDuration(context.timeoutMs)}. Re-run inside tmux for long-lived work.`);
514
+ console.error(formatCompletionSummary({ exitCode, elapsedMs, timedOut: true, commandLabel }));
515
+ process.exit(124);
516
+ }
517
+ if (elapsedMs >= LONG_RUN_REPORT_THRESHOLD_MS) {
518
+ console.error(`[runner] Completed in ${formatDuration(elapsedMs)}. For long-running tasks, prefer tmux directly.`);
519
+ }
520
+ console.error(formatCompletionSummary({ exitCode, elapsedMs, commandLabel }));
521
+ process.exit(exitCode);
522
+ }
523
+ catch (error) {
524
+ console.error('[runner] Failed to launch command:', error instanceof Error ? error.message : String(error));
525
+ process.exit(1);
526
+ return;
527
+ }
528
+ }
529
+ async function runCommandWithoutTimeout(context) {
530
+ const { command, args, env } = buildExecutionParams(context.commandArgs, context.workspaceDir);
531
+ const commandLabel = formatDisplayCommand(context.commandArgs);
532
+ const startTime = Date.now();
533
+ const child = spawn(command, args, {
534
+ cwd: context.workspaceDir,
535
+ env,
536
+ stdio: 'inherit',
537
+ });
538
+ const removeSignalHandlers = registerSignalForwarding(child);
539
+ try {
540
+ const exitCode = await new Promise((resolve, reject) => {
541
+ child.once('error', (error) => {
542
+ removeSignalHandlers();
543
+ reject(error);
544
+ });
545
+ child.once('exit', (code, signal) => {
546
+ removeSignalHandlers();
547
+ resolve(code ?? exitCodeFromSignal(signal));
548
+ });
549
+ });
550
+ const elapsedMs = Date.now() - startTime;
551
+ console.error(formatCompletionSummary({ exitCode, elapsedMs, commandLabel }));
552
+ process.exit(exitCode);
553
+ }
554
+ catch (error) {
555
+ console.error('[runner] Failed to launch command:', error instanceof Error ? error.message : String(error));
556
+ process.exit(1);
557
+ }
558
+ }
559
+ // Prepares the executable, args, and sanitized env for the child process.
560
+ function buildExecutionParams(commandArgs, workspaceDir) {
561
+ const env = { ...process.env };
562
+ injectWorkspaceBinDirs(env, workspaceDir);
563
+ const args = [];
564
+ let commandStarted = false;
565
+ for (const token of commandArgs) {
566
+ if (!commandStarted && isEnvAssignment(token)) {
567
+ const [key, ...rest] = token.split('=');
568
+ if (key) {
569
+ env[key] = rest.join('=');
570
+ }
571
+ continue;
572
+ }
573
+ commandStarted = true;
574
+ args.push(token);
575
+ }
576
+ if (args.length === 0 || !args[0]) {
577
+ printUsage('Missing command to execute.');
578
+ process.exit(1);
579
+ }
580
+ const [command, ...restArgs] = args;
581
+ return { command, args: restArgs, env };
582
+ }
583
+ function injectWorkspaceBinDirs(env, workspaceDir) {
584
+ if (ENABLE_DEBUG_LOGS) {
585
+ console.error(`[runner] Checking workspace bin dirs under ${workspaceDir}`);
586
+ }
587
+ const binCandidates = [
588
+ join(workspaceDir, 'node_modules', '.bin'),
589
+ join(workspaceDir, 'bin'),
590
+ ];
591
+ const existingPath = env.PATH ?? process.env.PATH ?? '';
592
+ const existingSegments = existingPath
593
+ .split(':')
594
+ .map((segment) => segment.trim())
595
+ .filter((segment) => segment.length > 0);
596
+ const additions = [];
597
+ for (const dir of binCandidates) {
598
+ if (!dir || !existsSync(dir)) {
599
+ continue;
600
+ }
601
+ if (existingSegments.includes(dir) || additions.includes(dir)) {
602
+ continue;
603
+ }
604
+ additions.push(dir);
605
+ }
606
+ if (additions.length === 0) {
607
+ return;
608
+ }
609
+ if (ENABLE_DEBUG_LOGS) {
610
+ console.error(`[runner] Prepending workspace PATH entries: ${additions.join(', ')}`);
611
+ }
612
+ const merged = [...additions, ...existingSegments];
613
+ env.PATH = merged.join(':');
614
+ }
615
+ // Forwards termination signals to the child and returns an unregister hook.
616
+ function registerSignalForwarding(child) {
617
+ const signals = ['SIGINT', 'SIGTERM'];
618
+ const handlers = new Map();
619
+ for (const signal of signals) {
620
+ const handler = () => {
621
+ if (!child.killed) {
622
+ child.kill(signal);
623
+ }
624
+ };
625
+ handlers.set(signal, handler);
626
+ process.on(signal, handler);
627
+ }
628
+ return () => {
629
+ for (const [signal, handler] of handlers) {
630
+ process.off(signal, handler);
631
+ }
632
+ };
633
+ }
634
+ // Maps a terminating signal to the exit code conventions bash expects.
635
+ function exitCodeFromSignal(signal) {
636
+ if (!signal) {
637
+ return 0;
638
+ }
639
+ const code = osConstants.signals[signal];
640
+ if (typeof code === 'number') {
641
+ return 128 + code;
642
+ }
643
+ return 1;
644
+ }
645
+ // Gives policy interceptors a chance to fully handle a command before exec.
646
+ async function resolveCommandInterception(context) {
647
+ const interceptors = [
648
+ maybeHandleTmuxInvocation,
649
+ maybeHandleFindInvocation,
650
+ maybeHandleRmInvocation,
651
+ maybeHandleSleepInvocation,
652
+ ];
653
+ for (const interceptor of interceptors) {
654
+ if (await interceptor(context)) {
655
+ return { handled: true };
656
+ }
657
+ }
658
+ const gitContext = analyzeGitExecution(context.commandArgs, context.workspaceDir);
659
+ if (await maybeHandleGitRm(gitContext)) {
660
+ return { handled: true };
661
+ }
662
+ return { handled: false, gitContext };
663
+ }
664
+ // Runs the shared git policy analyzers before dispatching the command.
665
+ function enforceGitPolicies(gitContext) {
666
+ const evaluation = evaluateGitPolicies(gitContext);
667
+ const hasConsentOverride = process.env.RUNNER_THE_USER_GAVE_ME_CONSENT === '1';
668
+ if (gitContext.subcommand === 'rebase' && !hasConsentOverride) {
669
+ console.error('git rebase requires the user to explicitly type "rebase" in chat. Once they do, rerun with RUNNER_THE_USER_GAVE_ME_CONSENT=1 in the same command (e.g. RUNNER_THE_USER_GAVE_ME_CONSENT=1 ./runner git rebase --continue).');
670
+ process.exit(1);
671
+ }
672
+ if (evaluation.requiresCommitHelper) {
673
+ console.error('Direct git add/commit is disabled. Use ./scripts/committer "chore(runner): describe change" "scripts/runner.ts" instead—see AGENTS.md and ./scripts/committer for details. The helper auto-stashes unrelated files before committing.');
674
+ process.exit(1);
675
+ }
676
+ if (evaluation.requiresExplicitConsent || evaluation.isDestructive) {
677
+ if (hasConsentOverride) {
678
+ if (ENABLE_DEBUG_LOGS) {
679
+ const reason = evaluation.isDestructive ? 'destructive git command' : 'guarded git command';
680
+ console.error(`[runner] Proceeding with ${reason} because RUNNER_THE_USER_GAVE_ME_CONSENT=1.`);
681
+ }
682
+ }
683
+ else {
684
+ if (evaluation.isDestructive) {
685
+ console.error(`git ${gitContext.subcommand ?? ''} can overwrite or discard work. Confirm with the user first, then re-run with RUNNER_THE_USER_GAVE_ME_CONSENT=1 if they approve.`);
686
+ }
687
+ else {
688
+ console.error(`Using git ${gitContext.subcommand ?? ''} requires consent. Set RUNNER_THE_USER_GAVE_ME_CONSENT=1 after verifying with the user, or ask them explicitly before proceeding.`);
689
+ }
690
+ process.exit(1);
691
+ }
692
+ }
693
+ }
694
+ // Handles guarded `find` invocations that may delete files outright.
695
+ async function maybeHandleFindInvocation(context) {
696
+ const findInvocation = extractFindInvocation(context.commandArgs);
697
+ if (!findInvocation) {
698
+ return false;
699
+ }
700
+ const findPlan = await buildFindDeletePlan(findInvocation.argv, context.workspaceDir);
701
+ if (!findPlan) {
702
+ return false;
703
+ }
704
+ const moveResult = await movePathsToTrash(findPlan.paths, context.workspaceDir, { allowMissing: false });
705
+ if (moveResult.missing.length > 0) {
706
+ for (const path of moveResult.missing) {
707
+ console.error(`find: ${path}: No such file or directory`);
708
+ }
709
+ process.exit(1);
710
+ }
711
+ if (moveResult.errors.length > 0) {
712
+ for (const error of moveResult.errors) {
713
+ console.error(error);
714
+ }
715
+ process.exit(1);
716
+ }
717
+ process.exit(0);
718
+ return true;
719
+ }
720
+ // Intercepts plain `rm` commands to route them through trash safeguards.
721
+ async function maybeHandleRmInvocation(context) {
722
+ const rmInvocation = extractRmInvocation(context.commandArgs);
723
+ if (!rmInvocation) {
724
+ return false;
725
+ }
726
+ const rmPlan = parseRmArguments(rmInvocation.argv);
727
+ if (!rmPlan?.shouldIntercept) {
728
+ return false;
729
+ }
730
+ try {
731
+ const moveResult = await movePathsToTrash(rmPlan.targets, context.workspaceDir, { allowMissing: rmPlan.force });
732
+ reportMissingForRm(moveResult.missing, rmPlan.force);
733
+ if (moveResult.errors.length > 0) {
734
+ for (const error of moveResult.errors) {
735
+ console.error(error);
736
+ }
737
+ process.exit(1);
738
+ }
739
+ process.exit(0);
740
+ }
741
+ catch (error) {
742
+ console.error(formatTrashError(error));
743
+ process.exit(1);
744
+ }
745
+ return true;
746
+ }
747
+ // Applies git-specific rm protections before the command executes.
748
+ async function maybeHandleGitRm(gitContext) {
749
+ if (gitContext.command?.name !== 'rm' || !gitContext.invocation) {
750
+ return false;
751
+ }
752
+ const gitRmPlan = parseGitRmArguments(gitContext.invocation.argv, gitContext.command);
753
+ if (!gitRmPlan?.shouldIntercept) {
754
+ return false;
755
+ }
756
+ try {
757
+ const moveResult = await movePathsToTrash(gitRmPlan.paths, gitContext.workDir, {
758
+ allowMissing: gitRmPlan.allowMissing,
759
+ });
760
+ if (!gitRmPlan.allowMissing && moveResult.missing.length > 0) {
761
+ for (const path of moveResult.missing) {
762
+ console.error(`git rm: ${path}: No such file or directory`);
763
+ }
764
+ process.exit(1);
765
+ }
766
+ if (moveResult.errors.length > 0) {
767
+ for (const error of moveResult.errors) {
768
+ console.error(error);
769
+ }
770
+ process.exit(1);
771
+ }
772
+ await stageGitRm(gitContext.workDir, gitRmPlan);
773
+ process.exit(0);
774
+ }
775
+ catch (error) {
776
+ console.error(formatTrashError(error));
777
+ process.exit(1);
778
+ }
779
+ return true;
780
+ }
781
+ // Blocks `sleep` calls longer than the AGENTS.md ceiling so scripts cannot stall the runner.
782
+ async function maybeHandleSleepInvocation(context) {
783
+ const tokens = stripWrappersAndAssignments(context.commandArgs);
784
+ if (tokens.length === 0) {
785
+ return false;
786
+ }
787
+ const [first, ...rest] = tokens;
788
+ if (!first || !isSleepBinary(first) || rest.length === 0) {
789
+ return false;
790
+ }
791
+ const commandIndex = context.commandArgs.length - tokens.length;
792
+ if (commandIndex < 0) {
793
+ return false;
794
+ }
795
+ const adjustedArgs = [...context.commandArgs];
796
+ const adjustments = [];
797
+ for (let offset = 0; offset < rest.length; offset += 1) {
798
+ const token = rest[offset];
799
+ const durationSeconds = parseSleepDurationSeconds(token);
800
+ if (durationSeconds == null || durationSeconds <= MAX_SLEEP_SECONDS) {
801
+ continue;
802
+ }
803
+ adjustments.push(`${token}→${formatSleepDuration(MAX_SLEEP_SECONDS)}`);
804
+ adjustedArgs[commandIndex + 1 + offset] = formatSleepArgument(MAX_SLEEP_SECONDS);
805
+ }
806
+ if (adjustments.length === 0) {
807
+ return false;
808
+ }
809
+ console.error(`[runner] sleep arguments exceed ${MAX_SLEEP_SECONDS}s; clamping (${adjustments.join(', ')}).`);
810
+ context.commandArgs = adjustedArgs;
811
+ return false;
812
+ }
813
+ async function maybeHandleTmuxInvocation(context) {
814
+ const tokens = stripWrappersAndAssignments(context.commandArgs);
815
+ if (tokens.length === 0) {
816
+ return false;
817
+ }
818
+ const candidate = tokens[0];
819
+ if (!candidate) {
820
+ return false;
821
+ }
822
+ if (basename(candidate) !== 'tmux') {
823
+ return false;
824
+ }
825
+ console.error('[runner] Detected tmux invocation; executing command without runner timeout guardrails.');
826
+ await runCommandWithoutTimeout(context);
827
+ return true;
828
+ }
829
+ function parseSleepDurationSeconds(token) {
830
+ const match = /^(\d+(?:\.\d+)?)([smhdSMHD]?)$/.exec(token);
831
+ if (!match) {
832
+ return null;
833
+ }
834
+ const value = Number(match[1]);
835
+ if (!Number.isFinite(value)) {
836
+ return null;
837
+ }
838
+ const unit = match[2]?.toLowerCase() ?? '';
839
+ const multiplier = unit === 'm' ? 60 : unit === 'h' ? 60 * 60 : unit === 'd' ? 60 * 60 * 24 : 1;
840
+ return value * multiplier;
841
+ }
842
+ function formatSleepArgument(seconds) {
843
+ return Number.isInteger(seconds) ? `${seconds}` : seconds.toString();
844
+ }
845
+ function formatSleepDuration(seconds) {
846
+ if (Number.isInteger(seconds)) {
847
+ return `${seconds}s`;
848
+ }
849
+ return `${seconds.toFixed(2)}s`;
850
+ }
851
+ function isSleepBinary(token) {
852
+ return token === 'sleep' || token.endsWith('/sleep');
853
+ }
854
+ // Detects `git find` invocations that need policy enforcement.
855
+ function extractFindInvocation(commandArgs) {
856
+ for (const [index, token] of commandArgs.entries()) {
857
+ if (token === 'find' || token.endsWith('/find')) {
858
+ return { index, argv: commandArgs.slice(index) };
859
+ }
860
+ }
861
+ return null;
862
+ }
863
+ // Detects `git rm` variants so we can intercept destructive operations.
864
+ function extractRmInvocation(commandArgs) {
865
+ if (commandArgs.length === 0) {
866
+ return null;
867
+ }
868
+ const wrappers = new Set([
869
+ 'sudo',
870
+ '/usr/bin/sudo',
871
+ 'env',
872
+ '/usr/bin/env',
873
+ 'command',
874
+ '/bin/command',
875
+ 'nohup',
876
+ '/usr/bin/nohup',
877
+ ]);
878
+ let index = 0;
879
+ while (index < commandArgs.length) {
880
+ const token = commandArgs[index];
881
+ if (!token) {
882
+ break;
883
+ }
884
+ if (token.includes('=') && !token.startsWith('-')) {
885
+ index += 1;
886
+ continue;
887
+ }
888
+ if (wrappers.has(token)) {
889
+ index += 1;
890
+ continue;
891
+ }
892
+ break;
893
+ }
894
+ const commandToken = commandArgs[index];
895
+ if (!commandToken) {
896
+ return null;
897
+ }
898
+ const isRmCommand = commandToken === 'rm' ||
899
+ commandToken.endsWith('/rm') ||
900
+ commandToken === 'rm.exe' ||
901
+ commandToken.endsWith('\\rm.exe');
902
+ if (!isRmCommand) {
903
+ return null;
904
+ }
905
+ return { index, argv: commandArgs.slice(index) };
906
+ }
907
+ // Expands guarded find expressions into an explicit delete plan for review.
908
+ async function buildFindDeletePlan(findArgs, workspaceDir) {
909
+ if (!findArgs.some((token) => token === '-delete')) {
910
+ return null;
911
+ }
912
+ if (findArgs.some((token) => token === '-exec' || token === '-execdir' || token === '-ok' || token === '-okdir')) {
913
+ console.error('Runner cannot safely translate find invocations that combine -delete with -exec/-ok. Run the command manually after reviewing the paths.');
914
+ process.exit(1);
915
+ }
916
+ const printableArgs = [];
917
+ for (const token of findArgs) {
918
+ if (token === '-delete') {
919
+ continue;
920
+ }
921
+ printableArgs.push(token);
922
+ }
923
+ printableArgs.push('-print0');
924
+ const proc = Bun.spawn(printableArgs, {
925
+ cwd: workspaceDir,
926
+ stdout: 'pipe',
927
+ stderr: 'pipe',
928
+ });
929
+ const [exitCode, stdoutBuf, stderrBuf] = await Promise.all([
930
+ proc.exited,
931
+ readProcessStream(proc.stdout),
932
+ readProcessStream(proc.stderr),
933
+ ]);
934
+ if (exitCode !== 0) {
935
+ const stderrText = stderrBuf.trim();
936
+ const stdoutText = stdoutBuf.trim();
937
+ if (stderrText.length > 0) {
938
+ console.error(stderrText);
939
+ }
940
+ else if (stdoutText.length > 0) {
941
+ console.error(stdoutText);
942
+ }
943
+ process.exit(exitCode);
944
+ }
945
+ const matches = stdoutBuf.split('\0').filter((entry) => entry.length > 0);
946
+ if (matches.length === 0) {
947
+ return { paths: [] };
948
+ }
949
+ const uniquePaths = new Map();
950
+ const workspaceCanonical = normalize(workspaceDir);
951
+ for (const match of matches) {
952
+ const absolute = isAbsolute(match) ? match : resolve(workspaceDir, match);
953
+ const canonical = normalize(absolute);
954
+ if (canonical === workspaceCanonical) {
955
+ console.error('Refusing to trash the current workspace via find -delete. Narrow your find predicate.');
956
+ process.exit(1);
957
+ }
958
+ if (!uniquePaths.has(canonical)) {
959
+ uniquePaths.set(canonical, match);
960
+ }
961
+ }
962
+ return { paths: Array.from(uniquePaths.values()) };
963
+ }
964
+ // Parses rm flags/targets to decide whether the runner should intervene.
965
+ function parseRmArguments(argv) {
966
+ if (argv.length <= 1) {
967
+ return null;
968
+ }
969
+ const targets = [];
970
+ let force = false;
971
+ let treatAsTarget = false;
972
+ let index = 1;
973
+ while (index < argv.length) {
974
+ const token = argv[index];
975
+ if (token === undefined) {
976
+ break;
977
+ }
978
+ if (!treatAsTarget && token === '--') {
979
+ treatAsTarget = true;
980
+ index += 1;
981
+ continue;
982
+ }
983
+ if (!treatAsTarget && token.startsWith('-') && token.length > 1) {
984
+ if (token.includes('f')) {
985
+ force = true;
986
+ }
987
+ if (token.includes('i') || token === '--interactive') {
988
+ return null;
989
+ }
990
+ if (token === '--help' || token === '--version') {
991
+ return null;
992
+ }
993
+ index += 1;
994
+ continue;
995
+ }
996
+ targets.push(token);
997
+ index += 1;
998
+ }
999
+ const firstTarget = targets[0];
1000
+ if (firstTarget === undefined) {
1001
+ return null;
1002
+ }
1003
+ return { targets, force, shouldIntercept: true };
1004
+ }
1005
+ // Generates a safe plan for git rm invocations, honoring guarded paths.
1006
+ function parseGitRmArguments(argv, command) {
1007
+ const stagingOptions = [];
1008
+ const paths = [];
1009
+ const optionsExpectingValue = new Set(['--pathspec-from-file']);
1010
+ let allowMissing = false;
1011
+ let treatAsPath = false;
1012
+ let index = command.index + 1;
1013
+ while (index < argv.length) {
1014
+ const token = argv[index];
1015
+ if (token === undefined) {
1016
+ break;
1017
+ }
1018
+ if (!treatAsPath && token === '--') {
1019
+ treatAsPath = true;
1020
+ index += 1;
1021
+ continue;
1022
+ }
1023
+ if (!treatAsPath && token.startsWith('-') && token.length > 1) {
1024
+ if (token === '--cached' || token === '--dry-run' || token === '-n') {
1025
+ return null;
1026
+ }
1027
+ if (token === '--ignore-unmatch' || token === '--force' || token === '-f') {
1028
+ allowMissing = true;
1029
+ stagingOptions.push(token);
1030
+ index += 1;
1031
+ continue;
1032
+ }
1033
+ if (optionsExpectingValue.has(token)) {
1034
+ const value = argv[index + 1];
1035
+ if (value) {
1036
+ stagingOptions.push(token, value);
1037
+ index += 2;
1038
+ }
1039
+ else {
1040
+ index += 1;
1041
+ }
1042
+ continue;
1043
+ }
1044
+ if (!token.startsWith('--')) {
1045
+ const flags = token.slice(1).split('');
1046
+ const retainedFlags = [];
1047
+ for (const flag of flags) {
1048
+ if (flag === 'n') {
1049
+ return null;
1050
+ }
1051
+ if (flag === 'f') {
1052
+ allowMissing = true;
1053
+ continue;
1054
+ }
1055
+ retainedFlags.push(flag);
1056
+ }
1057
+ if (retainedFlags.length > 0) {
1058
+ stagingOptions.push(`-${retainedFlags.join('')}`);
1059
+ }
1060
+ index += 1;
1061
+ continue;
1062
+ }
1063
+ stagingOptions.push(token);
1064
+ index += 1;
1065
+ continue;
1066
+ }
1067
+ if (token.length > 0) {
1068
+ paths.push(token);
1069
+ }
1070
+ index += 1;
1071
+ }
1072
+ if (paths.length === 0) {
1073
+ return null;
1074
+ }
1075
+ return {
1076
+ paths,
1077
+ stagingOptions,
1078
+ allowMissing,
1079
+ shouldIntercept: true,
1080
+ };
1081
+ }
1082
+ // Emits actionable messaging when git rm targets are already gone.
1083
+ function reportMissingForRm(missing, forced) {
1084
+ if (missing.length === 0 || forced) {
1085
+ return;
1086
+ }
1087
+ for (const path of missing) {
1088
+ console.error(`rm: ${path}: No such file or directory`);
1089
+ }
1090
+ process.exit(1);
1091
+ }
1092
+ // Attempts to move the provided paths into trash instead of deleting in place.
1093
+ async function movePathsToTrash(paths, baseDir, options) {
1094
+ const missing = [];
1095
+ const existing = [];
1096
+ for (const rawPath of paths) {
1097
+ const absolute = resolvePath(baseDir, rawPath);
1098
+ if (!existsSync(absolute)) {
1099
+ if (!options.allowMissing) {
1100
+ missing.push(rawPath);
1101
+ }
1102
+ continue;
1103
+ }
1104
+ existing.push({ raw: rawPath, absolute });
1105
+ }
1106
+ if (existing.length === 0) {
1107
+ return { missing, errors: [] };
1108
+ }
1109
+ const trashCliCommand = await findTrashCliCommand();
1110
+ if (trashCliCommand) {
1111
+ try {
1112
+ const cliArgs = [trashCliCommand, ...existing.map((item) => item.absolute)];
1113
+ const proc = Bun.spawn(cliArgs, {
1114
+ stdout: 'ignore',
1115
+ stderr: 'pipe',
1116
+ });
1117
+ const [exitCode, stderrText] = await Promise.all([proc.exited, readProcessStream(proc.stderr)]);
1118
+ if (exitCode === 0) {
1119
+ return { missing, errors: [] };
1120
+ }
1121
+ if (ENABLE_DEBUG_LOGS && stderrText.trim().length > 0) {
1122
+ console.error(`[runner] trash-cli error (${trashCliCommand}): ${stderrText.trim()}`);
1123
+ }
1124
+ }
1125
+ catch (error) {
1126
+ if (ENABLE_DEBUG_LOGS) {
1127
+ console.error(`[runner] trash-cli invocation failed: ${formatTrashError(error)}`);
1128
+ }
1129
+ }
1130
+ }
1131
+ const trashDir = getTrashDirectory();
1132
+ if (!trashDir) {
1133
+ return {
1134
+ missing,
1135
+ errors: ['Unable to locate macOS Trash directory (HOME/.Trash).'],
1136
+ };
1137
+ }
1138
+ const errors = [];
1139
+ for (const item of existing) {
1140
+ try {
1141
+ const target = buildTrashTarget(trashDir, item.absolute);
1142
+ try {
1143
+ renameSync(item.absolute, target);
1144
+ }
1145
+ catch (error) {
1146
+ if (isCrossDeviceError(error)) {
1147
+ cpSync(item.absolute, target, { recursive: true });
1148
+ rmSync(item.absolute, { recursive: true, force: true });
1149
+ }
1150
+ else {
1151
+ throw error;
1152
+ }
1153
+ }
1154
+ }
1155
+ catch (error) {
1156
+ errors.push(`Failed to move ${item.raw} to Trash: ${formatTrashError(error)}`);
1157
+ }
1158
+ }
1159
+ return { missing, errors };
1160
+ }
1161
+ // Resolves a potentially relative path against the workspace root.
1162
+ function resolvePath(baseDir, input) {
1163
+ if (input.startsWith('/')) {
1164
+ return input;
1165
+ }
1166
+ return resolve(baseDir, input);
1167
+ }
1168
+ // Returns the trash CLI directory if available so deletes can be safe.
1169
+ function getTrashDirectory() {
1170
+ const home = process.env.HOME;
1171
+ if (!home) {
1172
+ return null;
1173
+ }
1174
+ const trash = join(home, '.Trash');
1175
+ if (!existsSync(trash)) {
1176
+ return null;
1177
+ }
1178
+ return trash;
1179
+ }
1180
+ // Builds the destination path inside the trash directory for a file.
1181
+ function buildTrashTarget(trashDir, absolutePath) {
1182
+ const baseName = basename(absolutePath);
1183
+ const timestamp = Date.now();
1184
+ let attempt = 0;
1185
+ let candidate = join(trashDir, baseName);
1186
+ while (existsSync(candidate)) {
1187
+ candidate = join(trashDir, `${baseName}-${timestamp}${attempt > 0 ? `-${attempt}` : ''}`);
1188
+ attempt += 1;
1189
+ }
1190
+ return candidate;
1191
+ }
1192
+ // Determines whether a rename failed because the devices differ.
1193
+ function isCrossDeviceError(error) {
1194
+ return error instanceof Error && 'code' in error && error.code === 'EXDEV';
1195
+ }
1196
+ // Normalizes trash/rename errors into a readable string.
1197
+ function formatTrashError(error) {
1198
+ if (error instanceof Error) {
1199
+ return error.message;
1200
+ }
1201
+ return String(error);
1202
+ }
1203
+ // Replays a git rm plan via spawn so we can surface errors consistently.
1204
+ async function stageGitRm(workDir, plan) {
1205
+ if (plan.paths.length === 0) {
1206
+ return;
1207
+ }
1208
+ const args = ['git', 'rm', '--cached', '--quiet', ...plan.stagingOptions, '--', ...plan.paths];
1209
+ const proc = Bun.spawn(args, {
1210
+ cwd: workDir,
1211
+ stdout: 'inherit',
1212
+ stderr: 'inherit',
1213
+ });
1214
+ const exitCode = await proc.exited;
1215
+ if (exitCode !== 0) {
1216
+ throw new Error(`git rm --cached exited with status ${exitCode}.`);
1217
+ }
1218
+ }
1219
+ // Locates a usable trash CLI binary, caching the lookup per runner process.
1220
+ async function findTrashCliCommand() {
1221
+ if (cachedTrashCliCommand !== undefined) {
1222
+ return cachedTrashCliCommand;
1223
+ }
1224
+ const candidateNames = ['trash-put', 'trash'];
1225
+ const searchDirs = new Set();
1226
+ if (process.env.PATH) {
1227
+ for (const segment of process.env.PATH.split(':')) {
1228
+ if (segment && segment.length > 0) {
1229
+ searchDirs.add(segment);
1230
+ }
1231
+ }
1232
+ }
1233
+ const homebrewPrefix = process.env.HOMEBREW_PREFIX ?? '/opt/homebrew';
1234
+ searchDirs.add(join(homebrewPrefix, 'opt', 'trash', 'bin'));
1235
+ searchDirs.add('/usr/local/opt/trash/bin');
1236
+ const candidatePaths = new Set();
1237
+ for (const name of candidateNames) {
1238
+ candidatePaths.add(name);
1239
+ for (const dir of searchDirs) {
1240
+ candidatePaths.add(join(dir, name));
1241
+ }
1242
+ }
1243
+ for (const candidate of candidatePaths) {
1244
+ try {
1245
+ const proc = Bun.spawn([candidate, '--help'], {
1246
+ stdout: 'ignore',
1247
+ stderr: 'ignore',
1248
+ });
1249
+ const exitCode = await proc.exited;
1250
+ if (exitCode === 0 || exitCode === 1) {
1251
+ cachedTrashCliCommand = candidate;
1252
+ return candidate;
1253
+ }
1254
+ }
1255
+ catch (error) {
1256
+ if (ENABLE_DEBUG_LOGS) {
1257
+ console.error(`[runner] trash-cli probe failed for ${candidate}: ${formatTrashError(error)}`);
1258
+ }
1259
+ }
1260
+ }
1261
+ cachedTrashCliCommand = null;
1262
+ return null;
1263
+ }
1264
+ // Consumes a child process stream to completion for logging/error output.
1265
+ async function readProcessStream(stream) {
1266
+ if (!stream) {
1267
+ return '';
1268
+ }
1269
+ try {
1270
+ const candidate = stream;
1271
+ if (candidate.text) {
1272
+ return (await candidate.text()) ?? '';
1273
+ }
1274
+ }
1275
+ catch {
1276
+ // ignore
1277
+ }
1278
+ try {
1279
+ if (stream instanceof ReadableStream) {
1280
+ return await new Response(stream).text();
1281
+ }
1282
+ if (typeof stream === 'object' && stream !== null) {
1283
+ return await new Response(stream).text();
1284
+ }
1285
+ }
1286
+ catch {
1287
+ // ignore errors and return empty string
1288
+ }
1289
+ return '';
1290
+ }
1291
+ // Shows CLI usage plus optional error messaging.
1292
+ function printUsage(message) {
1293
+ if (message) {
1294
+ console.error(`[runner] ${message}`);
1295
+ }
1296
+ console.error('Usage: runner [--] <command...>');
1297
+ console.error('');
1298
+ console.error(`Defaults: ${formatDuration(DEFAULT_TIMEOUT_MS)} timeout for most commands, ${formatDuration(EXTENDED_TIMEOUT_MS)} when lint/test suites are detected.`);
1299
+ }
1300
+ // Pretty-prints a millisecond duration for logs.
1301
+ function formatDuration(durationMs) {
1302
+ if (durationMs < 1000) {
1303
+ return `${durationMs}ms`;
1304
+ }
1305
+ const seconds = durationMs / 1000;
1306
+ if (seconds < 60) {
1307
+ return `${seconds.toFixed(1)}s`;
1308
+ }
1309
+ const minutes = Math.floor(seconds / 60);
1310
+ const remainingSeconds = Math.round(seconds % 60);
1311
+ if (minutes < 60) {
1312
+ if (remainingSeconds === 0) {
1313
+ return `${minutes}m`;
1314
+ }
1315
+ return `${minutes}m ${remainingSeconds}s`;
1316
+ }
1317
+ const hours = Math.floor(minutes / 60);
1318
+ const remainingMinutes = minutes % 60;
1319
+ if (remainingMinutes === 0) {
1320
+ return `${hours}h`;
1321
+ }
1322
+ return `${hours}h ${remainingMinutes}m`;
1323
+ }
1324
+ function resolveSummaryStyle(rawValue) {
1325
+ if (!rawValue) {
1326
+ return 'compact';
1327
+ }
1328
+ const normalized = rawValue.trim().toLowerCase();
1329
+ switch (normalized) {
1330
+ case 'minimal':
1331
+ return 'minimal';
1332
+ case 'verbose':
1333
+ return 'verbose';
1334
+ case 'short':
1335
+ return 'compact';
1336
+ default:
1337
+ return 'compact';
1338
+ }
1339
+ }
1340
+ function formatCompletionSummary(options) {
1341
+ const { exitCode, elapsedMs, timedOut, commandLabel } = options;
1342
+ const durationText = typeof elapsedMs === 'number' ? formatDuration(elapsedMs) : null;
1343
+ // biome-ignore lint/nursery/noUnnecessaryConditions: switch makes the formatter easier to scan.
1344
+ switch (SUMMARY_STYLE) {
1345
+ case 'minimal': {
1346
+ const parts = [`${exitCode}`];
1347
+ if (durationText) {
1348
+ parts.push(durationText);
1349
+ }
1350
+ if (timedOut) {
1351
+ parts.push('timeout');
1352
+ }
1353
+ return `[runner] ${parts.join(' · ')}`;
1354
+ }
1355
+ case 'verbose': {
1356
+ const elapsedPart = durationText ? `, elapsed ${durationText}` : '';
1357
+ const timeoutPart = timedOut ? '; timed out' : '';
1358
+ return `[runner] Finished ${commandLabel} (exit ${exitCode}${elapsedPart}${timeoutPart}).`;
1359
+ }
1360
+ default: {
1361
+ const elapsedPart = durationText ? ` in ${durationText}` : '';
1362
+ const timeoutPart = timedOut ? ' (timeout)' : '';
1363
+ return `[runner] exit ${exitCode}${elapsedPart}${timeoutPart}`;
1364
+ }
1365
+ }
1366
+ }
1367
+ // Joins the command args in a shell-friendly way for log display.
1368
+ function formatDisplayCommand(commandArgs) {
1369
+ return commandArgs.map((token) => (token.includes(' ') ? `"${token}"` : token)).join(' ');
1370
+ }
1371
+ // Tells whether the runner is already executing inside the tmux guard.
1372
+ function isRunnerTmuxSession() {
1373
+ const value = process.env.RUNNER_TMUX;
1374
+ if (value) {
1375
+ return value !== '0' && value.toLowerCase() !== 'false';
1376
+ }
1377
+ return Boolean(process.env.TMUX);
1378
+ }