steroids-cli 0.8.24 → 0.8.26

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 (77) hide show
  1. package/dist/commands/ai.js +7 -6
  2. package/dist/commands/ai.js.map +1 -1
  3. package/dist/commands/completion.d.ts.map +1 -1
  4. package/dist/commands/completion.js +1 -0
  5. package/dist/commands/completion.js.map +1 -1
  6. package/dist/commands/config.js +10 -7
  7. package/dist/commands/config.js.map +1 -1
  8. package/dist/commands/llm.d.ts.map +1 -1
  9. package/dist/commands/llm.js +5 -0
  10. package/dist/commands/llm.js.map +1 -1
  11. package/dist/commands/merge.d.ts +3 -0
  12. package/dist/commands/merge.d.ts.map +1 -0
  13. package/dist/commands/merge.js +234 -0
  14. package/dist/commands/merge.js.map +1 -0
  15. package/dist/commands/runners-list.d.ts +3 -0
  16. package/dist/commands/runners-list.d.ts.map +1 -0
  17. package/dist/commands/runners-list.js +287 -0
  18. package/dist/commands/runners-list.js.map +1 -0
  19. package/dist/commands/runners-logs.d.ts +2 -0
  20. package/dist/commands/runners-logs.d.ts.map +1 -0
  21. package/dist/commands/runners-logs.js +207 -0
  22. package/dist/commands/runners-logs.js.map +1 -0
  23. package/dist/commands/runners-management.d.ts +3 -0
  24. package/dist/commands/runners-management.d.ts.map +1 -0
  25. package/dist/commands/runners-management.js +205 -0
  26. package/dist/commands/runners-management.js.map +1 -0
  27. package/dist/commands/runners-parallel.d.ts +26 -0
  28. package/dist/commands/runners-parallel.d.ts.map +1 -0
  29. package/dist/commands/runners-parallel.js +229 -0
  30. package/dist/commands/runners-parallel.js.map +1 -0
  31. package/dist/commands/runners-wakeup.d.ts +4 -0
  32. package/dist/commands/runners-wakeup.d.ts.map +1 -0
  33. package/dist/commands/runners-wakeup.js +157 -0
  34. package/dist/commands/runners-wakeup.js.map +1 -0
  35. package/dist/commands/runners.d.ts.map +1 -1
  36. package/dist/commands/runners.js +115 -786
  37. package/dist/commands/runners.js.map +1 -1
  38. package/dist/commands/workspaces.d.ts +3 -0
  39. package/dist/commands/workspaces.d.ts.map +1 -0
  40. package/dist/commands/workspaces.js +409 -0
  41. package/dist/commands/workspaces.js.map +1 -0
  42. package/dist/config/ai-setup.d.ts +1 -1
  43. package/dist/config/ai-setup.d.ts.map +1 -1
  44. package/dist/config/ai-setup.js +1 -0
  45. package/dist/config/ai-setup.js.map +1 -1
  46. package/dist/config/loader.d.ts +10 -3
  47. package/dist/config/loader.d.ts.map +1 -1
  48. package/dist/config/loader.js +7 -0
  49. package/dist/config/loader.js.map +1 -1
  50. package/dist/config/schema.d.ts.map +1 -1
  51. package/dist/config/schema.js +32 -3
  52. package/dist/config/schema.js.map +1 -1
  53. package/dist/index.js +10 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/providers/api-models.d.ts +11 -6
  56. package/dist/providers/api-models.d.ts.map +1 -1
  57. package/dist/providers/api-models.js +80 -0
  58. package/dist/providers/api-models.js.map +1 -1
  59. package/dist/providers/index.d.ts +2 -1
  60. package/dist/providers/index.d.ts.map +1 -1
  61. package/dist/providers/index.js +6 -1
  62. package/dist/providers/index.js.map +1 -1
  63. package/dist/providers/mistral.d.ts +58 -0
  64. package/dist/providers/mistral.d.ts.map +1 -0
  65. package/dist/providers/mistral.js +310 -0
  66. package/dist/providers/mistral.js.map +1 -0
  67. package/dist/providers/registry.d.ts.map +1 -1
  68. package/dist/providers/registry.js +2 -0
  69. package/dist/providers/registry.js.map +1 -1
  70. package/dist/runners/cron.d.ts.map +1 -1
  71. package/dist/runners/cron.js +9 -0
  72. package/dist/runners/cron.js.map +1 -1
  73. package/dist/runners/wakeup.d.ts +1 -0
  74. package/dist/runners/wakeup.d.ts.map +1 -1
  75. package/dist/runners/wakeup.js +27 -0
  76. package/dist/runners/wakeup.js.map +1 -1
  77. package/package.json +1 -1
@@ -38,21 +38,16 @@ exports.runnersCommand = runnersCommand;
38
38
  * steroids runners - Manage runner daemons
39
39
  */
40
40
  const node_util_1 = require("node:util");
41
- const node_child_process_1 = require("node:child_process");
42
- const fs = __importStar(require("node:fs"));
43
41
  const path = __importStar(require("node:path"));
44
- const os = __importStar(require("node:os"));
45
- const loader_js_1 = require("../config/loader.js");
46
42
  const daemon_js_1 = require("../runners/daemon.js");
47
- const lock_js_1 = require("../runners/lock.js");
48
- const wakeup_js_1 = require("../runners/wakeup.js");
49
- const cron_js_1 = require("../runners/cron.js");
50
43
  const connection_js_1 = require("../database/connection.js");
51
44
  const queries_js_1 = require("../database/queries.js");
52
- const node_fs_1 = require("node:fs");
53
- const node_path_1 = require("node:path");
54
- const projects_js_1 = require("../runners/projects.js");
55
45
  const help_js_1 = require("../cli/help.js");
