hub-launch 1.21.0 → 1.23.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 (77) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +59 -1
  3. package/dist/commands/init.d.ts.map +1 -1
  4. package/dist/commands/init.js +66 -23
  5. package/dist/commands/init.js.map +1 -1
  6. package/dist/commands/launch.d.ts +104 -1
  7. package/dist/commands/launch.d.ts.map +1 -1
  8. package/dist/commands/launch.js +933 -181
  9. package/dist/commands/launch.js.map +1 -1
  10. package/dist/commands/schedule.d.ts.map +1 -1
  11. package/dist/commands/schedule.js +8 -7
  12. package/dist/commands/schedule.js.map +1 -1
  13. package/dist/commands/upload.d.ts +9 -2
  14. package/dist/commands/upload.d.ts.map +1 -1
  15. package/dist/commands/upload.js +16 -6
  16. package/dist/commands/upload.js.map +1 -1
  17. package/dist/config/index.d.ts.map +1 -1
  18. package/dist/config/index.js +4 -1
  19. package/dist/config/index.js.map +1 -1
  20. package/dist/scripts/launch-run.d.ts +6 -0
  21. package/dist/scripts/launch-run.d.ts.map +1 -1
  22. package/dist/scripts/launch-run.js +70 -9
  23. package/dist/scripts/launch-run.js.map +1 -1
  24. package/dist/services/api/HulaApiClient.d.ts +53 -0
  25. package/dist/services/api/HulaApiClient.d.ts.map +1 -1
  26. package/dist/services/api/HulaApiClient.js +18 -0
  27. package/dist/services/api/HulaApiClient.js.map +1 -1
  28. package/dist/services/git/FilePublishService.d.ts +9 -1
  29. package/dist/services/git/FilePublishService.d.ts.map +1 -1
  30. package/dist/services/git/FilePublishService.js +13 -3
  31. package/dist/services/git/FilePublishService.js.map +1 -1
  32. package/dist/services/git/GitService.d.ts +19 -0
  33. package/dist/services/git/GitService.d.ts.map +1 -1
  34. package/dist/services/git/GitService.js +56 -31
  35. package/dist/services/git/GitService.js.map +1 -1
  36. package/dist/services/git/WorktreeService.d.ts +9 -0
  37. package/dist/services/git/WorktreeService.d.ts.map +1 -1
  38. package/dist/services/git/WorktreeService.js +6 -3
  39. package/dist/services/git/WorktreeService.js.map +1 -1
  40. package/dist/services/group/GroupLaunchService.d.ts +230 -0
  41. package/dist/services/group/GroupLaunchService.d.ts.map +1 -0
  42. package/dist/services/group/GroupLaunchService.js +282 -0
  43. package/dist/services/group/GroupLaunchService.js.map +1 -0
  44. package/dist/templates/skills/hula-help/SKILL.md +1 -1
  45. package/dist/templates/skills/hula-launch/SKILL.md +38 -1
  46. package/dist/templates/skills/hula-schedule/SKILL.md +3 -3
  47. package/dist/types/config.schema.d.ts +34 -3
  48. package/dist/types/config.schema.d.ts.map +1 -1
  49. package/dist/types/config.schema.js +35 -1
  50. package/dist/types/config.schema.js.map +1 -1
  51. package/dist/utils/config-file-edit.d.ts +8 -4
  52. package/dist/utils/config-file-edit.d.ts.map +1 -1
  53. package/dist/utils/config-file-edit.js +13 -9
  54. package/dist/utils/config-file-edit.js.map +1 -1
  55. package/dist/utils/config-parser.d.ts.map +1 -1
  56. package/dist/utils/config-parser.js +4 -0
  57. package/dist/utils/config-parser.js.map +1 -1
  58. package/dist/utils/env-vars.d.ts.map +1 -1
  59. package/dist/utils/env-vars.js +4 -0
  60. package/dist/utils/env-vars.js.map +1 -1
  61. package/dist/utils/github-cli.d.ts +1 -1
  62. package/dist/utils/github-cli.d.ts.map +1 -1
  63. package/dist/utils/github-cli.js +15 -7
  64. package/dist/utils/github-cli.js.map +1 -1
  65. package/dist/utils/project-resolver.d.ts +1 -1
  66. package/dist/utils/project-resolver.d.ts.map +1 -1
  67. package/dist/utils/project-resolver.js +2 -2
  68. package/dist/utils/project-resolver.js.map +1 -1
  69. package/dist/utils/provider-credentials.d.ts +54 -0
  70. package/dist/utils/provider-credentials.d.ts.map +1 -0
  71. package/dist/utils/provider-credentials.js +83 -0
  72. package/dist/utils/provider-credentials.js.map +1 -0
  73. package/package.json +2 -2
  74. package/dist/utils/ephemeral-credentials.d.ts +0 -32
  75. package/dist/utils/ephemeral-credentials.d.ts.map +0 -1
  76. package/dist/utils/ephemeral-credentials.js +0 -26
  77. package/dist/utils/ephemeral-credentials.js.map +0 -1
@@ -4,12 +4,14 @@ import chalk from 'chalk';
4
4
  import os from 'os';
5
5
  import path from 'path';
6
6
  import { existsSync, readFileSync, writeFileSync } from 'fs';
7
- import { join } from 'path';
7
+ import { join, isAbsolute } from 'path';
8
8
  import { parse } from 'dotenv';
9
9
  import { logger } from '../utils/logger.js';
10
10
  import { GitService } from '../services/git/GitService.js';
11
11
  import { PlanService } from '../services/plan/PlanService.js';
12
- import { HulaApiClient } from '../services/api/HulaApiClient.js';
12
+ import { ConfigLoader } from '../config/index.js';
13
+ import { HulaApiClient, ApiError, } from '../services/api/HulaApiClient.js';
14
+ import { GroupLaunchService, validateGroupId, } from '../services/group/GroupLaunchService.js';
13
15
  import { TempFileService } from '../services/logs/TempFileService.js';
14
16
  import { EditorService } from '../services/editor/EditorService.js';
15
17
  import { executeUpload } from './upload.js';
@@ -17,11 +19,11 @@ import { loadGitHubToken } from './login.js';
17
19
  import { resolveProject } from '../utils/project-resolver.js';
18
20
  import { HULA_PROJECT_URL } from '../config/constants.js';
19
21
  import { updateUsageTierInConfig } from '../utils/config-writer.js';
20
- import { resolveEphemeralCredentials } from '../utils/ephemeral-credentials.js';
22
+ import { resolveProviderCredentials, validateTokenFormat, } from '../utils/provider-credentials.js';
21
23
  import { readClientSessionId } from '../utils/client-session.js';
22
24
  import { input, UserCancelledError } from '../utils/prompts.js';
23
25
  import { findGitRoot } from '../utils/git-utils.js';
24
- import { upsertAnthropicApiKey } from '../utils/config-file-edit.js';
26
+ import { upsertProviderApiKey } from '../utils/config-file-edit.js';
25
27
  import { validateNoReservedVars, readAndFilterEnvVars, RESERVED_ENV_VARS, } from '../utils/env-vars.js';
26
28
  const DEFAULT_CONTAINER_RESOURCES = { cpu: 2, memory: 4, disk: 10 };
27
29
  /**
@@ -31,6 +33,20 @@ const DEFAULT_CONTAINER_RESOURCES = { cpu: 2, memory: 4, disk: 10 };
31
33
  * Usage: hula launch --show <name>
32
34
  * Usage: hula launch --logs <name>
33
35
  */