46
+ const runners_parallel_js_1 = require("./runners-parallel.js");
47
+ const runners_wakeup_js_1 = require("./runners-wakeup.js");
48
+ const runners_list_js_1 = require("./runners-list.js");
49
+ const runners_logs_js_1 = require("./runners-logs.js");
50
+ const runners_management_js_1 = require("./runners-management.js");
56
51
  const HELP = (0, help_js_1.generateHelp)({
57
52
  command: 'runners',
58
53
  description: 'Manage background runner daemons for automated task execution',
@@ -73,6 +68,9 @@ Runners can be started manually or managed automatically via cron.`,
73
68
  { long: 'detach', description: 'Run in background (daemonize) - start subcommand' },
74
69
  { long: 'project', description: 'Project path to work on', values: '<path>' },
75
70
  { long: 'section', description: 'Focus on specific section only', values: '<id|name>' },
71
+ { long: 'parallel', description: 'Run independent sections across multiple clones' },
72
+ { long: 'max', description: 'Limit number of concurrent workstreams', values: '<n>' },
73
+ { long: 'dry-run', description: 'Analyze plan and exit without cloning or spawning' },
76
74
  { long: 'id', description: 'Stop specific runner by ID - stop subcommand', values: '<id>' },
77
75
  { long: 'all', description: 'Stop all runners - stop subcommand' },
78
76
  { long: 'tree', description: 'Show tree view with projects/runners/tasks - list subcommand' },
@@ -83,6 +81,9 @@ Runners can be started manually or managed automatically via cron.`,
83
81
  examples: [
84
82
  { command: 'steroids runners start', description: 'Start in foreground' },
85
83
  { command: 'steroids runners start --detach', description: 'Start in background' },
84
+ { command: 'steroids runners start --parallel', description: 'Analyze and run independent workstreams in parallel clones' },
85
+ { command: 'steroids runners start --parallel --max 2', description: 'Run up to 2 workstreams concurrently' },
86
+ { command: 'steroids runners start --parallel --dry-run', description: 'Show planned parallel workstreams and exit' },
86
87
  { command: 'steroids runners start --section "Phase 2"', description: 'Focus on specific section' },
87
88
  { command: 'steroids runners stop', description: 'Stop runner for current project' },
88
89
  { command: 'steroids runners stop --all', description: 'Stop all runners' },
@@ -119,7 +120,7 @@ async function runnersCommand(args, flags) {
119
120
  const subArgs = args.slice(1);
120
121
  switch (subcommand) {
121
122
  case 'start':
122
- await runStart(subArgs);
123
+ await runStart(subArgs, flags);
123
124
  break;
124
125
  case 'stop':
125
126
  await runStop(subArgs);
@@ -128,16 +129,16 @@ async function runnersCommand(args, flags) {
128
129
  await runStatus(subArgs);
129
130
  break;
130
131
  case 'list':
131
- await runList(subArgs, flags);
132
+ await (0, runners_list_js_1.runList)(subArgs, flags);
132
133
  break;
133
134
  case 'logs':
134
- await runLogs(subArgs);
135
+ await (0, runners_logs_js_1.runLogs)(subArgs);
135
136
  break;
136
137
  case 'wakeup':
137
- await runWakeup(subArgs, flags);
138
+ await (0, runners_wakeup_js_1.runWakeup)(subArgs, flags);
138
139
  break;
139
140
  case 'cron':
140
- await runCron(subArgs);
141
+ await (0, runners_wakeup_js_1.runCron)(subArgs);
141
142
  break;
142
143
  default:
143
144
  console.error(`Unknown subcommand: ${subcommand}`);
@@ -145,7 +146,7 @@ async function runnersCommand(args, flags) {
145
146
  process.exit(1);
146
147
  }
147
148
  }
148
- async function runStart(args) {
149
+ async function runStart(args, flags) {
149
150
  const { values } = (0, node_util_1.parseArgs)({
150
151
  args,
151
152
  options: {
@@ -154,6 +155,11 @@ async function runStart(args) {
154
155
  detach: { type: 'boolean', short: 'd', default: false },
155
156
  project: { type: 'string', short: 'p' },
156
157
  section: { type: 'string' },
158
+ parallel: { type: 'boolean', default: false },
159
+ max: { type: 'string' },
160
+ 'section-ids': { type: 'string' },
161
+ branch: { type: 'string' },
162
+ 'parallel-session-id': { type: 'string' },
157
163
  },
158
164
  allowPositionals: false,
159
165
  });
@@ -164,22 +170,38 @@ steroids runners start - Start runner daemon
164
170
  USAGE:
165
171
  steroids runners start [options]
166
172
 
167
- OPTIONS:
173
+ OPTIONS:
168
174
  --detach Run in background
169
175
  --project <path> Project path
170
176
  --section <id|name> Focus on a specific section only
177
+ --parallel Analyze dependency graph and run independent workstreams in parallel clones
178
+ --max <n> Limit number of parallel workstreams (overrides runners.parallel.maxClones)
171
179
  -j, --json Output as JSON
172
180
  -h, --help Show help
181
+ --dry-run Print analysis plan and exit
173
182
  `);
174
183
  return;
175
184
  }
185
+ const sectionIdsOption = values['section-ids'];
186
+ const parallelSessionId = values['parallel-session-id'];
187
+ const asJson = values.json || flags.json;
188
+ if (values.parallel && values.section) {
189
+ const errorMsg = '--parallel cannot be combined with --section';
190
+ if (asJson) {
191
+ console.log(JSON.stringify({ success: false, error: errorMsg }));
192
+ }
193
+ else {
194
+ console.error(errorMsg);
195
+ }
196
+ process.exit(1);
197
+ }
176
198
  // Check if we can start
177
199
  // Default to cwd if --project not specified, to ensure proper per-project tracking
178
200
  // Always resolve to absolute path for consistent tracking across processes
179
201
  const projectPath = path.resolve(values.project ?? process.cwd());
180
202
  const check = (0, daemon_js_1.canStartDaemon)(projectPath);
181
203
  if (!check.canStart && !check.reason?.includes('zombie')) {
182
- if (values.json) {
204
+ if (asJson) {
183
205
  console.log(JSON.stringify({
184
206
  success: false,
185
207
  error: check.reason,
@@ -194,9 +216,36 @@ OPTIONS:
194
216
  }
195
217
  process.exit(6);
196
218
  }
219
+ const runFromDetachedParent = values.detach && !values.parallel && !parallelSessionId && !sectionIdsOption;
220
+ const sectionIdsFromSpawn = typeof sectionIdsOption === 'string'
221
+ ? (0, runners_parallel_js_1.parseSectionIds)(sectionIdsOption)
222
+ : [];
223
+ // Internal parallel runner invocation used by this command when spawning workspace runners.
224
+ if (values.parallel
225
+ && sectionIdsOption !== undefined
226
+ && parallelSessionId
227
+ && values.branch) {
228
+ if (sectionIdsFromSpawn.length === 0) {
229
+ const errorMsg = 'Internal parallel runner received empty section ids';
230
+ if (asJson) {
231
+ console.log(JSON.stringify({ success: false, error: errorMsg }));
232
+ }
233
+ else {
234
+ console.error(errorMsg);
235
+ }
236
+ process.exit(1);
237
+ }
238
+ await (0, daemon_js_1.startDaemon)({
239
+ projectPath,
240
+ sectionIds: sectionIdsFromSpawn,
241
+ branchName: values.branch,
242
+ parallelSessionId,
243
+ });
244
+ return;
245
+ }
197
246
  // Resolve section if --section flag is provided
198
247
  let focusedSectionId;
199
- if (values.section) {
248
+ if (!values.parallel && values.section) {
200
249
  const sectionInput = values.section;
201
250
  const { db, close } = (0, connection_js_1.openDatabase)(projectPath);
202
251
  try {
@@ -274,812 +323,92 @@ OPTIONS:
274
323
  close();
275
324
  }
276
325
  }
277
- if (values.detach) {
326
+ if (runFromDetachedParent) {
278
327
  // Spawn detached process - always pass --project for proper tracking
279
328
  const spawnArgs = [process.argv[1], 'runners', 'start', '--project', projectPath];
280
329
  // Pass --section if specified
281
330
  if (values.section) {
282
331
  spawnArgs.push('--section', values.section);
283
332
  }
284
- // Check config for daemon logging preference
285
- const config = (0, loader_js_1.loadConfig)(projectPath);
286
- const daemonLogsEnabled = config.runners?.daemonLogs !== false;
287
- let logFile;
288
- let logFd;
289
- if (daemonLogsEnabled) {
290
- // Create logs directory and log file for daemon output
291
- const logsDir = path.join(os.homedir(), '.steroids', 'runners', 'logs');
292
- fs.mkdirSync(logsDir, { recursive: true });
293
- // Use timestamp for now, will rename after we have PID
294
- const tempLogPath = path.join(logsDir, `daemon-${Date.now()}.log`);
295
- logFd = fs.openSync(tempLogPath, 'a');
296
- logFile = tempLogPath;
297
- }
298
- const child = (0, node_child_process_1.spawn)(process.execPath, spawnArgs, {
299
- detached: true,
300
- stdio: daemonLogsEnabled && logFd !== undefined
301
- ? ['ignore', logFd, logFd]
302
- : 'ignore',
333
+ const { pid, logFile: finalLogPath, } = (0, runners_parallel_js_1.spawnDetachedRunner)({
334
+ projectPath,
335
+ args: spawnArgs,
303
336
  });
304
- child.unref();
305
- // Clean up file descriptor and rename log file
306
- if (logFd !== undefined) {
307
- fs.closeSync(logFd);
308
- }
309
- let finalLogPath;
310
- if (logFile && child.pid) {
311
- const logsDir = path.dirname(logFile);
312
- finalLogPath = path.join(logsDir, `daemon-${child.pid}.log`);
313
- try {
314
- fs.renameSync(logFile, finalLogPath);
315
- }
316
- catch {
317
- finalLogPath = logFile; // Keep temp name if rename fails
318
- }
319
- }
320
- if (values.json) {
337
+ if (asJson) {
321
338
  console.log(JSON.stringify({
322
339
  success: true,
323
- pid: child.pid,
340
+ pid,
324
341
  detached: true,
325
342
  logFile: finalLogPath,
326
343
  }));
327
344
  }
328
345
  else {
329
- console.log(`Runner started in background (PID: ${child.pid})`);
346
+ console.log(`Runner started in background (PID: ${pid})`);
330
347
  if (finalLogPath) {
331
348
  console.log(` Log file: ${finalLogPath}`);
332
349
  }
333
350
  }
334
351
  return;
335
352
  }
336
- // Start in foreground
337
- await (0, daemon_js_1.startDaemon)({ projectPath, sectionId: focusedSectionId });
338
- }
339
- async function runStop(args) {
340
- const { values } = (0, node_util_1.parseArgs)({
341
- args,
342
- options: {
343
- help: { type: 'boolean', short: 'h', default: false },
344
- json: { type: 'boolean', short: 'j', default: false },
345
- id: { type: 'string' },
346
- all: { type: 'boolean', default: false },
347
- },
348
- allowPositionals: false,
349
- });
350
- if (values.help) {
351
- console.log(`
352
- steroids runners stop - Stop runner(s)
353
-
354
- USAGE:
355
- steroids runners stop [options]
356
-
357
- OPTIONS:
358
- --id <id> Stop specific runner
359
- --all Stop all runners
360
- -j, --json Output as JSON
361
- -h, --help Show help
362
- `);
363
- return;
364
- }
365
- // Capture stop context for logging
366
- const stopContext = {
367
- calledFrom: process.cwd(),
368
- callerPid: process.pid,
369
- timestamp: new Date().toISOString(),
370
- user: process.env.USER || process.env.USERNAME || 'unknown',
371
- args: {
372
- id: values.id,
373
- all: values.all,
374
- },
375
- };
376
- const runners = (0, daemon_js_1.listRunners)();
377
- let stopped = 0;
378
- const stoppedRunners = [];
379
- const runnersToStop = values.id
380
- ? runners.filter((r) => r.id === values.id || r.id.startsWith(values.id))
381
- : values.all
382
- ? runners
383
- : runners.filter((r) => r.pid === process.pid || r.pid !== null);
384
- // Log stop action to daemon logs
385
- const logsDir = path.join(os.homedir(), '.steroids', 'runners', 'logs');
386
- const stopLogPath = path.join(logsDir, 'stop-audit.log');
387
- fs.mkdirSync(logsDir, { recursive: true });
388
- for (const runner of runnersToStop) {
389
- if (runner.pid && (0, lock_js_1.isProcessAlive)(runner.pid)) {
390
- try {
391
- process.kill(runner.pid, 'SIGTERM');
392
- stopped++;
393
- stoppedRunners.push({
394
- id: runner.id,
395
- pid: runner.pid,
396
- project: runner.project_path,
397
- });
398
- // Log each stop to audit log
399
- const logEntry = {
400
- ...stopContext,
401
- action: 'stop',
402
- runner: {
403
- id: runner.id,
404
- pid: runner.pid,
405
- project: runner.project_path,
406
- },
407
- };
408
- fs.appendFileSync(stopLogPath, JSON.stringify(logEntry) + '\n');
409
- }
410
- catch {
411
- // Process already dead
412
- }
413
- }
414
- (0, daemon_js_1.unregisterRunner)(runner.id);
415
- }
416
- if (values.json) {
417
- console.log(JSON.stringify({
418
- success: true,
419
- stopped,
420
- stoppedRunners,
421
- context: stopContext,
422
- }));
423
- }
424
- else {
425
- console.log(`Stopped ${stopped} runner(s)`);
426
- if (stopped > 0 && !values.all) {
427
- console.log(` Called from: ${stopContext.calledFrom}`);
428
- console.log(` Audit log: ${stopLogPath}`);
429
- }
430
- }
431
- }
432
- async function runStatus(args) {
433
- const { values } = (0, node_util_1.parseArgs)({
434
- args,
435
- options: {
436
- help: { type: 'boolean', short: 'h', default: false },
437
- json: { type: 'boolean', short: 'j', default: false },
438
- },
439
- allowPositionals: false,
440
- });
441
- if (values.help) {
442
- console.log(`
443
- steroids runners status - Show runner status
444
-
445
- USAGE:
446
- steroids runners status [options]
447
-
448
- OPTIONS:
449
- -j, --json Output as JSON
450
- -h, --help Show help
451
- `);
452
- return;
453
- }
454
- const lockStatus = (0, lock_js_1.checkLockStatus)();
455
- const runners = (0, daemon_js_1.listRunners)();
456
- const activeRunner = runners.find((r) => r.pid && (0, lock_js_1.isProcessAlive)(r.pid));
457
- const status = {
458
- locked: lockStatus.locked,
459
- lockPid: lockStatus.pid,
460
- isZombie: lockStatus.isZombie,
461
- activeRunner: activeRunner
462
- ? {
463
- id: activeRunner.id,
464
- pid: activeRunner.pid,
465
- status: activeRunner.status,
466
- project: activeRunner.project_path,
467
- currentTask: activeRunner.current_task_id,
468
- heartbeat: activeRunner.heartbeat_at,
469
- }
470
- : null,
471
- totalRunners: runners.length,
472
- };
473
- if (values.json) {
474
- console.log(JSON.stringify(status, null, 2));
475
- return;
476
- }
477
- if (activeRunner) {
478
- console.log(`Runner Status: ACTIVE`);
479
- console.log(` ID: ${activeRunner.id}`);
480
- console.log(` PID: ${activeRunner.pid}`);
481
- console.log(` Status: ${activeRunner.status}`);
482
- if (activeRunner.project_path) {
483
- console.log(` Project: ${activeRunner.project_path}`);
484
- }
485
- if (activeRunner.current_task_id) {
486
- console.log(` Current Task: ${activeRunner.current_task_id}`);
487
- }
488
- console.log(` Last Heartbeat: ${activeRunner.heartbeat_at}`);
489
- }
490
- else if (lockStatus.isZombie) {
491
- console.log(`Runner Status: ZOMBIE`);
492
- console.log(` Lock exists but process (PID: ${lockStatus.pid}) is dead`);
493
- console.log(` Run 'steroids runners wakeup' to clean up`);
494
- }
495
- else {
496
- console.log(`Runner Status: INACTIVE`);
497
- console.log(` No runner is currently active`);
498
- }
499
- }
500
- async function runList(args, flags) {
501
- const { values } = (0, node_util_1.parseArgs)({
502
- args,
503
- options: {
504
- help: { type: 'boolean', short: 'h', default: false },
505
- tree: { type: 'boolean', short: 't', default: false },
506
- },
507
- allowPositionals: false,
508
- });
509
- if (values.help || flags.help) {
510
- console.log(`
511
- steroids runners list - List all runners
512
-
513
- USAGE:
514
- steroids runners list [options]
515
-
516
- OPTIONS:
517
- -t, --tree Show tree view with tasks
518
- -j, --json Output as JSON (global flag)
519
- -h, --help Show help
520
- `);
521
- return;
522
- }
523
- // Tree view mode
524
- if (values.tree) {
525
- await runListTree(flags.json);
526
- return;
527
- }
528
- const runners = (0, daemon_js_1.listRunners)();
529
- if (flags.json) {
530
- // For JSON output, enrich with section names if available
531
- const enrichedRunners = runners.map((runner) => {
532
- if (!runner.section_id || !runner.project_path) {
533
- return runner;
534
- }
535
- try {
536
- const { db, close } = (0, connection_js_1.openDatabase)(runner.project_path);
537
- try {
538
- const section = (0, queries_js_1.getSection)(db, runner.section_id);
539
- return { ...runner, section_name: section?.name };
540
- }
541
- finally {
542
- close();
543
- }
544
- }
545
- catch {
546
- return runner;
547
- }
548
- });
549
- console.log(JSON.stringify({ runners: enrichedRunners }, null, 2));
550
- return;
551
- }
552
- if (runners.length === 0) {
553
- console.log('No runners registered');
554
- return;
555
- }
556
- console.log('RUNNERS');
557
- console.log('─'.repeat(120));
558
- console.log('ID STATUS PID PROJECT SECTION HEARTBEAT');
559
- console.log('─'.repeat(120));
560
- for (const runner of runners) {
561
- const shortId = runner.id.substring(0, 8);
562
- const status = runner.status.padEnd(10);
563
- const pid = (runner.pid?.toString() ?? '-').padEnd(9);
564
- const project = (runner.project_path ?? '-').substring(0, 30).padEnd(30);
565
- // Fetch section name if available
566
- let sectionDisplay = '-';
567
- if (runner.section_id && runner.project_path) {
568
- try {
569
- const { db, close } = (0, connection_js_1.openDatabase)(runner.project_path);
570
- try {
571
- const section = (0, queries_js_1.getSection)(db, runner.section_id);
572
- if (section) {
573
- sectionDisplay = section.name.substring(0, 30);
574
- }
353
+ if (values.parallel) {
354
+ const maxFromCli = typeof values.max === 'string' ? values.max.trim() : undefined;
355
+ let maxClones;
356
+ if (maxFromCli !== undefined) {
357
+ const parsedMax = Number.parseInt(maxFromCli, 10);
358
+ if (!Number.isInteger(parsedMax) || parsedMax <= 0) {
359
+ if (asJson) {
360
+ console.log(JSON.stringify({ success: false, error: '--max must be a positive integer' }));
575
361
  }
576
- finally {
577
- close();
362
+ else {
363
+ console.error('--max must be a positive integer');
578
364
  }
365
+ process.exit(1);
579
366
  }
580
- catch {
581
- // If we can't fetch the section name, just show the ID prefix
582
- sectionDisplay = runner.section_id.substring(0, 8);
583
- }
584
- }
585
- const section = sectionDisplay.padEnd(30);
586
- const heartbeat = runner.heartbeat_at.substring(11, 19);
587
- const alive = runner.pid && (0, lock_js_1.isProcessAlive)(runner.pid) ? '' : ' (dead)';
588
- console.log(`${shortId} ${status} ${pid} ${project} ${section} ${heartbeat}${alive}`);
589
- }
590
- // Check if there are multiple projects
591
- const uniqueProjects = new Set(runners.map(r => r.project_path).filter(Boolean));
592
- if (uniqueProjects.size > 1) {
593
- const currentProject = process.cwd();
594
- console.log('');
595
- console.log('─'.repeat(120));
596
- console.log(`⚠️ MULTI-PROJECT WARNING: ${uniqueProjects.size} different projects have runners.`);
597
- console.log(` Your current project: ${currentProject}`);
598
- console.log(' DO NOT modify files in other projects. Each runner works only on its own project.');
599
- console.log('─'.repeat(120));
600
- }
601
- }
602
- /**
603
- * Tree view of runners grouped by project with their current tasks
604
- */
605
- async function runListTree(json) {
606
- const runners = (0, daemon_js_1.listRunners)();
607
- const projects = (0, projects_js_1.getRegisteredProjects)(false);
608
- const projectMap = new Map();
609
- // Initialize with all registered projects
610
- for (const project of projects) {
611
- projectMap.set(project.path, {
612
- path: project.path,
613
- name: project.name || (0, node_path_1.basename)(project.path),
614
- runners: [],
615
- activeTasks: [],
616
- });
617
- }
618
- // Add runners to their projects
619
- for (const runner of runners) {
620
- const projectPath = runner.project_path;
621
- if (!projectPath)
622
- continue;
623
- if (!projectMap.has(projectPath)) {
624
- projectMap.set(projectPath, {
625
- path: projectPath,
626
- name: (0, node_path_1.basename)(projectPath),
627
- runners: [],
628
- activeTasks: [],
629
- });
367
+ maxClones = parsedMax;
630
368
  }
631
- const info = projectMap.get(projectPath);
632
- info.runners.push(runner);
633
- }
634
- // Fetch active tasks for each project
635
- for (const [projectPath, info] of projectMap) {
636
- const dbPath = `${projectPath}/.steroids/steroids.db`;
637
- if (!(0, node_fs_1.existsSync)(dbPath))
638
- continue;
369
+ let parallelPlan;
639
370
  try {
640
- const { db, close } = (0, connection_js_1.openDatabase)(projectPath);
641
- try {
642
- const inProgress = (0, queries_js_1.listTasks)(db, { status: 'in_progress' });
643
- const review = (0, queries_js_1.listTasks)(db, { status: 'review' });
644
- info.activeTasks = [...inProgress, ...review];
645
- }
646
- finally {
647
- close();
371
+ parallelPlan = (0, runners_parallel_js_1.buildParallelRunPlan)(projectPath, maxClones);
372
+ }
373
+ catch (error) {
374
+ const message = error instanceof runners_parallel_js_1.CyclicDependencyError
375
+ ? error.message
376
+ : error instanceof Error
377
+ ? error.message
378
+ : 'Unable to create parallel plan';
379
+ if (asJson) {
380
+ console.log(JSON.stringify({ success: false, error: message }));
648
381
  }
649
- }
650
- catch {
651
- // Skip inaccessible projects
652
- }
653
- }
654
- // JSON output
655
- if (json) {
656
- const output = Array.from(projectMap.values()).map((info) => ({
657
- project: info.path,
658
- name: info.name,
659
- runners: info.runners.map((r) => ({
660
- id: r.id,
661
- status: r.status,
662
- pid: r.pid,
663
- currentTaskId: r.current_task_id,
664
- alive: r.pid ? (0, lock_js_1.isProcessAlive)(r.pid) : false,
665
- })),
666
- activeTasks: info.activeTasks.map((t) => ({
667
- id: t.id,
668
- title: t.title,
669
- status: t.status,
670
- })),
671
- }));
672
- console.log(JSON.stringify({ projects: output }, null, 2));
673
- return;
674
- }
675
- // Text tree view
676
- const projectList = Array.from(projectMap.values());
677
- const currentProject = process.cwd();
678
- if (projectList.length === 0) {
679
- console.log('No registered projects.');
680
- return;
681
- }
682
- console.log('');
683
- console.log('RUNNERS TREE');
684
- console.log('═'.repeat(80));
685
- for (let i = 0; i < projectList.length; i++) {
686
- const info = projectList[i];
687
- const isLast = i === projectList.length - 1;
688
- const isCurrent = info.path === currentProject;
689
- const currentMarker = isCurrent ? ' ← (current)' : '';
690
- console.log('');
691
- console.log(`📁 ${info.name}${currentMarker}`);
692
- console.log(` ${info.path}`);
693
- if (info.runners.length === 0) {
694
- console.log(' └─ (no runners)');
695
- }
696
- else {
697
- for (let j = 0; j < info.runners.length; j++) {
698
- const runner = info.runners[j];
699
- const isLastRunner = j === info.runners.length - 1;
700
- const runnerPrefix = isLastRunner ? '└─' : '├─';
701
- const childPrefix = isLastRunner ? ' ' : '│ ';
702
- const alive = runner.pid && (0, lock_js_1.isProcessAlive)(runner.pid);
703
- const statusIcon = alive ? '🟢' : '🔴';
704
- const statusText = alive ? runner.status : 'dead';
705
- const pidText = runner.pid ? ` PID ${runner.pid}` : '';
706
- console.log(` ${runnerPrefix} ${statusIcon} Runner ${runner.id.substring(0, 8)} (${statusText}${pidText})`);
707
- // Show section if focused
708
- if (runner.section_id && runner.project_path) {
709
- try {
710
- const { db, close } = (0, connection_js_1.openDatabase)(runner.project_path);
711
- try {
712
- const section = (0, queries_js_1.getSection)(db, runner.section_id);
713
- if (section) {
714
- console.log(` ${childPrefix} Section: ${section.name}`);
715
- }
716
- }
717
- finally {
718
- close();
719
- }
720
- }
721
- catch {
722
- // Ignore section fetch errors
723
- }
724
- }
725
- // Show current task if available
726
- if (runner.current_task_id && runner.project_path) {
727
- try {
728
- const { db, close } = (0, connection_js_1.openDatabase)(runner.project_path);
729
- try {
730
- const task = (0, queries_js_1.getTask)(db, runner.current_task_id);
731
- if (task) {
732
- const statusMarker = task.status === 'in_progress' ? '🔧' : '👁️';
733
- console.log(` ${childPrefix} ${statusMarker} ${task.title.substring(0, 50)}`);
734
- console.log(` ${childPrefix} [${task.status}] ${task.id.substring(0, 8)}`);
735
- }
736
- }
737
- finally {
738
- close();
739
- }
740
- }
741
- catch {
742
- console.log(` ${childPrefix} Task: ${runner.current_task_id.substring(0, 8)}`);
743
- }
744
- }
745
- else if (alive) {
746
- console.log(` ${childPrefix} (idle - no task)`);
747
- }
748
- }
749
- }
750
- // Show other active tasks not being worked on by runners
751
- const runnerTaskIds = new Set(info.runners.map((r) => r.current_task_id).filter(Boolean));
752
- const unassignedTasks = info.activeTasks.filter((t) => !runnerTaskIds.has(t.id));
753
- if (unassignedTasks.length > 0) {
754
- console.log(' │');
755
- console.log(' └─ 📋 Queued active tasks:');
756
- for (const task of unassignedTasks.slice(0, 5)) {
757
- const statusIcon = task.status === 'in_progress' ? '🔧' : '👁️';
758
- console.log(` ${statusIcon} ${task.title.substring(0, 45)} [${task.status}]`);
759
- }
760
- if (unassignedTasks.length > 5) {
761
- console.log(` ... and ${unassignedTasks.length - 5} more`);
382
+ else {
383
+ console.error(message);
762
384
  }
385
+ process.exit(1);
763
386
  }
764
- }
765
- console.log('');
766
- console.log('═'.repeat(80));
767
- // Multi-project warning
768
- const activeProjects = projectList.filter((p) => p.runners.length > 0);
769
- if (activeProjects.length > 1) {
770
- console.log('');
771
- console.log('⚠️ MULTI-PROJECT: Multiple projects have active runners.');
772
- console.log(' Each runner works ONLY on its own project.');
773
- }
774
- }
775
- async function runLogs(args) {
776
- const { values, positionals } = (0, node_util_1.parseArgs)({
777
- args,
778
- options: {
779
- help: { type: 'boolean', short: 'h', default: false },
780
- json: { type: 'boolean', short: 'j', default: false },
781
- tail: { type: 'string', short: 'n', default: '50' },
782
- follow: { type: 'boolean', short: 'f', default: false },
783
- clear: { type: 'boolean', default: false },
784
- },
785
- allowPositionals: true,
786
- });
787
- if (values.help) {
788
- console.log(`
789
- steroids runners logs - View daemon crash/output logs
790
-
791
- USAGE:
792
- steroids runners logs [pid] [options]
793
-
794
- OPTIONS:
795
- <pid> Show logs for specific daemon PID
796
- --tail <n> Show last n lines (default: 50)
797
- --follow Follow log output (latest log)
798
- --clear Clear all daemon logs
799
- -j, --json Output as JSON
800
- -h, --help Show help
801
-
802
- LOG LOCATION:
803
- Logs are stored in ~/.steroids/runners/logs/
804
- Each daemon gets its own log file: daemon-<pid>.log
805
-
806
- To disable daemon logging, set in config:
807
- steroids config set runners.daemonLogs false
808
-
809
- EXAMPLES:
810
- steroids runners logs # List available log files
811
- steroids runners logs 12345 # View logs for PID 12345
812
- steroids runners logs --follow # Follow the latest log
813
- steroids runners logs --clear # Remove all log files
814
- `);
815
- return;
816
- }
817
- const logsDir = path.join(os.homedir(), '.steroids', 'runners', 'logs');
818
- // Handle --clear
819
- if (values.clear) {
820
- if (!fs.existsSync(logsDir)) {
821
- if (values.json) {
822
- console.log(JSON.stringify({ success: true, cleared: 0 }));
387
+ if (flags.dryRun) {
388
+ if (asJson) {
389
+ console.log(JSON.stringify({ success: true, plan: parallelPlan }));
823
390
  }
824
391
  else {
825
- console.log('No logs directory found');
392
+ (0, runners_parallel_js_1.printParallelPlan)(projectPath, parallelPlan);
826
393
  }
827
394
  return;
828
395
  }
829
- const files = fs.readdirSync(logsDir).filter((f) => f.endsWith('.log'));
830
- for (const file of files) {
831
- fs.unlinkSync(path.join(logsDir, file));
832
- }
833
- if (values.json) {
834
- console.log(JSON.stringify({ success: true, cleared: files.length }));
396
+ const sessionId = (0, runners_parallel_js_1.launchParallelSession)(parallelPlan, projectPath);
397
+ if (asJson) {
398
+ console.log(JSON.stringify({ success: true, sessionId }));
835
399
  }
836
400
  else {
837
- console.log(`Cleared ${files.length} log file(s)`);
838
- }
839
- return;
840
- }
841
- // Ensure logs directory exists
842
- if (!fs.existsSync(logsDir)) {
843
- if (values.json) {
844
- console.log(JSON.stringify({ logs: [], logsDir }));
845
- }
846
- else {
847
- console.log('No daemon logs found');
848
- console.log(` Logs are stored in: ${logsDir}`);
849
- }
850
- return;
851
- }
852
- const logFiles = fs.readdirSync(logsDir)
853
- .filter((f) => f.startsWith('daemon-') && f.endsWith('.log'))
854
- .map((f) => {
855
- const filePath = path.join(logsDir, f);
856
- const stats = fs.statSync(filePath);
857
- const pidMatch = f.match(/daemon-(\d+)\.log/);
858
- return {
859
- file: f,
860
- path: filePath,
861
- pid: pidMatch ? parseInt(pidMatch[1], 10) : null,
862
- size: stats.size,
863
- modified: stats.mtime,
864
- };
865
- })
866
- .sort((a, b) => b.modified.getTime() - a.modified.getTime());
867
- // If a PID is specified, show that log
868
- if (positionals.length > 0) {
869
- const pidArg = positionals[0];
870
- const logFile = logFiles.find((l) => l.pid?.toString() === pidArg || l.file.includes(pidArg));
871
- if (!logFile) {
872
- console.error(`No log found for PID: ${pidArg}`);
873
- process.exit(1);
874
- }
875
- const content = fs.readFileSync(logFile.path, 'utf-8');
876
- const lines = content.split('\n');
877
- const tailLines = parseInt(values.tail, 10) || 50;
878
- const output = lines.slice(-tailLines).join('\n');
879
- if (values.json) {
880
- console.log(JSON.stringify({ pid: logFile.pid, path: logFile.path, content: output }));
881
- }
882
- else {
883
- console.log(`=== Daemon log for PID ${logFile.pid} ===`);
884
- console.log(`File: ${logFile.path}`);
885
- console.log(`Modified: ${logFile.modified.toISOString()}`);
886
- console.log('─'.repeat(60));
887
- console.log(output);
888
- }
889
- return;
890
- }
891
- // If --follow, tail the most recent log
892
- if (values.follow) {
893
- if (logFiles.length === 0) {
894
- console.error('No log files to follow');
895
- process.exit(1);
401
+ console.log(`Started parallel session: ${sessionId}`);
896
402
  }
897
- const latestLog = logFiles[0];
898
- console.log(`Following: ${latestLog.path} (PID: ${latestLog.pid})`);
899
- console.log('─'.repeat(60));
900
- // Use spawn to tail -f
901
- const tail = (0, node_child_process_1.spawn)('tail', ['-f', latestLog.path], { stdio: 'inherit' });
902
- tail.on('error', (err) => {
903
- console.error(`Error following log: ${err.message}`);
904
- process.exit(1);
905
- });
906
403
  return;
907
404
  }
908
- // List all log files
909
- if (values.json) {
910
- console.log(JSON.stringify({ logs: logFiles, logsDir }, null, 2));
911
- return;
912
- }
913
- if (logFiles.length === 0) {
914
- console.log('No daemon logs found');
915
- console.log(` Logs are stored in: ${logsDir}`);
916
- return;
917
- }
918
- console.log('DAEMON LOGS');
919
- console.log('─'.repeat(80));
920
- console.log('PID SIZE MODIFIED FILE');
921
- console.log('─'.repeat(80));
922
- for (const log of logFiles) {
923
- const pid = (log.pid?.toString() ?? 'unknown').padEnd(10);
924
- const size = formatBytes(log.size).padEnd(9);
925
- const modified = log.modified.toISOString().substring(0, 19).padEnd(22);
926
- console.log(`${pid} ${size} ${modified} ${log.file}`);
927
- }
928
- console.log('');
929
- console.log(`Logs directory: ${logsDir}`);
930
- console.log(`Use 'steroids runners logs <pid>' to view a specific log`);
931
- }
932
- function formatBytes(bytes) {
933
- if (bytes === 0)
934
- return '0 B';
935
- const k = 1024;
936
- const sizes = ['B', 'KB', 'MB', 'GB'];
937
- const i = Math.floor(Math.log(bytes) / Math.log(k));
938
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
405
+ // Start in foreground
406
+ await (0, daemon_js_1.startDaemon)({ projectPath, sectionId: focusedSectionId });
939
407
  }
940
- async function runWakeup(args, flags) {
941
- // Hard timeout: wakeup is a short-lived process spawned by launchd every 60s.
942
- // If ANY operation hangs (TCC dialog blocking file access, locked DB, etc.),
943
- // this ensures the process exits instead of becoming a zombie.
944
- const WAKEUP_TIMEOUT_MS = 30_000;
945
- setTimeout(() => process.exit(0), WAKEUP_TIMEOUT_MS).unref();
946
- const { values } = (0, node_util_1.parseArgs)({
947
- args,
948
- options: {
949
- help: { type: 'boolean', short: 'h', default: false },
950
- json: { type: 'boolean', short: 'j', default: false },
951
- quiet: { type: 'boolean', short: 'q', default: false },
952
- },
953
- allowPositionals: false,
954
- });
955
- if (values.help) {
956
- console.log(`
957
- steroids runners wakeup - Check and restart stale runners
958
-
959
- USAGE:
960
- steroids runners wakeup [options]
961
-
962
- OPTIONS:
963
- --quiet Suppress output (for cron)
964
- --dry-run Check without acting
965
- -j, --json Output as JSON
966
- -h, --help Show help
967
- `);
968
- return;
969
- }
970
- const results = await (0, wakeup_js_1.wakeup)({
971
- quiet: values.quiet || flags.quiet || values.json || flags.json,
972
- dryRun: flags.dryRun,
973
- });
974
- if (values.json || flags.json) {
975
- console.log(JSON.stringify({ results }, null, 2));
976
- return;
977
- }
978
- if (!values.quiet && !flags.quiet) {
979
- // Summarize results
980
- const started = results.filter(r => r.action === 'started').length;
981
- const cleaned = results.filter(r => r.action === 'cleaned').length;
982
- const wouldStart = results.filter(r => r.action === 'would_start').length;
983
- if (started > 0) {
984
- console.log(`Started ${started} runner(s)`);
985
- }
986
- if (cleaned > 0) {
987
- console.log(`Cleaned ${cleaned} stale runner(s)`);
988
- }
989
- if (wouldStart > 0) {
990
- console.log(`Would start ${wouldStart} runner(s) (dry-run)`);
991
- }
992
- if (started === 0 && cleaned === 0 && wouldStart === 0) {
993
- console.log('No action needed');
994
- }
995
- // Show per-project details
996
- for (const result of results) {
997
- if (result.projectPath) {
998
- const status = result.action === 'started' ? '✓' :
999
- result.action === 'would_start' ? '~' : '-';
1000
- console.log(` ${status} ${result.projectPath}: ${result.reason}`);
1001
- }
1002
- }
1003
- }
1004
- // Force exit — wakeup is a short-lived process spawned by launchd every 60s.
1005
- // Without this, lingering DB handles (better-sqlite3 native bindings) keep
1006
- // the Node.js event loop alive indefinitely, causing zombie process accumulation.
1007
- process.exit(0);
408
+ async function runStop(args) {
409
+ return (0, runners_management_js_1.runStop)(args);
1008
410
  }
1009
- async function runCron(args) {
1010
- if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
1011
- console.log(`
1012
- steroids runners cron - Manage cron job
1013
-
1014
- USAGE:
1015
- steroids runners cron <subcommand>
1016
-
1017
- SUBCOMMANDS:
1018
- install Add cron job (every minute)
1019
- uninstall Remove cron job
1020
- status Check cron status
1021
-
1022
- OPTIONS:
1023
- -j, --json Output as JSON
1024
- -h, --help Show help
1025
- `);
1026
- return;
1027
- }
1028
- const subcommand = args[0];
1029
- const subArgs = args.slice(1);
1030
- const { values } = (0, node_util_1.parseArgs)({
1031
- args: subArgs,
1032
- options: {
1033
- json: { type: 'boolean', short: 'j', default: false },
1034
- },
1035
- allowPositionals: false,
1036
- });
1037
- switch (subcommand) {
1038
- case 'install': {
1039
- const result = (0, cron_js_1.cronInstall)();
1040
- if (values.json) {
1041
- console.log(JSON.stringify(result, null, 2));
1042
- }
1043
- else {
1044
- console.log(result.message);
1045
- if (result.error) {
1046
- console.error(result.error);
1047
- }
1048
- }
1049
- break;
1050
- }
1051
- case 'uninstall': {
1052
- const result = (0, cron_js_1.cronUninstall)();
1053
- if (values.json) {
1054
- console.log(JSON.stringify(result, null, 2));
1055
- }
1056
- else {
1057
- console.log(result.message);
1058
- }
1059
- break;
1060
- }
1061
- case 'status': {
1062
- const status = (0, cron_js_1.cronStatus)();
1063
- if (values.json) {
1064
- console.log(JSON.stringify(status, null, 2));
1065
- }
1066
- else {
1067
- if (status.installed) {
1068
- console.log('Cron job: INSTALLED');
1069
- console.log(` Entry: ${status.entry}`);
1070
- }
1071
- else {
1072
- console.log('Cron job: NOT INSTALLED');
1073
- if (status.error) {
1074
- console.log(` ${status.error}`);
1075
- }
1076
- }
1077
- }
1078
- break;
1079
- }
1080
- default:
1081
- console.error(`Unknown cron subcommand: ${subcommand}`);
1082
- process.exit(1);
1083
- }
411
+ async function runStatus(args) {
412
+ return (0, runners_management_js_1.runStatus)(args);
1084
413
  }
1085
414
  //# sourceMappingURL=runners.js.map