36
+ /**
37
+ * Commander accumulator for the repeatable `--plan <repo>=<path>` option.
38
+ * Each occurrence is parsed as `repoName=relativePath` and merged into the
39
+ * override map. A missing `=` throws a clear error.
40
+ */
41
+ export function collectPlanOverrides(value, previous) {
42
+ const eq = value.indexOf('=');
43
+ if (eq <= 0) {
44
+ throw new Error(`Invalid --plan override "${value}". Expected <repoName>=<path>.`);
45
+ }
46
+ const repo = value.slice(0, eq);
47
+ const planPath = value.slice(eq + 1);
48
+ return { ...previous, [repo]: planPath };
49
+ }
34
50
  export function launchCommand(program, config) {
35
51
  program
36
52
  .command('launch [issueName] [planPath]')
@@ -53,7 +69,8 @@ export function launchCommand(program, config) {
53
69
  // 'Run in test mode (server uses a mock Claude — fast E2E run, still creates a real PR)',
54
70
  // )
55
71
  .option('--handoff <username>', 'Assign the created issue to this GitHub user')
56
- .option('--anthropic-key <key>', 'Anthropic API key for Claude Code (ephemeral)')
72
+ .option('--provider <type>', 'LLM provider: claude | openai | openrouter (default: config or claude)')
73
+ .option('--provider-key <key>', 'Provider credential (ephemeral; never stored server-side beyond the run)')
57
74
  .option('--container-cpu <n>', 'vCPU cores for the sandbox (1–32)', parseInt)
58
75
  .option('--container-memory <n>', 'GiB RAM for the sandbox (1–128)', parseInt)
59
76
  .option('--container-disk <n>', 'GiB disk for the sandbox (5–200)', parseInt)
@@ -64,8 +81,22 @@ export function launchCommand(program, config) {
64
81
  .option('--lines <n>', 'Number of log lines to show (default: 100)', parseInt)
65
82
  .option('--type <type>', 'Log type to fetch: log or output (default: log)')
66
83
  .option('--editor', 'Open logs in editor instead of printing to stdout')
84
+ // ── Multi-repo feature groups (client-side fan-out) ────────────────────
85
+ // NOTE: this `--folder` (a PARENT directory that contains sibling repos) is
86
+ // DIFFERENT from /hula-plan's `--folder` (a plans SUBDIRECTORY). See README.
87
+ .option('--folder <path>', 'Launch the plan in every initialized repo under this folder as one feature group')
88
+ .option('--group-id <id>', 'Use this group id instead of generating one (join/retry an existing group)')
89
+ .option('--plan <repo>=<path>', 'Override the auto-resolved plan for one repo in --folder mode (repeatable)', collectPlanOverrides, {})
90
+ .option('--show-group [id]', 'Show combined status for a feature group (id optional inside a member repo)')
91
+ .option('--yes', 'Skip the group-launch confirmation and allow plan auto-resolve without a TTY (used by the /hula-launch skill)')
67
92
  .action(async (issueName, planPath, options) => {
68
93
  try {
94
+ // Handle --show-group flag (read-only group status). Routed before
95
+ // the other read flags and before any folder/launch work.
96
+ if (options.showGroup !== undefined) {
97
+ await showGroupStatus(config, options.showGroup, options);
98
+ return;
99
+ }
69
100
  // Handle --show flag
70
101
  if (options.show) {
71
102
  await showJobStatus(config, options.show, options);
@@ -83,6 +114,21 @@ export function launchCommand(program, config) {
83
114
  logger.error(killFlagError);
84
115
  process.exit(1);
85
116
  }
117
+ // Validate the multi-repo `--folder` / `--group-id` / `--plan` flag
118
+ // combination before any work.
119
+ const folderFlagError = validateFolderFlags(options, Boolean(planPath));
120
+ if (folderFlagError) {
121
+ logger.error(folderFlagError);
122
+ process.exit(1);
123
+ }
124
+ // Validate an explicitly-supplied group id against the server format.
125
+ if (typeof options.groupId === 'string') {
126
+ const groupIdError = validateGroupId(options.groupId);
127
+ if (groupIdError) {
128
+ logger.error(groupIdError);
129
+ process.exit(1);
130
+ }
131
+ }
86
132
  // Validate per-step config + --skip-regression against the two
87
133
  // server-parity cross-field rules before any credential resolution or
88
134
  // network call. On conflict, fail fast with the server's exact wording.
@@ -91,6 +137,21 @@ export function launchCommand(program, config) {
91
137
  logger.error(stepsValidation.error);
92
138
  process.exit(1);
93
139
  }
140
+ // Multi-repo fan-out: --folder launches (or kills) one ordinary
141
+ // single-repo job per discovered repo, correlated by a shared groupId.
142
+ if (options.folder) {
143
+ if (!issueName) {
144
+ logger.error('Usage: hula launch <issueName> --folder <path>');
145
+ process.exit(1);
146
+ }
147
+ if (options.kill) {
148
+ await executeGroupKill(config, issueName, options);
149
+ }
150
+ else {
151
+ await executeGroupLaunch(config, issueName, options);
152
+ }
153
+ return;
154
+ }
94
155
  // --kill is a cancel-only path: it needs ONLY the tracking name (no
95
156
  // plan path) and routes to the dedicated executeKill function.
96
157
  if (options.kill) {
@@ -158,6 +219,43 @@ export function validateKillFlags(options) {
158
219
  export function requiresPlanPath(options) {
159
220
  return !options.kill;
160
221
  }
222
+ /**
223
+ * Validate the multi-repo `--folder` / `--group-id` / `--plan` flag matrix.
224
+ *
225
+ * - `--folder` resolves plans per repository, so it is incompatible with a
226
+ * positional `<planPath>` and with the read/resume flags `--show`, `--logs`,
227
+ * `--resume`, `--fix`.
228
+ * - `--plan` (per-repo override) only makes sense with `--folder`.
229
+ * - `--group-id` alone (no `--folder`) is allowed — it attaches a single-repo
230
+ * launch to an existing group.
231
+ * - `--kill --folder` is allowed (cancel-only fan-out).
232
+ *
233
+ * Returns an error message when the combination is invalid, or `null` when OK.
234
+ */
235
+ export function validateFolderFlags(options, hasPlanPathPositional) {
236
+ if (options.folder) {
237
+ if (hasPlanPathPositional) {
238
+ return '--folder resolves plans per repository; use --plan <repo>=<path> to override a specific repo';
239
+ }
240
+ const incompatible = [
241
+ { key: 'show', flag: '--show' },
242
+ { key: 'logs', flag: '--logs' },
243
+ { key: 'resume', flag: '--resume' },
244
+ { key: 'fix', flag: '--fix' },
245
+ ];
246
+ for (const { key, flag } of incompatible) {
247
+ const value = options[key];
248
+ if (value !== undefined && value !== false) {
249
+ return `--folder cannot be combined with ${flag}`;
250
+ }
251
+ }
252
+ }
253
+ const hasPlanOverrides = options.plan !== undefined && Object.keys(options.plan).length > 0;
254
+ if (hasPlanOverrides && !options.folder) {
255
+ return '--plan requires --folder (it overrides a per-repo plan in folder mode)';
256
+ }
257
+ return null;
258
+ }
161
259
  /**
162
260
  * Derive the request `conflictMode` from the CLI flags (plus an optional explicit
163
261
  * override used when re-invoking after an interactive 409 resolution).
@@ -221,16 +319,56 @@ export function resolveSteps(configSteps, skipRegressionFlag, legacyRegressionFl
221
319
  }
222
320
  return { steps: Object.keys(steps).length > 0 ? steps : undefined };
223
321
  }
224
- async function validateLaunchCredentials(anthropicApiKey, githubToken) {
322
+ /**
323
+ * Per-provider live-validation endpoint + copy. Claude accepts both subscription
324
+ * OAuth tokens (Bearer) and API keys (x-api-key); openai/openrouter use Bearer.
325
+ * See the endpoint table in the multi-provider plan (Requirements §4).
326
+ */
327
+ function buildProviderValidation(providerType, authToken) {
328
+ switch (providerType) {
329
+ case 'claude': {
330
+ const isApiKey = authToken.startsWith('sk-ant-api');
331
+ return {
332
+ url: 'https://api.anthropic.com/v1/models',
333
+ headers: isApiKey
334
+ ? { 'x-api-key': authToken, 'anthropic-version': '2023-06-01' }
335
+ : {
336
+ Authorization: `Bearer ${authToken}`,
337
+ 'anthropic-version': '2023-06-01',
338
+ },
339
+ readyLabel: 'Claude Code is ready to go 🤖',
340
+ providerName: 'Anthropic',
341
+ invalidHint: 'Generate a new credential — a subscription OAuth token at https://claude.ai/settings (Pro/Max) or an API key at https://platform.claude.com.',
342
+ quotaHint: 'Your Anthropic account is out of quota — check your plan and usage.',
343
+ };
344
+ }
345
+ case 'openai':
346
+ return {
347
+ url: 'https://api.openai.com/v1/models',
348
+ headers: { Authorization: `Bearer ${authToken}` },
349
+ readyLabel: 'Codex (OpenAI) is ready to go 🤖',
350
+ providerName: 'OpenAI',
351
+ invalidHint: 'Create a new API key at https://platform.openai.com/api-keys.',
352
+ quotaHint: 'Your OpenAI account is out of quota — check billing at https://platform.openai.com/account/billing.',
353
+ };
354
+ case 'openrouter':
355
+ return {
356
+ url: 'https://openrouter.ai/api/v1/key',
357
+ headers: { Authorization: `Bearer ${authToken}` },
358
+ readyLabel: 'Codex (OpenRouter) is ready to go 🤖',
359
+ providerName: 'OpenRouter',
360
+ invalidHint: 'Create a new key at https://openrouter.ai/settings/keys.',
361
+ quotaHint: 'Your OpenRouter account is out of credits — top up at https://openrouter.ai/settings/credits.',
362
+ };
363
+ }
364
+ }
365
+ async function validateProviderCredentials(providerType, authToken, githubToken) {
225
366
  console.log('Checking your credentials before launch...\n');
226
- // --- Anthropic ---
227
- const anthropicAuthHeaders = {
228
- Authorization: `Bearer ${anthropicApiKey}`,
229
- 'anthropic-version': '2023-06-01',
230
- };
231
- const anthropicPromise = axios
232
- .get('https://api.anthropic.com/v1/models', {
233
- headers: anthropicAuthHeaders,
367
+ // --- LLM provider ---
368
+ const providerCfg = buildProviderValidation(providerType, authToken);
369
+ const providerPromise = axios
370
+ .get(providerCfg.url, {
371
+ headers: providerCfg.headers,
234
372
  timeout: 8000,
235
373
  })
236
374
  .then(() => ({ ok: true }))
@@ -238,20 +376,37 @@ async function validateLaunchCredentials(anthropicApiKey, githubToken) {
238
376
  if (axios.isAxiosError(err) &&
239
377
  err.response &&
240
378
  err.response.status < 500) {
241
- // Any 4xx → token is rejected (invalid, expired, or forbidden)
379
+ const status = err.response.status;
380
+ if (status === 429) {
381
+ return {
382
+ ok: false,
383
+ hard: true,
384
+ message: `${providerCfg.providerName} rejected the request — rate limit or quota exceeded.`,
385
+ hint: providerCfg.quotaHint,
386
+ };
387
+ }
388
+ if (status === 403) {
389
+ return {
390
+ ok: false,
391
+ hard: true,
392
+ message: `${providerCfg.providerName} credential is forbidden (account suspended or payment required).`,
393
+ hint: providerCfg.invalidHint,
394
+ };
395
+ }
396
+ // 401 and any other 4xx → credential is rejected (invalid or expired)
242
397
  return {
243
398
  ok: false,
244
399
  hard: true,
245
- message: 'Anthropic OAuth token is invalid or expired.',
246
- hint: 'Generate a new OAuth token at https://claude.ai/settings (requires a paid Claude.ai plan — Pro or Max).',
400
+ message: `${providerCfg.providerName} credential is invalid or expired.`,
401
+ hint: providerCfg.invalidHint,
247
402
  };
248
403
  }
249
404
  // Network error / timeout / 5xx — non-blocking warning
250
405
  return {
251
406
  ok: false,
252
407
  hard: false,
253
- message: 'Could not reach the Anthropic API to verify your OAuth token (network issue).',
254
- hint: 'Proceeding anyway — the server will validate the token on startup.',
408
+ message: `Could not reach the ${providerCfg.providerName} API to verify your credential (network issue).`,
409
+ hint: 'Proceeding anyway — the server will validate the credential on startup.',
255
410
  };
256
411
  });
257
412
  // --- GitHub ---
@@ -285,22 +440,22 @@ async function validateLaunchCredentials(anthropicApiKey, githubToken) {
285
440
  message: 'No GitHub token found.',
286
441
  hint: "Run 'hula login' to authenticate with GitHub first.",
287
442
  });
288
- const [anthropicResult, githubResult] = await Promise.allSettled([
289
- anthropicPromise,
443
+ const [providerResult, githubResult] = await Promise.allSettled([
444
+ providerPromise,
290
445
  githubPromise,
291
446
  ]);
292
447
  let hasHardFailure = false;
293
- // Print Anthropic result
294
- const result = anthropicResult.status === 'fulfilled'
295
- ? anthropicResult.value
448
+ // Print LLM provider result
449
+ const result = providerResult.status === 'fulfilled'
450
+ ? providerResult.value
296
451
  : {
297
452
  ok: false,
298
453
  hard: false,
299
- message: 'Unexpected error during Anthropic validation.',
454
+ message: `Unexpected error during ${providerCfg.providerName} validation.`,
300
455
  hint: 'Proceeding anyway.',
301
456
  };
302
457
  if (result.ok) {
303
- console.log(` ${chalk.green('✓')} Anthropic OAuth token is valid — Claude Code is ready to go 🤖`);
458
+ console.log(` ${chalk.green('✓')} ${providerCfg.providerName} credential is valid — ${providerCfg.readyLabel}`);
304
459
  }
305
460
  else if (result.hard) {
306
461
  console.error(` ${chalk.red('✗')} ${result.message}`);
@@ -344,7 +499,7 @@ function printFreeTierError() {
344
499
  logger.info(' • Run /hula-create, which will assign the issue to Copilot on GitHub.');
345
500
  logger.info(' • Go to https://www.hublaunch.site and upgrade your subscription to Pro.');
346
501
  logger.blank();
347
- logger.info('The Pro tier allows launching as many plans as you like, subject to the constraints of your own Anthropic subscription.');
502
+ logger.info('The Pro tier allows launching as many plans as you like, subject to the constraints of your own LLM provider (Claude, OpenAI, or OpenRouter) subscription.');
348
503
  }
349
504
  /**
350
505
  * Checks whether the user is on the Pro tier.
@@ -352,7 +507,11 @@ function printFreeTierError() {
352
507
  * If the config records 'free', calls the API and updates the config on success.
353
508
  * Returns false (and prints the error) when the user is not on Pro.
354
509
  */
355
- async function verifyProTier(config, serverUrl, apiKey) {
510
+ async function verifyProTier(config, serverUrl, apiKey,
511
+ // Repo root whose config file records the verified tier. Group mode passes
512
+ // the member repo's path — process.cwd() there is the parent folder, which
513
+ // has no .hublaunch and would silently drop the persistence every run.
514
+ repoRoot = process.cwd()) {
356
515
  if (config.usageTier === 'pro') {
357
516
  return true;
358
517
  }
@@ -363,7 +522,7 @@ async function verifyProTier(config, serverUrl, apiKey) {
363
522
  });
364
523
  const plan = response.data?.plan;
365
524
  if (plan === 'pro') {
366
- const saved = updateUsageTierInConfig(process.cwd(), 'pro');
525
+ const saved = updateUsageTierInConfig(repoRoot, 'pro');
367
526
  if (saved) {
368
527
  logger.success('Subscription verified as Pro — config updated for future launches.');
369
528
  }
@@ -390,73 +549,210 @@ async function verifyProTier(config, serverUrl, apiKey) {
390
549
  return true;
391
550
  }
392
551
  }
552
+ /**
553
+ * Assemble the `POST /api/v1/ralph-run` request body from already-resolved
554
+ * inputs. Fields are inserted in a fixed order and only when present, so a
555
+ * launch with no group flag produces a body byte-identical to the pre-fan-out
556
+ * CLI (verified by unit test — AC5).
557
+ */
558
+ export function buildRalphRunRequestBody(input) {
559
+ const body = {
560
+ issueName: input.issueName,
561
+ planPath: input.planPath,
562
+ repositoryId: input.repositoryId,
563
+ };
564
+ if (input.duration !== undefined) {
565
+ body.duration = input.duration;
566
+ }
567
+ // Keep 'ralphPath' property name for server API compatibility
568
+ // even though the CLI option is now '--launch'
569
+ if (input.launchPath) {
570
+ body.ralphPath = input.launchPath;
571
+ }
572
+ // Handle worktree: commander sets it to false for --no-worktree
573
+ if (input.worktree === false) {
574
+ body.worktree = false;
575
+ }
576
+ else if (typeof input.worktree === 'string') {
577
+ body.worktree = input.worktree;
578
+ }
579
+ if (input.resume !== undefined) {
580
+ body.resume = input.resume;
581
+ }
582
+ if (input.fix) {
583
+ body.fix = input.fix;
584
+ }
585
+ if (input.regression) {
586
+ body.regression = true;
587
+ }
588
+ // Maps to RalphRunRequest.steps (hula-server PR #442).
589
+ if (input.steps) {
590
+ body.steps = input.steps;
591
+ }
592
+ // Maps to RalphRunRequest.test (hula-server PR #367).
593
+ if (input.test) {
594
+ body.test = true;
595
+ }
596
+ // Maps to RalphRunRequest.conflictMode (hula-server PR #391). Only added for
597
+ // 'killAndRelaunch' — the server default 'reject' is expressed by omitting.
598
+ if (input.conflictMode) {
599
+ body.conflictMode = input.conflictMode;
600
+ }
601
+ // Maps to RalphRunRequest.groupId (hula-server PR #505): the shared feature-
602
+ // group label correlating the N single-repo launches of a `--folder` fan-out.
603
+ // Omitted entirely for single-repo launches — the server default (ungrouped)
604
+ // is expressed by omitting the key, keeping non-group bodies byte-identical
605
+ // (same omit-when-absent pattern as `test`/`conflictMode` above).
606
+ if (input.groupId) {
607
+ body.groupId = input.groupId;
608
+ }
609
+ if (input.verbose) {
610
+ body.verbose = true;
611
+ }
612
+ if (input.handoff) {
613
+ body.handoff = input.handoff;
614
+ }
615
+ // Maps to RalphRunRequest.clientSessionId (hula-server PR #419).
616
+ if (input.clientSessionId) {
617
+ body.clientSessionId = input.clientSessionId;
618
+ }
619
+ body.provider = {
620
+ type: input.provider.type,
621
+ authToken: input.provider.authToken,
622
+ };
623
+ if (input.containerResources) {
624
+ body.containerResources = input.containerResources;
625
+ }
626
+ if (input.updateNotificationUrl) {
627
+ body.updateNotificationUrl = input.updateNotificationUrl;
628
+ }
629
+ if (input.updateNotificationNameTag) {
630
+ body.updateNotificationNameTag = input.updateNotificationNameTag;
631
+ }
632
+ if (input.githubToken) {
633
+ body.githubToken = input.githubToken;
634
+ }
635
+ if (input.envVars) {
636
+ body.envVars = input.envVars;
637
+ }
638
+ return body;
639
+ }
640
+ /**
641
+ * Single-repo launch entry point. Runs the plan-sync/upload check, then defers
642
+ * the body-build + POST to {@link launchSingleRepo} in single mode (keeps the
643
+ * interactive 409 resolver and process-exit-on-error behavior).
644
+ */
393
645
  async function executeLaunch(config, issueName, planPath, options,
394
646
  // Explicit conflict mode override. Set when re-invoking after the interactive
395
647
  // 409 resolver picks "kill and relaunch" — it both forces the server to
396
648
  // cancel+relaunch and (being non-undefined) suppresses re-entering the prompt.
397
649
  conflictModeOverride) {
398
650
  logger.section('Launch');
651
+ const result = await launchSingleRepo(config, process.cwd(), issueName, planPath, options, {
652
+ mode: 'single',
653
+ interactive409: true,
654
+ conflictModeOverride,
655
+ groupId: options.groupId,
656
+ });
657
+ // In single mode every fatal error already called process.exit inside
658
+ // launchSingleRepo (or the interactive 409 resolver handled the flow); a
659
+ // returned failure that reached here still exits non-zero for safety.
660
+ if (!result.ok) {
661
+ process.exit(1);
662
+ }
663
+ }
664
+ /**
665
+ * Body-build + POST core of a launch, reused by both the single-repo path and
666
+ * the `--folder` fan-out. In single mode fatal errors call `process.exit` and a
667
+ * reject-409 opens the interactive resolver; in group mode fatal errors are
668
+ * returned as a failed {@link RepoLaunchResult} so the fan-out can continue.
669
+ *
670
+ * @param repoConfig - The repository's loaded config.
671
+ * @param repoRoot - Absolute path to the repository root (cwd for git/env/.env).
672
+ * @param issueName - Tracking name for the launch.
673
+ * @param planPath - Plan path (repo-relative or absolute).
674
+ * @param options - Parsed CLI options.
675
+ * @param ctx - Mode + shared-once context (see {@link SingleRepoLaunchContext}).
676
+ */
677
+ async function launchSingleRepo(repoConfig, repoRoot, issueName, planPath, options, ctx) {
678
+ const isGroup = ctx.mode === 'group';
399
679
  // Effective conflict mode: an explicit override (from the 409 resolver) or the
400
680
  // --kill-and-relaunch flag. Undefined = default 'reject' (byte-identical body).
401
- const conflictMode = deriveConflictMode(options, conflictModeOverride);
681
+ const conflictMode = deriveConflictMode(options, ctx.conflictModeOverride);
402
682
  // Check if plan is synced to origin/main
403
- const planSyncStatus = await checkPlanSyncStatus(config, planPath);
683
+ const planSyncStatus = await checkPlanSyncStatus(repoConfig, planPath, repoRoot);
404
684
  if (!planSyncStatus.synced) {
405
685
  if (planSyncStatus.location === 'not-found') {
406
686
  logger.error('Plan not found locally or on origin/main');
407
687
  logger.info('Make sure you created the plan with /hula-plan');
688
+ if (isGroup)
689
+ return { ok: false, message: 'Plan not found locally or on origin/main' };
408
690
  process.exit(1);
409
691
  }
410
692
  // Plan exists locally but not on origin/main - auto-upload
411
693
  logger.info('Plan found locally, pushing to origin/main...');
412
694
  try {
413
- await executeUpload(config, planPath);
695
+ await executeUpload(repoConfig, planPath, repoRoot);
414
696
  logger.success('Plan synced to origin/main');
415
697
  logger.blank();
416
698
  }
417
699
  catch (uploadError) {
418
- logger.error('Failed to upload plan:');
419
- logger.error(uploadError instanceof Error
700
+ const msg = uploadError instanceof Error
420
701
  ? uploadError.message
421
- : String(uploadError));
702
+ : String(uploadError);
703
+ logger.error('Failed to upload plan:');
704
+ logger.error(msg);
705
+ if (isGroup)
706
+ return { ok: false, message: `Failed to upload plan: ${msg}` };
422
707
  process.exit(1);
423
708
  }
424
709
  }
425
710
  // Get server URL and API key
426
- const serverUrl = options.url || config.hulaProjectUrl || HULA_PROJECT_URL;
427
- const apiKey = options.apiKey || config.hulaApiKey || process.env.HULA_API_KEY;
711
+ const serverUrl = options.url || repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
712
+ const apiKey = options.apiKey || repoConfig.hulaApiKey || process.env.HULA_API_KEY;
428
713
  if (!apiKey) {
429
714
  logger.error('No API key configured.');
430
715
  logger.info('Set hulaApiKey in your config or use --api-key option.');
431
716
  logger.info('You can also set HULA_API_KEY environment variable.');
432
717
  logger.info("Run 'hula login' to authenticate with hula-project.");
718
+ if (isGroup)
719
+ return { ok: false, message: 'No API key configured' };
433
720
  process.exit(1);
434
721
  }
435
- // Verify the user is on the Pro tier before proceeding
436
- const isProUser = await verifyProTier(config, serverUrl, apiKey);
437
- if (!isProUser) {
438
- process.exit(1);
722
+ // Verify the user is on the Pro tier before proceeding (skipped in group mode
723
+ // where the check ran once against the first repo's key).
724
+ if (!ctx.proTierVerified) {
725
+ const isProUser = await verifyProTier(repoConfig, serverUrl, apiKey);
726
+ if (!isProUser) {
727
+ if (isGroup)
728
+ return { ok: false, message: 'Not on the Pro tier' };
729
+ process.exit(1);
730
+ }
439
731
  }
440
- // Get project ID - from config or detect from git
441
- const repositoryId = await resolveProject(config);
732
+ // Get project ID - from config or detect from git (repo cwd for the fallback)
733
+ const repositoryId = ctx.repositoryId ?? (await resolveProject(repoConfig, undefined, repoRoot));
442
734
  // Validate fix requires resume
443
735
  if (options.fix && options.resume === undefined) {
444
736
  logger.error('--fix requires --resume to be set');
737
+ if (isGroup)
738
+ return { ok: false, message: '--fix requires --resume to be set' };
445
739
  process.exit(1);
446
740
  }
447
741
  // Validate resume range
448
742
  if (options.resume !== undefined &&
449
743
  (options.resume < 1 || options.resume > 9)) {
450
744
  logger.error('--resume must be between 1 and 9');
745
+ if (isGroup)
746
+ return { ok: false, message: '--resume must be between 1 and 9' };
451
747
  process.exit(1);
452
748
  }
453
749
  // Build the API endpoint
454
750
  const endpoint = `${serverUrl.replace(/\/$/, '')}/api/v1/ralph-run`;
455
751
  const updateNotificationUrl = options.updateNotificationUrl ||
456
- config.updateNotificationUrl ||
752
+ repoConfig.updateNotificationUrl ||
457
753
  process.env.HULA_UPDATE_NOTIFICATION_URL;
458
754
  const updateNotificationNameTag = options.updateNotificationNameTag ||
459
- config.updateNotificationNameTag ||
755
+ repoConfig.updateNotificationNameTag ||
460
756
  process.env.HULA_UPDATE_NOTIFICATION_NAME_TAG;
461
757
  logger.info(`Triggering launch job...`);
462
758
  logger.log(` Issue Name: ${issueName}`);
@@ -483,106 +779,45 @@ conflictModeOverride) {
483
779
  logger.log(` Test Mode: yes (mock Claude)`);
484
780
  if (options.handoff)
485
781
  logger.log(` Handoff To: ${options.handoff}`);
782
+ if (ctx.groupId)
783
+ logger.log(` Group: ${ctx.groupId}`);
486
784
  if (updateNotificationUrl)
487
785
  logger.log(` Notify on completion: enabled`);
488
786
  if (updateNotificationNameTag)
489
787
  logger.log(` Notify tag: ${updateNotificationNameTag}`);
490
- if (config.envVars) {
491
- if (config.envVars === 'all') {
788
+ if (repoConfig.envVars) {
789
+ if (repoConfig.envVars === 'all') {
492
790
  logger.log(` Environment Variables: all (from .env)`);
493
791
  }
494
- else if (config.envVars.length > 0) {
495
- logger.log(` Environment Variables: ${config.envVars.length} configured`);
792
+ else if (repoConfig.envVars.length > 0) {
793
+ logger.log(` Environment Variables: ${repoConfig.envVars.length} configured`);
496
794
  }
497
795
  }
498
796
  logger.blank();
499
- // Build request body
500
- const requestBody = {
501
- issueName,
502
- planPath,
503
- repositoryId,
504
- };
505
- if (options.duration !== undefined) {
506
- requestBody.duration = options.duration;
507
- }
508
- // Keep 'ralphPath' property name for server API compatibility
509
- // even though the CLI option is now '--launch'
510
- if (options.launch) {
511
- requestBody.ralphPath = options.launch;
512
- }
513
- // Handle worktree: commander sets it to false for --no-worktree
514
- if (options.worktree === false) {
515
- requestBody.worktree = false;
516
- }
517
- else if (typeof options.worktree === 'string') {
518
- requestBody.worktree = options.worktree;
519
- }
520
- if (options.resume !== undefined) {
521
- requestBody.resume = options.resume;
522
- }
523
- if (options.fix) {
524
- requestBody.fix = options.fix;
525
- }
526
- if (options.regression) {
527
- requestBody.regression = true;
528
- }
529
- // Maps to RalphRunRequest.steps (hula-server PR #442): per-pipeline-step model,
530
- // iteration-cap, and skip overrides from the config-file `steps` block plus the
531
- // --skip-regression flag. resolveSteps() re-runs the same validation the action
532
- // handler already passed (pure function), and returns `undefined` when there's
533
- // nothing to send — omitted entirely then, keeping non-steps request bodies
534
- // byte-identical (same pattern as `test`/`conflictMode` above).
535
- const stepsResult = resolveSteps(config.steps, options.skipRegression, options.regression);
536
- if (stepsResult.steps) {
537
- requestBody.steps = stepsResult.steps;
538
- }
539
- // Maps to RalphRunRequest.test (hula-server PR #367): when true, the server
540
- // runs the full production pipeline but swaps the real Claude CLI for a mock
541
- // executable — a fast E2E run that still creates a real PR. Omitted entirely
542
- // when --test is absent, keeping non-test request bodies byte-identical.
543
- if (options.test) {
544
- requestBody.test = true;
545
- }
546
- // Maps to RalphRunRequest.conflictMode (hula-server PR #391). Only added for
547
- // 'killAndRelaunch' — the server default 'reject' is expressed by omitting the
548
- // key entirely, keeping the default-launch request body byte-identical to
549
- // before this change (same pattern as `test` above).
550
- if (conflictMode) {
551
- requestBody.conflictMode = conflictMode;
552
- }
553
- if (options.verbose) {
554
- requestBody.verbose = true;
555
- }
556
- if (options.handoff) {
557
- requestBody.handoff = options.handoff;
558
- }
559
- // Maps to RalphRunRequest.clientSessionId (hula-server PR #419): the Claude
560
- // Code session id of the chat session that ran /hula-launch, captured by the
561
- // hula-session-hook moments before this CLI runs. Provenance for client
562
- // tooling (/hula-verify, /hula-info). Omitted entirely when no hook capture
563
- // exists (plain-terminal launches, CI), keeping those request bodies
564
- // byte-identical — same pattern as `test` above.
797
+ // Maps to RalphRunRequest.steps (hula-server PR #442). resolveSteps() re-runs
798
+ // the same validation the action handler already passed (pure function), and
799
+ // returns `undefined` when there's nothing to send.
800
+ const stepsResult = resolveSteps(repoConfig.steps, options.skipRegression, options.regression);
801
+ // Maps to RalphRunRequest.clientSessionId (hula-server PR #419).
565
802
  const clientSessionId = readClientSessionId(issueName);
566
- if (clientSessionId) {
567
- requestBody.clientSessionId = clientSessionId;
568
- }
569
- // Resolve ephemeral credentials through the shared resolver — the single
570
- // source of truth for precedence/validation, also used by `execute`.
803
+ // Resolve provider credentials through the shared resolver — the single
804
+ // source of truth for precedence/validation, also used by `schedule`.
571
805
  // Collect ALL errors before exiting (preserves prior launch behavior).
572
- const resolved = resolveEphemeralCredentials(options, config);
573
- let anthropicApiKey = resolved.anthropicApiKey;
806
+ const resolved = resolveProviderCredentials(options, repoConfig);
807
+ const providerType = resolved.providerType;
808
+ let authToken = resolved.authToken;
574
809
  const credentialErrors = resolved.errors;
575
810
  if (credentialErrors.length > 0) {
576
811
  // Interactive terminal + the ONLY problem is a totally-absent token:
577
812
  // collect it inline rather than failing, then persist it for next time.
578
- // Any other case (wrong-prefix, non-TTY, multiple errors) keeps today's
813
+ // Any other case (wrong-format, non-TTY, multiple errors) keeps today's
579
814
  // fail-fast behavior — identical to what `hula schedule` does.
580
- if (resolved.missingAnthropicKey &&
815
+ if (resolved.missingToken &&
581
816
  credentialErrors.length === 1 &&
582
817
  process.stdout.isTTY) {
583
818
  let pasted;
584
819
  try {
585
- pasted = (await input('Paste your Anthropic OAuth token (sk-ant-oat01-…), or press Enter to cancel:')).trim();
820
+ pasted = (await input(`Paste your ${providerType} credential, or press Enter to cancel:`)).trim();
586
821
  }
587
822
  catch (error) {
588
823
  // Treat an explicit cancel (Ctrl-C / escape) exactly like empty input.
@@ -596,81 +831,90 @@ conflictModeOverride) {
596
831
  if (!pasted) {
597
832
  // Empty / cancelled → the original resolver error, then exit.
598
833
  credentialErrors.forEach((msg) => console.error(chalk.red(msg)));
834
+ if (isGroup)
835
+ return { ok: false, message: credentialErrors.join('; ') };
599
836
  process.exit(1);
600
837
  }
601
- if (!pasted.startsWith('sk-ant-oat')) {
602
- // Wrong prefix → the existing wrong-prefix error text. One attempt only.
603
- console.error(chalk.red('Anthropic credential must be an OAuth token (starts with sk-ant-oat01-…). Standard API keys (sk-ant-api03-…) are not supported — the container requires CLAUDE_CODE_OAUTH_TOKEN.'));
838
+ const formatError = validateTokenFormat(providerType, pasted);
839
+ if (formatError) {
840
+ // Wrong format for this provider the per-provider error. One attempt.
841
+ console.error(chalk.red(formatError));
842
+ if (isGroup)
843
+ return { ok: false, message: formatError };
604
844
  process.exit(1);
605
845
  }
606
846
  // Valid: use it for this launch and persist it to the config file. A
607
847
  // persistence failure must NOT abort a valid launch — warn and continue
608
- // with the in-memory key.
609
- anthropicApiKey = pasted;
848
+ // with the in-memory key. In group mode the token is written to the
849
+ // member repo's own config file (repoRoot); in single mode to the git root.
850
+ authToken = pasted;
610
851
  try {
611
- const gitRoot = await findGitRoot();
612
- const configFilePath = path.join(gitRoot, '.hublaunch', 'hublaunch.config.js');
852
+ const configDir = isGroup ? repoRoot : await findGitRoot();
853
+ const configFilePath = path.join(configDir, '.hublaunch', 'hublaunch.config.js');
613
854
  const original = readFileSync(configFilePath, 'utf-8');
614
- const updated = upsertAnthropicApiKey(original, pasted);
855
+ const updated = upsertProviderApiKey(original, providerType, pasted);
615
856
  writeFileSync(configFilePath, updated, { mode: 0o600 });
616
- logger.success('anthropicApiKey saved to .hublaunch/hublaunch.config.js');
857
+ logger.success('provider credential saved to .hublaunch/hublaunch.config.js');
617
858
  }
618
859
  catch {
619
- logger.warning("⚠️ Couldn't save the token to .hublaunch/hublaunch.config.js — continuing this launch with it in memory. Add it manually to avoid re-entering.");
860
+ logger.warning("⚠️ Couldn't save the credential to .hublaunch/hublaunch.config.js — continuing this launch with it in memory. Add it manually to avoid re-entering.");
620
861
  }
621
862
  }
622
863
  else {
623
864
  credentialErrors.forEach((msg) => console.error(chalk.red(msg)));
865
+ if (isGroup)
866
+ return { ok: false, message: credentialErrors.join('; ') };
624
867
  process.exit(1);
625
868
  }
626
869
  }
627
- requestBody.anthropicApiKey = anthropicApiKey;
628
- const cpuRaw = options.containerCpu ?? config.containerResources?.cpu;
629
- const memRaw = options.containerMemory ?? config.containerResources?.memory;
630
- const diskRaw = options.containerDisk ?? config.containerResources?.disk;
870
+ // Resolve container resources (validate ranges, mode-aware failure).
871
+ const cpuRaw = options.containerCpu ?? repoConfig.containerResources?.cpu;
872
+ const memRaw = options.containerMemory ?? repoConfig.containerResources?.memory;
873
+ const diskRaw = options.containerDisk ?? repoConfig.containerResources?.disk;
874
+ let containerResources;
631
875
  if (cpuRaw !== undefined || memRaw !== undefined || diskRaw !== undefined) {
632
876
  const cpu = cpuRaw ?? DEFAULT_CONTAINER_RESOURCES.cpu;
633
877
  const memory = memRaw ?? DEFAULT_CONTAINER_RESOURCES.memory;
634
878
  const disk = diskRaw ?? DEFAULT_CONTAINER_RESOURCES.disk;
635
879
  if (!Number.isInteger(cpu) || cpu < 1 || cpu > 32) {
636
880
  logger.error('containerResources.cpu must be an integer between 1 and 32');
881
+ if (isGroup)
882
+ return { ok: false, message: 'containerResources.cpu must be an integer between 1 and 32' };
637
883
  process.exit(1);
638
884
  }
639
885
  if (!Number.isInteger(memory) || memory < 1 || memory > 128) {
640
886
  logger.error('containerResources.memory must be an integer between 1 and 128 GiB');
887
+ if (isGroup)
888
+ return { ok: false, message: 'containerResources.memory must be an integer between 1 and 128 GiB' };
641
889
  process.exit(1);
642
890
  }
643
891
  if (!Number.isInteger(disk) || disk < 5 || disk > 200) {
644
892
  logger.error('containerResources.disk must be an integer between 5 and 200 GiB');
893
+ if (isGroup)
894
+ return { ok: false, message: 'containerResources.disk must be an integer between 5 and 200 GiB' };
645
895
  process.exit(1);
646
896
  }
647
- requestBody.containerResources = { cpu, memory, disk };
897
+ containerResources = { cpu, memory, disk };
648
898
  logger.log(` Container Resources: cpu=${cpu} memory=${memory}GiB disk=${disk}GiB`);
649
899
  }
650
- if (updateNotificationUrl) {
651
- requestBody.updateNotificationUrl = updateNotificationUrl;
652
- }
653
- if (updateNotificationNameTag) {
654
- requestBody.updateNotificationNameTag = updateNotificationNameTag;
655
- }
656
- const tokenData = await loadGitHubToken();
657
- const githubToken = tokenData?.access_token || process.env.GITHUB_TOKEN;
658
- if (githubToken) {
659
- requestBody.githubToken = githubToken;
660
- }
661
- // Collect environment variables if configured. Read from the project's .env,
900
+ // Resolve the GitHub token: shared (group mode) or loaded (single mode).
901
+ const githubToken = ctx.skipTokenLoad
902
+ ? ctx.githubToken
903
+ : (await loadGitHubToken())?.access_token || process.env.GITHUB_TOKEN;
904
+ // Collect environment variables if configured. Read from the repo's .env,
662
905
  // validate against the reserved blocklist, and confirm presence — failing fast
663
906
  // with a clear message before the job is submitted.
664
- if (config.envVars) {
907
+ let envVars;
908
+ if (repoConfig.envVars) {
665
909
  logger.info('Collecting environment variables for container...');
666
910
  try {
667
911
  // Expand "all" to the actual list of non-reserved variables from .env.
668
- let varsToCollect = Array.isArray(config.envVars)
669
- ? config.envVars
912
+ let varsToCollect = Array.isArray(repoConfig.envVars)
913
+ ? repoConfig.envVars
670
914
  : [];
671
- if (config.envVars === 'all') {
915
+ if (repoConfig.envVars === 'all') {
672
916
  // Read .env and get all non-reserved variables
673
- const envPath = join(process.cwd(), '.env');
917
+ const envPath = join(repoRoot, '.env');
674
918
  if (!existsSync(envPath)) {
675
919
  throw new Error(`Cannot read environment variables: .env file not found at ${envPath}\n` +
676
920
  `Config specifies envVars: "all", but .env does not exist`);
@@ -686,22 +930,53 @@ conflictModeOverride) {
686
930
  // Validate and collect
687
931
  if (varsToCollect.length > 0) {
688
932
  validateNoReservedVars(varsToCollect);
689
- const envVars = readAndFilterEnvVars(process.cwd(), varsToCollect);
690
- if (Object.keys(envVars).length > 0) {
691
- requestBody.envVars = envVars;
692
- logger.log(` ✓ ${Object.keys(envVars).length} variable(s) collected`);
933
+ const collected = readAndFilterEnvVars(repoRoot, varsToCollect);
934
+ if (Object.keys(collected).length > 0) {
935
+ envVars = collected;
936
+ logger.log(` ✓ ${Object.keys(collected).length} variable(s) collected`);
693
937
  }
694
938
  }
695
939
  }
696
940
  catch (error) {
941
+ const msg = error instanceof Error ? error.message : String(error);
697
942
  logger.error('Failed to collect environment variables:');
698
- logger.error(error instanceof Error ? error.message : String(error));
943
+ logger.error(msg);
944
+ if (isGroup)
945
+ return { ok: false, message: `Failed to collect environment variables: ${msg}` };
699
946
  process.exit(1);
700
947
  }
701
948
  }
702
- // Pre-flight credential validation
703
- // anthropicApiKey is guaranteed non-null here missing key exits above
704
- await validateLaunchCredentials(anthropicApiKey, githubToken);
949
+ // Pre-flight credential validation (skipped in group mode where each distinct
950
+ // provider credential was validated once before the fan-out loop).
951
+ // authToken is guaranteed non-empty here — missing key exits above.
952
+ if (!ctx.credentialsValidated) {
953
+ await validateProviderCredentials(providerType, authToken, githubToken);
954
+ }
955
+ // Assemble the request body from the resolved values.
956
+ const requestBody = buildRalphRunRequestBody({
957
+ issueName,
958
+ planPath,
959
+ repositoryId,
960
+ duration: options.duration,
961
+ launchPath: options.launch,
962
+ worktree: options.worktree,
963
+ resume: options.resume,
964
+ fix: options.fix,
965
+ regression: options.regression,
966
+ steps: stepsResult.steps,
967
+ test: options.test,
968
+ conflictMode,
969
+ groupId: ctx.groupId,
970
+ verbose: options.verbose,
971
+ handoff: options.handoff,
972
+ clientSessionId: clientSessionId ?? undefined,
973
+ provider: { type: providerType, authToken },
974
+ containerResources,
975
+ updateNotificationUrl,
976
+ updateNotificationNameTag,
977
+ githubToken,
978
+ envVars,
979
+ });
705
980
  try {
706
981
  const response = await axios.post(endpoint, requestBody, {
707
982
  headers: {
@@ -731,30 +1006,505 @@ conflictModeOverride) {
731
1006
  }
732
1007
  logger.blank();
733
1008
  // Clean up local plan file — it's now on origin/main and submitted to server
734
- const planService = new PlanService(config.planPath);
735
- planService.deletePlan(planPath);
736
- }
737
- else {
738
- logger.error(response.data.error || 'Unknown error occurred');
739
- process.exit(1);
1009
+ const planService = new PlanService(repoConfig.planPath);
1010
+ planService.deletePlan(isAbsolute(planPath) ? planPath : join(repoRoot, planPath));
1011
+ return { ok: true, planId: response.data.planId };
740
1012
  }
1013
+ logger.error(response.data.error || 'Unknown error occurred');
1014
+ if (isGroup)
1015
+ return { ok: false, message: response.data.error || 'Unknown error occurred' };
1016
+ process.exit(1);
741
1017
  }
742
1018
  catch (error) {
743
1019
  // A 409 on the default `reject` path means a task is already running for this
744
- // tracking name. Offer recovery options interactively (or, when scripted,
745
- // print the server message and exit). Skip this whenever a conflictMode is
746
- // already in play — that request cannot produce a reject 409 and must not
747
- // re-enter the prompt (loop guard).
1020
+ // tracking name. Skip this whenever a conflictMode is already in play — that
1021
+ // request cannot produce a reject 409 and must not re-enter the prompt.
748
1022
  if (!conflictMode &&
749
1023
  axios.isAxiosError(error) &&
750
1024
  error.response?.status === 409) {
751
- await handleReject409(error, config, issueName, planPath, options);
752
- return;
1025
+ if (ctx.interactive409) {
1026
+ // Single mode: offer recovery options interactively (or, when scripted,
1027
+ // print the server message and exit). The resolver drives the flow.
1028
+ await handleReject409(error, repoConfig, issueName, planPath, options);
1029
+ return { ok: true };
1030
+ }
1031
+ // Group mode: record the failure with the server message plus a hint to
1032
+ // re-run with --kill-and-relaunch. Never enters the interactive resolver.
1033
+ const data = (error.response?.data ?? {});
1034
+ const serverMessage = data.error || `A task is already running for '${issueName}'.`;
1035
+ return {
1036
+ ok: false,
1037
+ status: 409,
1038
+ message: `${serverMessage} (re-run with --kill-and-relaunch to force)`,
1039
+ };
1040
+ }
1041
+ if (isGroup) {
1042
+ const described = describeLaunchAxiosError(error, serverUrl, issueName, planPath);
1043
+ return { ok: false, status: described.status, message: described.message };
753
1044
  }
754
1045
  handleLaunchAxiosError(error, serverUrl, issueName, planPath);
755
1046
  process.exit(1);
756
1047
  }
757
1048
  }
1049
+ /**
1050
+ * Fan-out launch (`hula launch <issue> --folder <path>`). Discovers every
1051
+ * initialized repo under `<path>`, resolves each repo's plan, confirms the
1052
+ * roster, then launches each repo with its own config/key plus one shared
1053
+ * generated (or `--group-id`-supplied) groupId. Continues past per-repo
1054
+ * failures and prints a retry command carrying `--group-id`.
1055
+ */
1056
+ async function executeGroupLaunch(_config, issueName, options) {
1057
+ const service = new GroupLaunchService();
1058
+ const folder = path.resolve(options.folder);
1059
+ if (!existsSync(folder)) {
1060
+ logger.error(`Folder not found: ${folder}`);
1061
+ process.exit(1);
1062
+ }
1063
+ const { repos, skipped, folderIsRepo } = service.discoverRepos(folder);
1064
+ logger.section('Group Launch');
1065
+ for (const s of skipped) {
1066
+ logger.log(` skipped ${s.name} (${s.reason})`);
1067
+ }
1068
+ if (repos.length === 0) {
1069
+ if (folderIsRepo) {
1070
+ logger.error(`${folder} looks like a repository itself; --folder expects the parent folder that contains your repos`);
1071
+ }
1072
+ else {
1073
+ logger.error(`No initialized repos found directly under ${folder}`);
1074
+ logger.info('Each repo needs a .git entry and a .hublaunch config (run hula init + hula login in it).');
1075
+ }
1076
+ process.exit(1);
1077
+ }
1078
+ if (repos.length === 1) {
1079
+ logger.warning('only one repo found — this launches a single-repo group');
1080
+ }
1081
+ // Resolve or accept the group id (already format-validated in the handler).
1082
+ const groupId = options.groupId ?? service.generateGroupId(issueName);
1083
+ // Validate --plan override repo names and reject path-traversal paths.
1084
+ const overrides = options.plan ?? {};
1085
+ const repoNames = new Set(repos.map((r) => r.name));
1086
+ for (const [name, overridePath] of Object.entries(overrides)) {
1087
+ if (!repoNames.has(name)) {
1088
+ logger.error(`Unknown repo in --plan override: "${name}". Discovered repos: ${[...repoNames].join(', ')}`);
1089
+ process.exit(1);
1090
+ }
1091
+ if (overridePath.includes('..')) {
1092
+ logger.error(`Invalid --plan path for "${name}" (path traversal not allowed): ${overridePath}`);
1093
+ process.exit(1);
1094
+ }
1095
+ }
1096
+ const isTty = Boolean(process.stdout.isTTY);
1097
+ // `--yes` opts a non-TTY caller (the /hula-launch skill runs this CLI as a
1098
+ // piped subprocess) into the same auto-resolve + roster flow a TTY gets,
1099
+ // skipping the interactive confirmation. Without it, non-TTY still requires
1100
+ // explicit --plan coverage (never guess silently in CI).
1101
+ const assumeYes = Boolean(options.yes);
1102
+ const canAutoResolve = isTty || assumeYes;
1103
+ // Load each repo's config INDEPENDENTLY (never the cached singleton), resolve
1104
+ // its project, and resolve its plan (override → auto-resolve, disabled non-TTY
1105
+ // unless --yes).
1106
+ const entries = [];
1107
+ for (const repo of repos) {
1108
+ const repoConfig = await new ConfigLoader(repo.path).load();
1109
+ const project = await resolveProject(repoConfig, undefined, repo.path);
1110
+ let loginError;
1111
+ const hasKey = Boolean(options.apiKey) ||
1112
+ Boolean(repoConfig.hulaApiKey) ||
1113
+ Boolean(process.env.HULA_API_KEY);
1114
+ if (!hasKey) {
1115
+ loginError = `NOT LOGGED IN (run hula login in ${repo.path})`;
1116
+ }
1117
+ let planPath;
1118
+ if (overrides[repo.name]) {
1119
+ planPath = overrides[repo.name];
1120
+ }
1121
+ else if (!canAutoResolve) {
1122
+ // Non-TTY without --yes: never guess — require an explicit --plan override.
1123
+ planPath = null;
1124
+ }
1125
+ else {
1126
+ planPath = service.resolvePlan(repo, repoConfig, issueName);
1127
+ }
1128
+ entries.push({ repo, repoConfig, project, planPath, loginError });
1129
+ }
1130
+ // Duplicate project detection: two members with the same repositoryId would
1131
+ // overwrite each other server-side ("latest launch wins"). Fail before launch.
1132
+ const projectToRepos = new Map();
1133
+ for (const e of entries) {
1134
+ projectToRepos.set(e.project, [
1135
+ ...(projectToRepos.get(e.project) ?? []),
1136
+ e.repo.name,
1137
+ ]);
1138
+ }
1139
+ for (const [project, names] of projectToRepos) {
1140
+ if (names.length > 1) {
1141
+ logger.error(`Duplicate project ${project} across discovered repos: ${names.join(', ')}. ` +
1142
+ `Each member must be a distinct repository.`);
1143
+ process.exit(1);
1144
+ }
1145
+ }
1146
+ // Non-TTY coverage: without --yes, every repo must be covered by a --plan
1147
+ // override (a piped/CI caller must opt in before the CLI guesses plans).
1148
+ if (!canAutoResolve) {
1149
+ const uncovered = entries
1150
+ .filter((e) => !overrides[e.repo.name])
1151
+ .map((e) => e.repo.name);
1152
+ if (uncovered.length > 0) {
1153
+ logger.error(`Non-interactive run requires a --plan <repo>=<path> override for every repo (or pass --yes to auto-resolve). Uncovered: ${uncovered.join(', ')}`);
1154
+ process.exit(1);
1155
+ }
1156
+ }
1157
+ // Print the roster and gate on any repo that can't launch.
1158
+ logger.blank();
1159
+ logger.info(`Feature group: ${groupId}`);
1160
+ let blocked = false;
1161
+ for (const e of entries) {
1162
+ let planLabel;
1163
+ if (e.loginError) {
1164
+ planLabel = e.loginError;
1165
+ blocked = true;
1166
+ }
1167
+ else if (!e.planPath) {
1168
+ planLabel = 'NO PLAN FOUND';
1169
+ blocked = true;
1170
+ }
1171
+ else {
1172
+ planLabel = e.planPath;
1173
+ }
1174
+ logger.log(` ${e.repo.name} → ${planLabel}`);
1175
+ }
1176
+ if (blocked) {
1177
+ logger.blank();
1178
+ logger.error('Cannot launch: every repo needs an API key and a resolvable plan.');
1179
+ logger.info('Supply a plan with --plan <repo>=<path>, run hula login in the repo, or remove it from the folder.');
1180
+ process.exit(1);
1181
+ }
1182
+ // Single interactive confirmation before any network launch (TTY only;
1183
+ // --yes skips it — the roster above stays in the output as the record).
1184
+ if (isTty && !assumeYes) {
1185
+ const { proceed } = await inquirer.prompt([
1186
+ {
1187
+ type: 'confirm',
1188
+ name: 'proceed',
1189
+ message: `Launch these ${entries.length} repos as group ${groupId}?`,
1190
+ default: true,
1191
+ },
1192
+ ]);
1193
+ if (!proceed) {
1194
+ logger.info('Cancelled — no launches sent.');
1195
+ process.exit(0);
1196
+ }
1197
+ }
1198
+ // ── Shared-once work ──────────────────────────────────────────────────
1199
+ // Pro-tier check with the first repo's key.
1200
+ const first = entries[0];
1201
+ const proServerUrl = options.url || first.repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
1202
+ const proApiKey = options.apiKey || first.repoConfig.hulaApiKey || process.env.HULA_API_KEY;
1203
+ if (proApiKey) {
1204
+ const isPro = await verifyProTier(first.repoConfig, proServerUrl, proApiKey, first.repo.path);
1205
+ if (!isPro)
1206
+ process.exit(1);
1207
+ }
1208
+ // GitHub token once (user-level).
1209
+ const tokenData = await loadGitHubToken();
1210
+ const githubToken = tokenData?.access_token || process.env.GITHUB_TOKEN;
1211
+ // Validate each DISTINCT resolvable provider credential once (keyed by
1212
+ // provider type + token, since members may select different providers).
1213
+ const validatedKeys = new Set();
1214
+ for (const e of entries) {
1215
+ const { providerType, authToken } = resolveProviderCredentials(options, e.repoConfig);
1216
+ const dedupKey = `${providerType}:${authToken}`;
1217
+ if (authToken && !validatedKeys.has(dedupKey)) {
1218
+ validatedKeys.add(dedupKey);
1219
+ await validateProviderCredentials(providerType, authToken, githubToken);
1220
+ }
1221
+ }
1222
+ // ── Sequential fan-out (continue on failure) ──────────────────────────
1223
+ const results = [];
1224
+ const successfulRepos = [];
1225
+ for (const e of entries) {
1226
+ logger.section(`Launch: ${e.repo.name}`);
1227
+ const result = await launchSingleRepo(e.repoConfig, e.repo.path, issueName, e.planPath, options, {
1228
+ mode: 'group',
1229
+ interactive409: false,
1230
+ groupId,
1231
+ repositoryId: e.project,
1232
+ proTierVerified: true,
1233
+ credentialsValidated: true,
1234
+ skipTokenLoad: true,
1235
+ githubToken,
1236
+ });
1237
+ results.push({ repo: e.repo, project: e.project, planPath: e.planPath, result });
1238
+ if (result.ok) {
1239
+ successfulRepos.push(e.repo);
1240
+ }
1241
+ else {
1242
+ logger.error(`✗ ${e.repo.name}: ${result.message ?? 'launch failed'}`);
1243
+ }
1244
+ }
1245
+ // Record the group locally in every successful member (best-effort).
1246
+ if (successfulRepos.length > 0) {
1247
+ const recordRepos = successfulRepos.map((r) => {
1248
+ const e = entries.find((x) => x.repo.path === r.path);
1249
+ return { name: r.name, path: r.path, project: e.project };
1250
+ });
1251
+ const record = {
1252
+ groupId,
1253
+ issueName,
1254
+ createdAt: new Date().toISOString(),
1255
+ folder,
1256
+ repos: recordRepos,
1257
+ };
1258
+ service.recordGroup(successfulRepos, record);
1259
+ }
1260
+ // ── Summary ───────────────────────────────────────────────────────────
1261
+ logger.section('Group Launch Summary');
1262
+ logger.log(` Group: ${groupId}`);
1263
+ for (const r of results) {
1264
+ if (r.result.ok) {
1265
+ logger.log(` ✓ ${r.repo.name}${r.result.planId ? ` (plan ${r.result.planId})` : ''}`);
1266
+ }
1267
+ else {
1268
+ logger.log(` ✗ ${r.repo.name} ${r.result.message ?? 'launch failed'}`);
1269
+ }
1270
+ }
1271
+ const failures = results.filter((r) => !r.result.ok);
1272
+ if (failures.length > 0) {
1273
+ logger.blank();
1274
+ logger.info('Retry the whole group (already-successful members may 409):');
1275
+ logger.info(` hula launch ${issueName} --folder "${options.folder}" --group-id ${groupId}`);
1276
+ logger.info('Or retry a single failed repo surgically:');
1277
+ for (const f of failures) {
1278
+ logger.info(` cd ${f.repo.path} && hula launch ${issueName} ${f.planPath} --group-id ${groupId}`);
1279
+ }
1280
+ process.exit(1);
1281
+ }
1282
+ logger.blank();
1283
+ logger.success(`Launched ${results.length} repos as group ${groupId}`);
1284
+ logger.info(`View combined status: hula launch --show-group ${groupId}`);
1285
+ }
1286
+ /**
1287
+ * Cancel-only fan-out (`hula launch <issue> --folder <path> --kill`). Stops the
1288
+ * in-flight task in every member repo using each repo's own API key. Continues
1289
+ * past per-repo failures and exits non-zero when any cancel failed.
1290
+ */
1291
+ async function executeGroupKill(_config, issueName, options) {
1292
+ const service = new GroupLaunchService();
1293
+ const folder = path.resolve(options.folder);
1294
+ if (!existsSync(folder)) {
1295
+ logger.error(`Folder not found: ${folder}`);
1296
+ process.exit(1);
1297
+ }
1298
+ const { repos, skipped, folderIsRepo } = service.discoverRepos(folder);
1299
+ logger.section('Group Cancel');
1300
+ for (const s of skipped) {
1301
+ logger.log(` skipped ${s.name} (${s.reason})`);
1302
+ }
1303
+ if (repos.length === 0) {
1304
+ if (folderIsRepo) {
1305
+ logger.error(`${folder} looks like a repository itself; --folder expects the parent folder that contains your repos`);
1306
+ }
1307
+ else {
1308
+ logger.error(`No initialized repos found directly under ${folder}`);
1309
+ }
1310
+ process.exit(1);
1311
+ }
1312
+ const entries = [];
1313
+ for (const repo of repos) {
1314
+ const repoConfig = await new ConfigLoader(repo.path).load();
1315
+ const project = await resolveProject(repoConfig, undefined, repo.path);
1316
+ entries.push({ repo, repoConfig, project });
1317
+ }
1318
+ // Shared Pro-tier check with the first repo's key.
1319
+ const first = entries[0];
1320
+ const proServerUrl = options.url || first.repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
1321
+ const proApiKey = options.apiKey || first.repoConfig.hulaApiKey || process.env.HULA_API_KEY;
1322
+ if (proApiKey) {
1323
+ const isPro = await verifyProTier(first.repoConfig, proServerUrl, proApiKey, first.repo.path);
1324
+ if (!isPro)
1325
+ process.exit(1);
1326
+ }
1327
+ const results = [];
1328
+ for (const e of entries) {
1329
+ logger.section(`Cancel: ${e.repo.name}`);
1330
+ const result = await killSingleRepo(e.repoConfig, e.repo.path, issueName, options, {
1331
+ repositoryId: e.project,
1332
+ proTierVerified: true,
1333
+ });
1334
+ results.push({ repo: e.repo, result });
1335
+ if (!result.ok) {
1336
+ logger.error(`✗ ${e.repo.name}: ${result.message ?? 'cancel failed'}`);
1337
+ }
1338
+ }
1339
+ logger.section('Group Cancel Summary');
1340
+ for (const r of results) {
1341
+ logger.log(` ${r.result.ok ? '✓' : '✗'} ${r.repo.name}`);
1342
+ }
1343
+ if (results.some((r) => !r.result.ok)) {
1344
+ process.exit(1);
1345
+ }
1346
+ }
1347
+ /**
1348
+ * Cancel the in-flight task for one repo (per-repo helper for the `--kill
1349
+ * --folder` fan-out). Mirrors {@link executeKill} but returns a
1350
+ * {@link RepoLaunchResult} instead of exiting, and skips the shared Pro check.
1351
+ */
1352
+ async function killSingleRepo(repoConfig, repoRoot, issueName, options, ctx) {
1353
+ const serverUrl = options.url || repoConfig.hulaProjectUrl || HULA_PROJECT_URL;
1354
+ const apiKey = options.apiKey || repoConfig.hulaApiKey || process.env.HULA_API_KEY;
1355
+ if (!apiKey) {
1356
+ return { ok: false, message: 'No API key configured' };
1357
+ }
1358
+ if (!ctx.proTierVerified) {
1359
+ const isPro = await verifyProTier(repoConfig, serverUrl, apiKey);
1360
+ if (!isPro)
1361
+ return { ok: false, message: 'Not on the Pro tier' };
1362
+ }
1363
+ const repositoryId = ctx.repositoryId ?? (await resolveProject(repoConfig, undefined, repoRoot));
1364
+ const endpoint = `${serverUrl.replace(/\/$/, '')}/api/v1/ralph-run`;
1365
+ const requestBody = buildKillRequestBody(issueName, repositoryId);
1366
+ logger.info('Stopping in-flight task...');
1367
+ logger.log(` Issue Name: ${issueName}`);
1368
+ logger.log(` Server: ${serverUrl}`);
1369
+ logger.blank();
1370
+ try {
1371
+ const response = await axios.post(endpoint, requestBody, {
1372
+ headers: {
1373
+ 'Content-Type': 'application/json',
1374
+ Authorization: `Bearer ${apiKey}`,
1375
+ },
1376
+ timeout: 30000,
1377
+ });
1378
+ const data = response.data;
1379
+ if (data.killed) {
1380
+ logger.success(data.message || `Cancelled '${issueName}'`);
1381
+ }
1382
+ else {
1383
+ logger.info(data.message || `No active task to cancel for '${issueName}'`);
1384
+ }
1385
+ return { ok: true };
1386
+ }
1387
+ catch (error) {
1388
+ const described = describeLaunchAxiosError(error, serverUrl, issueName, undefined);
1389
+ logger.error(described.message);
1390
+ return { ok: false, status: described.status, message: described.message };
1391
+ }
1392
+ }
1393
+ /**
1394
+ * Render combined status for a feature group (`hula launch --show-group [id]`).
1395
+ * The id is explicit (string) or resolved from the newest local group record
1396
+ * (bare flag → `true`). Prints a `completed/total` header and one line per plan.
1397
+ */
1398
+ async function showGroupStatus(config, groupIdOrTrue, options) {
1399
+ const serverUrl = options.url || config.hulaProjectUrl || HULA_PROJECT_URL;
1400
+ const apiKey = options.apiKey || config.hulaApiKey || process.env.HULA_API_KEY;
1401
+ if (!serverUrl || !apiKey) {
1402
+ logger.error('Server URL and API key are required.');
1403
+ logger.info("Run 'hula login' to authenticate with hula-project.");
1404
+ process.exit(1);
1405
+ }
1406
+ // Resolve the group id: an explicit value, else the newest local record.
1407
+ let groupId;
1408
+ if (typeof groupIdOrTrue === 'string') {
1409
+ groupId = groupIdOrTrue;
1410
+ }
1411
+ else {
1412
+ const service = new GroupLaunchService();
1413
+ const repoRoot = await findGitRoot();
1414
+ const latest = service.latestRecordedGroupId(repoRoot);
1415
+ if (!latest) {
1416
+ logger.error('No local group record found in this repo. Pass an id: hula launch --show-group <id>');
1417
+ process.exit(1);
1418
+ }
1419
+ groupId = latest;
1420
+ }
1421
+ const idError = validateGroupId(groupId);
1422
+ if (idError) {
1423
+ logger.error(idError);
1424
+ process.exit(1);
1425
+ }
1426
+ const apiClient = new HulaApiClient({
1427
+ baseUrl: serverUrl.replace(/\/$/, ''),
1428
+ apiKey,
1429
+ timeout: 30000,
1430
+ });
1431
+ try {
1432
+ const data = await apiClient.fetchGroupStatus(groupId);
1433
+ logger.section(`Group ${data.groupId}: ${data.completed}/${data.total} completed`);
1434
+ if (data.plans.length === 0) {
1435
+ logger.log(' (no plans yet)');
1436
+ }
1437
+ for (const plan of data.plans) {
1438
+ const taskStatus = plan.latestTask ? plan.latestTask.status : 'no task';
1439
+ const pr = plan.prUrl
1440
+ ? `${plan.prUrl}${plan.prMerged ? ' (merged)' : ''}`
1441
+ : '—';
1442
+ logger.log(` ${plan.project.fullName} ${plan.trackingStatus} task:${taskStatus} PR:${pr} ${plan.updatedAt}`);
1443
+ }
1444
+ logger.blank();
1445
+ }
1446
+ catch (error) {
1447
+ const status = (axios.isAxiosError(error) && error.response?.status) ||
1448
+ (error instanceof ApiError ? error.statusCode : undefined);
1449
+ if (status === 404) {
1450
+ logger.error('Group not found (or you are not a member of any of its projects).');
1451
+ logger.info('The server may not support groups yet (requires hula-server with feature groups).');
1452
+ process.exit(1);
1453
+ }
1454
+ if (error instanceof ApiError) {
1455
+ logger.error(error.message);
1456
+ process.exit(1);
1457
+ }
1458
+ handleApiError(error, serverUrl);
1459
+ process.exit(1);
1460
+ }
1461
+ }
1462
+ /**
1463
+ * Non-logging sibling of {@link handleLaunchAxiosError}: map a launch/kill error
1464
+ * to a `{ status, message }` pair without printing or throwing. Used by the
1465
+ * `--folder` fan-out to CAPTURE a per-repo failure for the end-of-run summary
1466
+ * instead of logging-and-exiting. Message text mirrors the single-repo handler.
1467
+ */
1468
+ function describeLaunchAxiosError(error, serverUrl, issueName, planPath) {
1469
+ if (axios.isAxiosError(error)) {
1470
+ if (error.response) {
1471
+ const status = error.response.status;
1472
+ const data = error.response.data;
1473
+ switch (status) {
1474
+ case 400:
1475
+ return { status, message: `Bad request: ${data.error || 'Invalid parameters'}` };
1476
+ case 401:
1477
+ return { status, message: 'Authentication failed. Your API key may be invalid.' };
1478
+ case 402:
1479
+ return { status, message: 'You cannot run launch because you are on the free tier.' };
1480
+ case 403:
1481
+ return {
1482
+ status,
1483
+ message: data.error || 'Access denied. You may not be authorized for launch.',
1484
+ };
1485
+ case 404:
1486
+ return { status, message: data.error || `Not found: ${planPath ?? issueName}` };
1487
+ case 409:
1488
+ return { status, message: data.error || `Job already running: ${issueName}` };
1489
+ case 500:
1490
+ return { status, message: `Server error: ${data.error || 'Internal server error'}` };
1491
+ default:
1492
+ return {
1493
+ status,
1494
+ message: `Request failed with status ${status}: ${data.error || error.message}`,
1495
+ };
1496
+ }
1497
+ }
1498
+ if (error.code === 'ECONNREFUSED') {
1499
+ return { message: `Cannot connect to server at ${serverUrl}` };
1500
+ }
1501
+ if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
1502
+ return { message: 'Request timed out' };
1503
+ }
1504
+ return { message: `Network error: ${error.message}` };
1505
+ }
1506
+ return { message: error instanceof Error ? error.message : String(error) };
1507
+ }
758
1508
  /**
759
1509
  * Shared axios error handler for the launch / kill request. Surfaces the server
760
1510
  * `data.error` for 400/403/404/409 (PR #391 returns specific, actionable
@@ -1070,8 +1820,8 @@ export function handleApiError(error, serverUrl) {
1070
1820
  /**
1071
1821
  * Check if plan is synced to origin/main
1072
1822
  */
1073
- async function checkPlanSyncStatus(config, planPath) {
1074
- const gitService = new GitService();
1823
+ async function checkPlanSyncStatus(config, planPath, repoRoot) {
1824
+ const gitService = new GitService(repoRoot);
1075
1825
  const planService = new PlanService(config.planPath);
1076
1826
  // Fetch latest from origin
1077
1827
  await gitService.fetch('origin', 'main');
@@ -1088,8 +1838,10 @@ async function checkPlanSyncStatus(config, planPath) {
1088
1838
  }
1089
1839
  // Plan is not on any git ref, but a plan freshly created by /hula-plan lives
1090
1840
  // only in the working tree (never committed). Treat that as needing upload
1091
- // rather than declaring it not found.
1092
- if (existsSync(planPath)) {
1841
+ // rather than declaring it not found. In folder mode the plan path is
1842
+ // repo-relative, so resolve it against the repo root for the disk check.
1843
+ const planFsPath = isAbsolute(planPath) || !repoRoot ? planPath : join(repoRoot, planPath);
1844
+ if (existsSync(planFsPath)) {
1093
1845
  return { synced: false, location: 'local-main' };
1094
1846
  }
1095
1847
  return { synced: false, location: 'not-found' };