@pruddiman/dispatch 1.4.3 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -241,6 +241,41 @@ var init_logger = __esm({
241
241
  }
242
242
  });
243
243
 
244
+ // src/providers/progress.ts
245
+ function sanitizeProgressText(raw, maxLength = 120) {
246
+ const text = raw.replace(ANSI_PATTERN, "").replace(CONTROL_PATTERN, "").replace(/\s+/g, " ").trim();
247
+ if (!text) return "";
248
+ if (text.length <= maxLength) return text;
249
+ if (maxLength <= 1) return maxLength <= 0 ? "" : "\u2026";
250
+ return `${text.slice(0, maxLength - 1).trimEnd()}\u2026`;
251
+ }
252
+ function createProgressReporter(onProgress) {
253
+ let last;
254
+ return {
255
+ emit(raw) {
256
+ if (!onProgress) return;
257
+ const text = sanitizeProgressText(raw ?? "");
258
+ if (!text || text === last) return;
259
+ last = text;
260
+ try {
261
+ onProgress({ text });
262
+ } catch {
263
+ }
264
+ },
265
+ reset() {
266
+ last = void 0;
267
+ }
268
+ };
269
+ }
270
+ var ANSI_PATTERN, CONTROL_PATTERN;
271
+ var init_progress = __esm({
272
+ "src/providers/progress.ts"() {
273
+ "use strict";
274
+ ANSI_PATTERN = /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u009B[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\))/g;
275
+ CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
276
+ }
277
+ });
278
+
244
279
  // src/helpers/guards.ts
245
280
  function hasProperty(value, key) {
246
281
  return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key);
@@ -251,6 +286,52 @@ var init_guards = __esm({
251
286
  }
252
287
  });
253
288
 
289
+ // src/helpers/timeout.ts
290
+ function withTimeout(promise, ms, label) {
291
+ const p = new Promise((resolve5, reject) => {
292
+ let settled = false;
293
+ const timer = setTimeout(() => {
294
+ if (settled) return;
295
+ settled = true;
296
+ reject(new TimeoutError(ms, label));
297
+ }, ms);
298
+ promise.then(
299
+ (value) => {
300
+ if (settled) return;
301
+ settled = true;
302
+ clearTimeout(timer);
303
+ resolve5(value);
304
+ },
305
+ (err) => {
306
+ if (settled) return;
307
+ settled = true;
308
+ clearTimeout(timer);
309
+ reject(err);
310
+ }
311
+ );
312
+ });
313
+ p.catch(() => {
314
+ });
315
+ return p;
316
+ }
317
+ var TimeoutError, DEFAULT_PLAN_TIMEOUT_MIN;
318
+ var init_timeout = __esm({
319
+ "src/helpers/timeout.ts"() {
320
+ "use strict";
321
+ TimeoutError = class extends Error {
322
+ /** Optional label identifying the operation that timed out. */
323
+ label;
324
+ constructor(ms, label) {
325
+ const suffix = label ? ` [${label}]` : "";
326
+ super(`Timed out after ${ms}ms${suffix}`);
327
+ this.name = "TimeoutError";
328
+ this.label = label;
329
+ }
330
+ };
331
+ DEFAULT_PLAN_TIMEOUT_MIN = 30;
332
+ }
333
+ });
334
+
254
335
  // src/providers/opencode.ts
255
336
  import {
256
337
  createOpencode,
@@ -346,9 +427,10 @@ async function boot(opts) {
346
427
  throw err;
347
428
  }
348
429
  },
349
- async prompt(sessionId, text) {
430
+ async prompt(sessionId, text, options) {
350
431
  log.debug(`Sending async prompt to session ${sessionId} (${text.length} chars)...`);
351
432
  let controller;
433
+ const reporter = createProgressReporter(options?.onProgress);
352
434
  try {
353
435
  const { error: promptError } = await client.session.promptAsync({
354
436
  path: { id: sessionId },
@@ -366,29 +448,15 @@ async function boot(opts) {
366
448
  const { stream } = await client.event.subscribe({
367
449
  signal: controller.signal
368
450
  });
369
- for await (const event of stream) {
370
- if (!isSessionEvent(event, sessionId)) continue;
371
- if (event.type === "message.part.updated" && event.properties.part.type === "text") {
372
- const delta = event.properties.delta;
373
- if (delta) {
374
- log.debug(`Streaming text (+${delta.length} chars)...`);
375
- }
376
- continue;
377
- }
378
- if (event.type === "session.error") {
379
- const err = event.properties.error;
380
- throw new Error(
381
- `OpenCode session error: ${err ? JSON.stringify(err) : "unknown error"}`
382
- );
383
- }
384
- if (event.type === "session.idle") {
385
- log.debug("Session went idle, fetching result...");
386
- break;
387
- }
388
- }
451
+ await withTimeout(
452
+ waitForSessionReady(stream, sessionId, reporter),
453
+ SESSION_READY_TIMEOUT_MS,
454
+ "opencode session ready"
455
+ );
389
456
  } finally {
390
457
  if (controller && !controller.signal.aborted) controller.abort();
391
458
  }
459
+ log.debug("Session went idle, fetching result...");
392
460
  const { data: messages } = await client.session.messages({
393
461
  path: { id: sessionId }
394
462
  });
@@ -417,6 +485,20 @@ async function boot(opts) {
417
485
  throw err;
418
486
  }
419
487
  },
488
+ async send(sessionId, text) {
489
+ log.debug(`Sending follow-up message to session ${sessionId} (${text.length} chars)...`);
490
+ const { error } = await client.session.promptAsync({
491
+ path: { id: sessionId },
492
+ body: {
493
+ parts: [{ type: "text", text }],
494
+ ...modelOverride ? { model: modelOverride } : {}
495
+ }
496
+ });
497
+ if (error) {
498
+ throw new Error(`OpenCode send failed: ${JSON.stringify(error)}`);
499
+ }
500
+ log.debug("Follow-up message sent successfully");
501
+ },
420
502
  async cleanup() {
421
503
  if (cleaned) return;
422
504
  cleaned = true;
@@ -429,6 +511,29 @@ async function boot(opts) {
429
511
  }
430
512
  };
431
513
  }
514
+ async function waitForSessionReady(stream, sessionId, reporter) {
515
+ for await (const event of stream) {
516
+ if (!isSessionEvent(event, sessionId)) continue;
517
+ if (event.type === "message.part.updated" && event.properties.part.type === "text") {
518
+ const delta = event.properties.delta;
519
+ if (delta) {
520
+ log.debug(`Streaming text (+${delta.length} chars)...`);
521
+ reporter?.emit(delta);
522
+ }
523
+ continue;
524
+ }
525
+ if (event.type === "session.error") {
526
+ const err = event.properties.error;
527
+ throw new Error(
528
+ `OpenCode session error: ${err ? JSON.stringify(err) : "unknown error"}`
529
+ );
530
+ }
531
+ if (event.type === "session.idle") {
532
+ return;
533
+ }
534
+ }
535
+ throw new Error("OpenCode event stream ended before session became idle");
536
+ }
432
537
  function isSessionEvent(event, sessionId) {
433
538
  const props = event.properties;
434
539
  if (!hasProperty(props, "sessionID") && !hasProperty(props, "info") && !hasProperty(props, "part")) {
@@ -443,56 +548,15 @@ function isSessionEvent(event, sessionId) {
443
548
  }
444
549
  return false;
445
550
  }
551
+ var SESSION_READY_TIMEOUT_MS;
446
552
  var init_opencode = __esm({
447
553
  "src/providers/opencode.ts"() {
448
554
  "use strict";
555
+ init_progress();
449
556
  init_logger();
450
557
  init_guards();
451
- }
452
- });
453
-
454
- // src/helpers/timeout.ts
455
- function withTimeout(promise, ms, label) {
456
- const p = new Promise((resolve5, reject) => {
457
- let settled = false;
458
- const timer = setTimeout(() => {
459
- if (settled) return;
460
- settled = true;
461
- reject(new TimeoutError(ms, label));
462
- }, ms);
463
- promise.then(
464
- (value) => {
465
- if (settled) return;
466
- settled = true;
467
- clearTimeout(timer);
468
- resolve5(value);
469
- },
470
- (err) => {
471
- if (settled) return;
472
- settled = true;
473
- clearTimeout(timer);
474
- reject(err);
475
- }
476
- );
477
- });
478
- p.catch(() => {
479
- });
480
- return p;
481
- }
482
- var TimeoutError;
483
- var init_timeout = __esm({
484
- "src/helpers/timeout.ts"() {
485
- "use strict";
486
- TimeoutError = class extends Error {
487
- /** Optional label identifying the operation that timed out. */
488
- label;
489
- constructor(ms, label) {
490
- const suffix = label ? ` [${label}]` : "";
491
- super(`Timed out after ${ms}ms${suffix}`);
492
- this.name = "TimeoutError";
493
- this.label = label;
494
- }
495
- };
558
+ init_timeout();
559
+ SESSION_READY_TIMEOUT_MS = 6e5;
496
560
  }
497
561
  });
498
562
 
@@ -5603,35 +5667,33 @@ async function boot2(opts) {
5603
5667
  throw err;
5604
5668
  }
5605
5669
  },
5606
- async prompt(sessionId, text) {
5670
+ async prompt(sessionId, text, options) {
5607
5671
  const session = sessions.get(sessionId);
5608
5672
  if (!session) {
5609
5673
  throw new Error(`Copilot session ${sessionId} not found`);
5610
5674
  }
5611
5675
  log.debug(`Sending prompt to session ${sessionId} (${text.length} chars)...`);
5676
+ const reporter = createProgressReporter(options?.onProgress);
5677
+ let unsubIdle;
5678
+ let unsubErr;
5612
5679
  try {
5613
5680
  await session.send({ prompt: text });
5614
5681
  log.debug("Async prompt accepted, waiting for session to become idle...");
5615
- let unsubIdle;
5616
- let unsubErr;
5617
- try {
5618
- await withTimeout(
5619
- new Promise((resolve5, reject) => {
5620
- unsubIdle = session.on("session.idle", () => {
5621
- resolve5();
5622
- });
5623
- unsubErr = session.on("session.error", (event) => {
5624
- reject(new Error(`Copilot session error: ${event.data.message}`));
5625
- });
5626
- }),
5627
- SESSION_READY_TIMEOUT_MS,
5628
- "copilot session ready"
5629
- );
5630
- } finally {
5631
- unsubIdle?.();
5632
- unsubErr?.();
5633
- }
5682
+ reporter.emit("Waiting for Copilot response");
5683
+ await withTimeout(
5684
+ new Promise((resolve5, reject) => {
5685
+ unsubIdle = session.on("session.idle", () => {
5686
+ resolve5();
5687
+ });
5688
+ unsubErr = session.on("session.error", (event) => {
5689
+ reject(new Error(`Copilot session error: ${event.data.message}`));
5690
+ });
5691
+ }),
5692
+ SESSION_READY_TIMEOUT_MS2,
5693
+ "copilot session ready"
5694
+ );
5634
5695
  log.debug("Session went idle, fetching result...");
5696
+ reporter.emit("Finalizing response");
5635
5697
  const events = await session.getMessages();
5636
5698
  const last = [...events].reverse().find((e) => e.type === "assistant.message");
5637
5699
  const result = last?.data?.content ?? null;
@@ -5640,6 +5702,23 @@ async function boot2(opts) {
5640
5702
  } catch (err) {
5641
5703
  log.debug(`Prompt failed: ${log.formatErrorChain(err)}`);
5642
5704
  throw err;
5705
+ } finally {
5706
+ unsubIdle?.();
5707
+ unsubErr?.();
5708
+ }
5709
+ },
5710
+ async send(sessionId, text) {
5711
+ const session = sessions.get(sessionId);
5712
+ if (!session) {
5713
+ throw new Error(`Copilot session ${sessionId} not found`);
5714
+ }
5715
+ log.debug(`Sending follow-up to session ${sessionId} (${text.length} chars)...`);
5716
+ try {
5717
+ await session.send({ prompt: text });
5718
+ log.debug("Follow-up message sent");
5719
+ } catch (err) {
5720
+ log.debug(`Follow-up send failed: ${log.formatErrorChain(err)}`);
5721
+ throw err;
5643
5722
  }
5644
5723
  },
5645
5724
  async cleanup() {
@@ -5657,13 +5736,14 @@ async function boot2(opts) {
5657
5736
  }
5658
5737
  };
5659
5738
  }
5660
- var SESSION_READY_TIMEOUT_MS;
5739
+ var SESSION_READY_TIMEOUT_MS2;
5661
5740
  var init_copilot = __esm({
5662
5741
  "src/providers/copilot.ts"() {
5663
5742
  "use strict";
5743
+ init_progress();
5664
5744
  init_logger();
5665
5745
  init_timeout();
5666
- SESSION_READY_TIMEOUT_MS = 6e5;
5746
+ SESSION_READY_TIMEOUT_MS2 = 6e5;
5667
5747
  }
5668
5748
  });
5669
5749
 
@@ -5689,7 +5769,12 @@ async function boot3(opts) {
5689
5769
  async createSession() {
5690
5770
  log.debug("Creating Claude session...");
5691
5771
  try {
5692
- const sessionOpts = { model, permissionMode: "acceptEdits", ...cwd ? { cwd } : {} };
5772
+ const sessionOpts = {
5773
+ model,
5774
+ permissionMode: "bypassPermissions",
5775
+ allowDangerouslySkipPermissions: true,
5776
+ ...cwd ? { cwd } : {}
5777
+ };
5693
5778
  const session = unstable_v2_createSession(sessionOpts);
5694
5779
  const sessionId = randomUUID();
5695
5780
  sessions.set(sessionId, session);
@@ -5700,19 +5785,23 @@ async function boot3(opts) {
5700
5785
  throw err;
5701
5786
  }
5702
5787
  },
5703
- async prompt(sessionId, text) {
5788
+ async prompt(sessionId, text, options) {
5704
5789
  const session = sessions.get(sessionId);
5705
5790
  if (!session) {
5706
5791
  throw new Error(`Claude session ${sessionId} not found`);
5707
5792
  }
5708
5793
  log.debug(`Sending prompt to session ${sessionId} (${text.length} chars)...`);
5794
+ const reporter = createProgressReporter(options?.onProgress);
5709
5795
  try {
5710
5796
  await session.send(text);
5711
5797
  const parts = [];
5712
5798
  for await (const msg of session.stream()) {
5713
5799
  if (msg.type === "assistant") {
5714
5800
  const msgText = msg.message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
5715
- if (msgText) parts.push(msgText);
5801
+ if (msgText) {
5802
+ reporter.emit(msgText);
5803
+ parts.push(msgText);
5804
+ }
5716
5805
  }
5717
5806
  }
5718
5807
  const result = parts.join("") || null;
@@ -5723,6 +5812,19 @@ async function boot3(opts) {
5723
5812
  throw err;
5724
5813
  }
5725
5814
  },
5815
+ async send(sessionId, text) {
5816
+ const session = sessions.get(sessionId);
5817
+ if (!session) {
5818
+ throw new Error(`Claude session ${sessionId} not found`);
5819
+ }
5820
+ log.debug(`Sending follow-up to session ${sessionId} (${text.length} chars)...`);
5821
+ try {
5822
+ await session.send(text);
5823
+ } catch (err) {
5824
+ log.debug(`Follow-up send failed: ${log.formatErrorChain(err)}`);
5825
+ throw err;
5826
+ }
5827
+ },
5726
5828
  async cleanup() {
5727
5829
  log.debug("Cleaning up Claude provider...");
5728
5830
  for (const session of sessions.values()) {
@@ -5738,6 +5840,7 @@ async function boot3(opts) {
5738
5840
  var init_claude = __esm({
5739
5841
  "src/providers/claude.ts"() {
5740
5842
  "use strict";
5843
+ init_progress();
5741
5844
  init_logger();
5742
5845
  }
5743
5846
  });
@@ -5766,6 +5869,11 @@ async function boot4(opts) {
5766
5869
  log.debug("Creating Codex session...");
5767
5870
  try {
5768
5871
  const sessionId = randomUUID2();
5872
+ const state = {
5873
+ agent: void 0,
5874
+ reporter: createProgressReporter(),
5875
+ loadingReported: false
5876
+ };
5769
5877
  const agent = new AgentLoop({
5770
5878
  model,
5771
5879
  config: { model, instructions: "" },
@@ -5773,14 +5881,26 @@ async function boot4(opts) {
5773
5881
  ...opts?.cwd ? { rootDir: opts.cwd } : {},
5774
5882
  additionalWritableRoots: [],
5775
5883
  getCommandConfirmation: async () => ({ approved: true }),
5776
- onItem: () => {
5884
+ onItem: (item) => {
5885
+ if (item && typeof item === "object" && "type" in item && item.type === "message" && "content" in item && Array.isArray(item.content)) {
5886
+ const itemText = item.content.filter(
5887
+ (block) => Boolean(block) && typeof block === "object" && "type" in block && block.type === "output_text"
5888
+ ).map((block) => block.text ?? "").join("");
5889
+ if (itemText) {
5890
+ state.reporter.emit(itemText);
5891
+ }
5892
+ }
5777
5893
  },
5778
5894
  onLoading: () => {
5895
+ if (state.loadingReported) return;
5896
+ state.loadingReported = true;
5897
+ state.reporter.emit("thinking");
5779
5898
  },
5780
5899
  onLastResponseId: () => {
5781
5900
  }
5782
5901
  });
5783
- sessions.set(sessionId, agent);
5902
+ state.agent = agent;
5903
+ sessions.set(sessionId, state);
5784
5904
  log.debug(`Session created: ${sessionId}`);
5785
5905
  return sessionId;
5786
5906
  } catch (err) {
@@ -5788,35 +5908,55 @@ async function boot4(opts) {
5788
5908
  throw err;
5789
5909
  }
5790
5910
  },
5791
- async prompt(sessionId, text) {
5792
- const agent = sessions.get(sessionId);
5793
- if (!agent) {
5911
+ async prompt(sessionId, text, options) {
5912
+ const state = sessions.get(sessionId);
5913
+ if (!state) {
5794
5914
  throw new Error(`Codex session ${sessionId} not found`);
5795
5915
  }
5796
5916
  log.debug(`Sending prompt to session ${sessionId} (${text.length} chars)...`);
5917
+ state.onProgress = options?.onProgress;
5918
+ state.reporter = createProgressReporter(state.onProgress);
5919
+ state.loadingReported = false;
5797
5920
  try {
5798
- const items = await agent.run([text]);
5921
+ state.reporter.emit("Waiting for Codex response");
5922
+ const items = await state.agent.run([text]);
5799
5923
  const parts = [];
5800
5924
  for (const item of items) {
5801
5925
  if (item.type === "message" && "content" in item) {
5802
5926
  const content = item.content;
5803
5927
  const itemText = content.filter((block) => block.type === "output_text").map((block) => block.text ?? "").join("");
5804
- if (itemText) parts.push(itemText);
5928
+ if (itemText) {
5929
+ parts.push(itemText);
5930
+ }
5805
5931
  }
5806
5932
  }
5933
+ state.reporter.emit("Finalizing response");
5807
5934
  const result = parts.join("") || null;
5808
5935
  log.debug(`Prompt response received (${result?.length ?? 0} chars)`);
5809
5936
  return result;
5810
5937
  } catch (err) {
5811
5938
  log.debug(`Prompt failed: ${log.formatErrorChain(err)}`);
5812
5939
  throw err;
5940
+ } finally {
5941
+ state.onProgress = void 0;
5942
+ state.reporter = createProgressReporter();
5943
+ state.loadingReported = false;
5944
+ }
5945
+ },
5946
+ async send(sessionId, text) {
5947
+ const state = sessions.get(sessionId);
5948
+ if (!state) {
5949
+ throw new Error(`Codex session ${sessionId} not found`);
5813
5950
  }
5951
+ log.debug(
5952
+ `Codex provider does not support non-blocking send \u2014 agent.run() is blocking. Ignoring follow-up for session ${sessionId} (${text.length} chars).`
5953
+ );
5814
5954
  },
5815
5955
  async cleanup() {
5816
5956
  log.debug("Cleaning up Codex provider...");
5817
- for (const agent of sessions.values()) {
5957
+ for (const state of sessions.values()) {
5818
5958
  try {
5819
- agent.terminate();
5959
+ state.agent.terminate();
5820
5960
  } catch {
5821
5961
  }
5822
5962
  }
@@ -5827,6 +5967,7 @@ async function boot4(opts) {
5827
5967
  var init_codex = __esm({
5828
5968
  "src/providers/codex.ts"() {
5829
5969
  "use strict";
5970
+ init_progress();
5830
5971
  init_logger();
5831
5972
  }
5832
5973
  });
@@ -6128,19 +6269,10 @@ import { execFile as execFile5 } from "child_process";
6128
6269
  import { promisify as promisify5 } from "util";
6129
6270
 
6130
6271
  // src/datasources/github.ts
6272
+ init_logger();
6131
6273
  import { execFile } from "child_process";
6132
6274
  import { promisify } from "util";
6133
6275
 
6134
- // src/helpers/slugify.ts
6135
- var MAX_SLUG_LENGTH = 60;
6136
- function slugify(input3, maxLength) {
6137
- const slug = input3.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
6138
- return maxLength != null ? slug.slice(0, maxLength) : slug;
6139
- }
6140
-
6141
- // src/datasources/github.ts
6142
- init_logger();
6143
-
6144
6276
  // src/helpers/branch-validation.ts
6145
6277
  var InvalidBranchNameError = class extends Error {
6146
6278
  constructor(branch, reason) {
@@ -6271,6 +6403,31 @@ ${deviceCodeInfo.message}`;
6271
6403
  azdev.getBearerHandler(accessToken.token)
6272
6404
  );
6273
6405
  }
6406
+ async function ensureAuthReady(source, cwd, org) {
6407
+ if (source === "github") {
6408
+ const remoteUrl = await getGitRemoteUrl(cwd);
6409
+ if (remoteUrl && parseGitHubRemoteUrl(remoteUrl)) {
6410
+ await getGithubOctokit();
6411
+ } else if (!remoteUrl) {
6412
+ log.warn("No git remote found \u2014 skipping GitHub pre-authentication");
6413
+ } else {
6414
+ log.warn("Remote URL is not a GitHub repository \u2014 skipping GitHub pre-authentication");
6415
+ }
6416
+ } else if (source === "azdevops") {
6417
+ let orgUrl = org;
6418
+ if (!orgUrl) {
6419
+ const remoteUrl = await getGitRemoteUrl(cwd);
6420
+ if (remoteUrl) {
6421
+ const parsed = parseAzDevOpsRemoteUrl(remoteUrl);
6422
+ if (parsed) orgUrl = parsed.orgUrl;
6423
+ else log.warn("Remote URL is not an Azure DevOps repository \u2014 skipping Azure pre-authentication");
6424
+ } else {
6425
+ log.warn("No git remote found \u2014 skipping Azure DevOps pre-authentication");
6426
+ }
6427
+ }
6428
+ if (orgUrl) await getAzureConnection(orgUrl);
6429
+ }
6430
+ }
6274
6431
 
6275
6432
  // src/datasources/github.ts
6276
6433
  var exec = promisify(execFile);
@@ -6292,9 +6449,31 @@ async function getOwnerRepo(cwd) {
6292
6449
  }
6293
6450
  return parsed;
6294
6451
  }
6295
- function buildBranchName(issueNumber, title, username = "unknown") {
6296
- const slug = slugify(title, 50);
6297
- return `${username}/dispatch/${issueNumber}-${slug}`;
6452
+ function buildBranchName(issueNumber, _title, username = "unknown") {
6453
+ return `${username}/dispatch/issue-${issueNumber}`;
6454
+ }
6455
+ async function deriveShortUsername(cwd, fallback) {
6456
+ try {
6457
+ const raw = (await git(["config", "user.name"], cwd)).trim();
6458
+ if (raw) {
6459
+ const parts = raw.toLowerCase().replace(/[^a-z\s]/g, "").trim().split(/\s+/);
6460
+ if (parts.length >= 2) {
6461
+ return parts[0].slice(0, 2) + parts[parts.length - 1].slice(0, 6) || fallback;
6462
+ }
6463
+ }
6464
+ } catch {
6465
+ }
6466
+ try {
6467
+ const raw = (await git(["config", "user.email"], cwd)).trim();
6468
+ if (raw) {
6469
+ const localPart = raw.split("@")[0].toLowerCase().replace(/[^a-z0-9]/g, "");
6470
+ if (localPart) {
6471
+ return localPart.slice(0, 8);
6472
+ }
6473
+ }
6474
+ } catch {
6475
+ }
6476
+ return fallback;
6298
6477
  }
6299
6478
  async function getDefaultBranch(cwd) {
6300
6479
  const PREFIX = "refs/remotes/origin/";
@@ -6422,13 +6601,8 @@ var datasource = {
6422
6601
  };
6423
6602
  },
6424
6603
  async getUsername(opts) {
6425
- try {
6426
- const name = await git(["config", "user.name"], opts.cwd);
6427
- const slug = slugify(name.trim());
6428
- return slug || "unknown";
6429
- } catch {
6430
- return "unknown";
6431
- }
6604
+ if (opts.username) return opts.username;
6605
+ return deriveShortUsername(opts.cwd, "unknown");
6432
6606
  },
6433
6607
  getDefaultBranch(opts) {
6434
6608
  return getDefaultBranch(opts.cwd);
@@ -6517,9 +6691,9 @@ var datasource = {
6517
6691
  };
6518
6692
 
6519
6693
  // src/datasources/azdevops.ts
6694
+ init_logger();
6520
6695
  import { execFile as execFile2 } from "child_process";
6521
6696
  import { promisify as promisify2 } from "util";
6522
- init_logger();
6523
6697
  import { PullRequestStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js";
6524
6698
  var exec2 = promisify2(execFile2);
6525
6699
  var doneStateCache = /* @__PURE__ */ new Map();
@@ -6630,6 +6804,29 @@ async function fetchComments(workItemId, project, connection) {
6630
6804
  return [];
6631
6805
  }
6632
6806
  }
6807
+ async function deriveShortUsername2(cwd, fallback) {
6808
+ try {
6809
+ const raw = (await git2(["config", "user.name"], cwd)).trim();
6810
+ if (raw) {
6811
+ const parts = raw.toLowerCase().replace(/[^a-z\s]/g, "").trim().split(/\s+/);
6812
+ if (parts.length >= 2) {
6813
+ return parts[0].slice(0, 2) + parts[parts.length - 1].slice(0, 6) || fallback;
6814
+ }
6815
+ }
6816
+ } catch {
6817
+ }
6818
+ try {
6819
+ const raw = (await git2(["config", "user.email"], cwd)).trim();
6820
+ if (raw) {
6821
+ const localPart = raw.split("@")[0].toLowerCase().replace(/[^a-z0-9]/g, "");
6822
+ if (localPart) {
6823
+ return localPart.slice(0, 8);
6824
+ }
6825
+ }
6826
+ } catch {
6827
+ }
6828
+ return fallback;
6829
+ }
6633
6830
  var datasource2 = {
6634
6831
  name: "azdevops",
6635
6832
  supportsGit() {
@@ -6774,17 +6971,11 @@ var datasource2 = {
6774
6971
  return this.getDefaultBranch(opts);
6775
6972
  },
6776
6973
  async getUsername(opts) {
6777
- try {
6778
- const name = await git2(["config", "user.name"], opts.cwd);
6779
- const slug = slugify(name.trim());
6780
- if (slug) return slug;
6781
- } catch {
6782
- }
6783
- return "unknown";
6974
+ if (opts.username) return opts.username;
6975
+ return deriveShortUsername2(opts.cwd, "unknown");
6784
6976
  },
6785
- buildBranchName(issueNumber, title, username) {
6786
- const slug = slugify(title, 50);
6787
- const branch = `${username}/dispatch/${issueNumber}-${slug}`;
6977
+ buildBranchName(issueNumber, _title, username) {
6978
+ const branch = `${username}/dispatch/issue-${issueNumber}`;
6788
6979
  if (!isValidBranchName(branch)) {
6789
6980
  throw new InvalidBranchNameError(branch);
6790
6981
  }
@@ -6891,6 +7082,13 @@ import { basename, dirname as dirname5, isAbsolute, join as join5, parse as pars
6891
7082
  import { promisify as promisify4 } from "util";
6892
7083
  import { glob } from "glob";
6893
7084
 
7085
+ // src/helpers/slugify.ts
7086
+ var MAX_SLUG_LENGTH = 60;
7087
+ function slugify(input3, maxLength) {
7088
+ const slug = input3.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
7089
+ return maxLength != null ? slug.slice(0, maxLength) : slug;
7090
+ }
7091
+
6894
7092
  // src/config.ts
6895
7093
  init_providers();
6896
7094
  import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
@@ -7026,6 +7224,12 @@ async function runInteractiveConfigWizard(configDir) {
7026
7224
  });
7027
7225
  if (areaInput.trim()) area = areaInput.trim();
7028
7226
  }
7227
+ try {
7228
+ await ensureAuthReady(effectiveSource ?? void 0, process.cwd(), org);
7229
+ } catch (err) {
7230
+ log.warn(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`);
7231
+ log.warn("You can re-run 'dispatch config' or authenticate later at runtime.");
7232
+ }
7029
7233
  const newConfig = {
7030
7234
  provider,
7031
7235
  source
@@ -7067,9 +7271,12 @@ async function runInteractiveConfigWizard(configDir) {
7067
7271
  var CONFIG_BOUNDS = {
7068
7272
  testTimeout: { min: 1, max: 120 },
7069
7273
  planTimeout: { min: 1, max: 120 },
7274
+ specTimeout: { min: 1, max: 120 },
7275
+ specWarnTimeout: { min: 1, max: 120 },
7276
+ specKillTimeout: { min: 1, max: 120 },
7070
7277
  concurrency: { min: 1, max: 64 }
7071
7278
  };
7072
- var CONFIG_KEYS = ["provider", "model", "source", "testTimeout", "planTimeout", "concurrency", "org", "project", "workItemType", "iteration", "area"];
7279
+ var CONFIG_KEYS = ["provider", "model", "source", "testTimeout", "planTimeout", "specTimeout", "specWarnTimeout", "specKillTimeout", "concurrency", "org", "project", "workItemType", "iteration", "area", "username"];
7073
7280
  function getConfigPath(configDir) {
7074
7281
  const dir = configDir ?? join4(process.cwd(), ".dispatch");
7075
7282
  return join4(dir, "config.json");
@@ -7153,10 +7360,33 @@ function toIssueDetails(filename, content, dir) {
7153
7360
  acceptanceCriteria: ""
7154
7361
  };
7155
7362
  }
7156
- var datasource3 = {
7157
- name: "md",
7158
- supportsGit() {
7159
- return true;
7363
+ async function deriveShortUsername3(cwd, fallback) {
7364
+ try {
7365
+ const raw = (await git3(["config", "user.name"], cwd)).trim();
7366
+ if (raw) {
7367
+ const parts = raw.toLowerCase().replace(/[^a-z\s]/g, "").trim().split(/\s+/);
7368
+ if (parts.length >= 2) {
7369
+ return parts[0].slice(0, 2) + parts[parts.length - 1].slice(0, 6) || fallback;
7370
+ }
7371
+ }
7372
+ } catch {
7373
+ }
7374
+ try {
7375
+ const raw = (await git3(["config", "user.email"], cwd)).trim();
7376
+ if (raw) {
7377
+ const localPart = raw.split("@")[0].toLowerCase().replace(/[^a-z0-9]/g, "");
7378
+ if (localPart) {
7379
+ return localPart.slice(0, 8);
7380
+ }
7381
+ }
7382
+ } catch {
7383
+ }
7384
+ return fallback;
7385
+ }
7386
+ var datasource3 = {
7387
+ name: "md",
7388
+ supportsGit() {
7389
+ return true;
7160
7390
  },
7161
7391
  async list(opts) {
7162
7392
  if (opts?.pattern) {
@@ -7267,29 +7497,22 @@ var datasource3 = {
7267
7497
  return this.getDefaultBranch(opts);
7268
7498
  },
7269
7499
  async getUsername(opts) {
7270
- try {
7271
- const { stdout } = await exec4("git", ["config", "user.name"], { cwd: opts.cwd, shell: process.platform === "win32" });
7272
- const name = stdout.trim();
7273
- if (!name) return "local";
7274
- return slugify(name);
7275
- } catch {
7276
- return "local";
7277
- }
7500
+ if (opts.username) return opts.username;
7501
+ return deriveShortUsername3(opts.cwd, "local");
7278
7502
  },
7279
- buildBranchName(issueNumber, title, username) {
7280
- const slug = slugify(title, 50);
7503
+ buildBranchName(issueNumber, _title, username) {
7281
7504
  if (issueNumber.includes("/") || issueNumber.includes("\\")) {
7282
7505
  const normalized = issueNumber.replaceAll("\\", "/");
7283
7506
  const filename = basename(normalized);
7284
7507
  const idMatch = /^(\d+)-(.+)\.md$/.exec(filename);
7285
7508
  if (idMatch) {
7286
- return `${username}/dispatch/file-${idMatch[1]}-${slug}`;
7509
+ return `${username}/dispatch/issue-${idMatch[1]}`;
7287
7510
  }
7288
7511
  const nameWithoutExt = parsePath(filename).name;
7289
7512
  const slugifiedName = slugify(nameWithoutExt, 50);
7290
- return `${username}/dispatch/file-${slugifiedName}-${slug}`;
7513
+ return `${username}/dispatch/file-${slugifiedName}`;
7291
7514
  }
7292
- return `${username}/dispatch/${issueNumber}-${slug}`;
7515
+ return `${username}/dispatch/issue-${issueNumber}`;
7293
7516
  },
7294
7517
  async createAndSwitchBranch(branchName, opts) {
7295
7518
  try {
@@ -7439,6 +7662,9 @@ function parseGitHubRemoteUrl(url) {
7439
7662
  // src/spec-generator.ts
7440
7663
  init_logger();
7441
7664
  var MB_PER_CONCURRENT_TASK = 500;
7665
+ var DEFAULT_SPEC_TIMEOUT_MIN = 10;
7666
+ var DEFAULT_SPEC_WARN_MIN = 10;
7667
+ var DEFAULT_SPEC_KILL_MIN = 10;
7442
7668
  var RECOGNIZED_H2 = /* @__PURE__ */ new Set([
7443
7669
  "## Context",
7444
7670
  "## Why",
@@ -7900,12 +8126,16 @@ var CONFIG_TO_CLI = {
7900
8126
  source: "issueSource",
7901
8127
  testTimeout: "testTimeout",
7902
8128
  planTimeout: "planTimeout",
8129
+ specTimeout: "specTimeout",
8130
+ specWarnTimeout: "specWarnTimeout",
8131
+ specKillTimeout: "specKillTimeout",
7903
8132
  concurrency: "concurrency",
7904
8133
  org: "org",
7905
8134
  project: "project",
7906
8135
  workItemType: "workItemType",
7907
8136
  iteration: "iteration",
7908
- area: "area"
8137
+ area: "area",
8138
+ username: "username"
7909
8139
  };
7910
8140
  function setCliField(target, key, value) {
7911
8141
  target[key] = value;
@@ -7968,6 +8198,7 @@ init_providers();
7968
8198
  import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile6, unlink } from "fs/promises";
7969
8199
  import { join as join10, resolve as resolve2, sep } from "path";
7970
8200
  import { randomUUID as randomUUID4 } from "crypto";
8201
+ init_timeout();
7971
8202
  init_logger();
7972
8203
  init_file_logger();
7973
8204
  init_environment();
@@ -7979,7 +8210,7 @@ async function boot5(opts) {
7979
8210
  return {
7980
8211
  name: "spec",
7981
8212
  async generate(genOpts) {
7982
- const { issue, filePath, fileContent, inlineText, cwd: workingDir, outputPath } = genOpts;
8213
+ const { issue, filePath, fileContent, inlineText, cwd: workingDir, outputPath, onProgress } = genOpts;
7983
8214
  const startTime = Date.now();
7984
8215
  try {
7985
8216
  const resolvedCwd = resolve2(workingDir);
@@ -8014,7 +8245,50 @@ async function boot5(opts) {
8014
8245
  fileLoggerStorage.getStore()?.prompt("spec", prompt);
8015
8246
  const sessionId = await provider.createSession();
8016
8247
  log.debug(`Spec prompt built (${prompt.length} chars)`);
8017
- const response = await provider.prompt(sessionId, prompt);
8248
+ const warnMs = genOpts.timeboxWarnMs ?? DEFAULT_SPEC_WARN_MIN * 6e4;
8249
+ const killMs = genOpts.timeboxKillMs ?? DEFAULT_SPEC_KILL_MIN * 6e4;
8250
+ const response = await new Promise((resolve5, reject) => {
8251
+ let settled = false;
8252
+ let warnTimer;
8253
+ let killTimer;
8254
+ const cleanup = () => {
8255
+ if (warnTimer) clearTimeout(warnTimer);
8256
+ if (killTimer) clearTimeout(killTimer);
8257
+ };
8258
+ warnTimer = setTimeout(() => {
8259
+ if (settled) return;
8260
+ const remainingSec = Math.round(killMs / 1e3);
8261
+ const warnMessage = `Your spec generation time is done. You have exceeded the ${Math.round(warnMs / 6e4)}-minute limit. You MUST write the spec file to "${outputPath}" immediately. If you do not comply within ${remainingSec} seconds, you will be terminated.`;
8262
+ log.warn(`Timebox warn fired for session ${sessionId} \u2014 sending wrap-up message`);
8263
+ if (provider.send) {
8264
+ provider.send(sessionId, warnMessage).catch((err) => {
8265
+ log.warn(`Failed to send timebox warning: ${log.extractMessage(err)}`);
8266
+ });
8267
+ } else {
8268
+ log.warn(`Provider does not support send() \u2014 cannot deliver timebox warning`);
8269
+ }
8270
+ killTimer = setTimeout(() => {
8271
+ if (settled) return;
8272
+ settled = true;
8273
+ cleanup();
8274
+ reject(new TimeoutError(warnMs + killMs, "spec timebox"));
8275
+ }, killMs);
8276
+ }, warnMs);
8277
+ provider.prompt(sessionId, prompt, { onProgress }).then(
8278
+ (value) => {
8279
+ if (settled) return;
8280
+ settled = true;
8281
+ cleanup();
8282
+ resolve5(value);
8283
+ },
8284
+ (err) => {
8285
+ if (settled) return;
8286
+ settled = true;
8287
+ cleanup();
8288
+ reject(err);
8289
+ }
8290
+ );
8291
+ });
8018
8292
  if (response === null) {
8019
8293
  return {
8020
8294
  data: null,
@@ -8140,12 +8414,18 @@ function buildCommonSpecInstructions(params) {
8140
8414
  return [
8141
8415
  `You are a **spec agent**. Your job is to explore the codebase, understand ${subject}, and write a high-level **markdown spec file** to disk that will drive an automated implementation pipeline.`,
8142
8416
  ``,
8417
+ `**Time limit:** You have ${DEFAULT_SPEC_WARN_MIN} minutes to complete this spec. Work efficiently and focus on delivering a complete, well-structured spec within this window.`,
8418
+ ``,
8143
8419
  `**Important:** This file will be consumed by a two-stage pipeline:`,
8144
8420
  `1. A **planner agent** reads each task together with the prose context in this file, then explores the codebase to produce a detailed, line-level implementation plan.`,
8145
8421
  `2. A **coder agent** follows that detailed plan to make the actual code changes.`,
8146
8422
  ``,
8147
8423
  `Because the planner agent handles low-level details, your spec must stay **high-level and strategic**. Focus on the WHAT, WHY, and HOW \u2014 not exact code or line numbers.`,
8148
8424
  ``,
8425
+ `**Scope:** Each invocation is scoped to exactly one source item. The source item for this invocation is the single passed issue, file, or inline request shown below.`,
8426
+ `Treat other repository materials \u2014 including existing spec files, sibling issues, and future work \u2014 as context only unless the passed source explicitly references them as required context.`,
8427
+ `Do not merge unrelated specs, issues, files, or requests into the generated output.`,
8428
+ ``,
8149
8429
  `**CRITICAL \u2014 Output constraints (read carefully):**`,
8150
8430
  `The file you write must contain ONLY the structured spec content described below. You MUST NOT include:`,
8151
8431
  `- **No preamble:** Do not add any text before the H1 heading (e.g., "Here's the spec:", "I've written the spec file to...")`,
@@ -8300,7 +8580,11 @@ function buildInlineTextSpecPrompt(text, cwd, outputPath) {
8300
8580
  init_cleanup();
8301
8581
  init_logger();
8302
8582
  init_file_logger();
8583
+ import chalk6 from "chalk";
8584
+
8585
+ // src/tui.ts
8303
8586
  import chalk5 from "chalk";
8587
+ import { emitKeypressEvents } from "readline";
8304
8588
 
8305
8589
  // src/helpers/format.ts
8306
8590
  import chalk4 from "chalk";
@@ -8326,8 +8610,402 @@ function renderHeaderLines(info) {
8326
8610
  return lines;
8327
8611
  }
8328
8612
 
8613
+ // src/tui.ts
8614
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
8615
+ var BAR_WIDTH = 30;
8616
+ var spinnerIndex = 0;
8617
+ var interval = null;
8618
+ var lastLineCount = 0;
8619
+ function spinner() {
8620
+ return chalk5.cyan(SPINNER_FRAMES[spinnerIndex % SPINNER_FRAMES.length]);
8621
+ }
8622
+ function progressBar(done, total) {
8623
+ if (total === 0) return chalk5.dim("\u2591".repeat(BAR_WIDTH));
8624
+ const filled = Math.round(done / total * BAR_WIDTH);
8625
+ const empty = BAR_WIDTH - filled;
8626
+ const pct = Math.round(done / total * 100);
8627
+ return chalk5.green("\u2588".repeat(filled)) + chalk5.dim("\u2591".repeat(empty)) + chalk5.white(` ${pct}%`);
8628
+ }
8629
+ function statusIcon(status) {
8630
+ switch (status) {
8631
+ case "pending":
8632
+ return chalk5.dim("\u25CB");
8633
+ case "planning":
8634
+ return spinner();
8635
+ case "running":
8636
+ case "generating":
8637
+ case "syncing":
8638
+ return spinner();
8639
+ case "paused":
8640
+ return chalk5.yellow("\u25D0");
8641
+ case "done":
8642
+ return chalk5.green("\u25CF");
8643
+ case "failed":
8644
+ return chalk5.red("\u2716");
8645
+ }
8646
+ }
8647
+ function statusLabel(status) {
8648
+ switch (status) {
8649
+ case "pending":
8650
+ return chalk5.dim("pending");
8651
+ case "planning":
8652
+ return chalk5.magenta("planning");
8653
+ case "running":
8654
+ return chalk5.cyan("executing");
8655
+ case "generating":
8656
+ return chalk5.cyan("generating");
8657
+ case "syncing":
8658
+ return chalk5.cyan("syncing");
8659
+ case "paused":
8660
+ return chalk5.yellow("paused");
8661
+ case "done":
8662
+ return chalk5.green("done");
8663
+ case "failed":
8664
+ return chalk5.red("failed");
8665
+ }
8666
+ }
8667
+ function phaseLabel(phase, provider, mode = "dispatch") {
8668
+ switch (phase) {
8669
+ case "discovering":
8670
+ return `${spinner()} Discovering task files...`;
8671
+ case "parsing":
8672
+ return `${spinner()} Parsing tasks...`;
8673
+ case "booting": {
8674
+ const name = provider ?? "provider";
8675
+ return `${spinner()} Connecting to ${name}...`;
8676
+ }
8677
+ case "dispatching":
8678
+ return mode === "spec" ? `${spinner()} Generating specs...` : `${spinner()} Dispatching tasks...`;
8679
+ case "paused":
8680
+ return chalk5.yellow("\u25D0") + " Waiting for rerun...";
8681
+ case "done":
8682
+ return chalk5.green("\u2714") + " Complete";
8683
+ }
8684
+ }
8685
+ function isActiveStatus(status) {
8686
+ return status === "planning" || status === "running" || status === "generating" || status === "syncing";
8687
+ }
8688
+ function sanitizeSubordinateText(text) {
8689
+ return text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\r\n]+/g, " ").replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]+/g, "").replace(/\s+/g, " ").trim();
8690
+ }
8691
+ function truncateText(text, maxLen) {
8692
+ if (text.length <= maxLen) return text;
8693
+ return text.slice(0, Math.max(0, maxLen - 1)) + "\u2026";
8694
+ }
8695
+ function renderTaskError(error) {
8696
+ if (!error) return null;
8697
+ return chalk5.red(` \u2514\u2500 ${error}`);
8698
+ }
8699
+ function renderTaskFeedback(feedback, cols) {
8700
+ if (!feedback) return null;
8701
+ const sanitized = sanitizeSubordinateText(feedback);
8702
+ if (!sanitized) return null;
8703
+ const maxLen = Math.max(16, cols - 10);
8704
+ return chalk5.dim(` \u2514\u2500 ${truncateText(sanitized, maxLen)}`);
8705
+ }
8706
+ function countVisualRows(text, cols) {
8707
+ const stripped = text.replace(/\x1B\[[0-9;]*m/g, "");
8708
+ const safeCols = Math.max(1, cols);
8709
+ return stripped.split("\n").reduce((sum, line) => {
8710
+ return sum + Math.max(1, Math.ceil(line.length / safeCols));
8711
+ }, 0);
8712
+ }
8713
+ function toggleRecoveryAction(action) {
8714
+ return action === "rerun" ? "quit" : "rerun";
8715
+ }
8716
+ function renderRecoveryAction(action, selectedAction) {
8717
+ const selected = action === selectedAction;
8718
+ if (action === "rerun") {
8719
+ return selected ? chalk5.greenBright(`[\u25B6 rerun]`) : chalk5.dim("\u25B6 rerun");
8720
+ }
8721
+ return selected ? chalk5.redBright("[q quit]") : chalk5.dim("q quit");
8722
+ }
8723
+ function render(state, cols) {
8724
+ const lines = [];
8725
+ const now = Date.now();
8726
+ const totalElapsed = elapsed(now - state.startTime);
8727
+ const done = state.tasks.filter((t) => t.status === "done").length;
8728
+ const failed = state.tasks.filter((t) => t.status === "failed").length;
8729
+ const total = state.tasks.length;
8730
+ lines.push("");
8731
+ lines.push(
8732
+ ...renderHeaderLines({
8733
+ provider: state.provider,
8734
+ model: state.model,
8735
+ source: state.source
8736
+ })
8737
+ );
8738
+ if (state.currentIssue) {
8739
+ lines.push(
8740
+ chalk5.dim(` issue: `) + chalk5.white(`#${state.currentIssue.number}`) + chalk5.dim(` \u2014 ${state.currentIssue.title}`)
8741
+ );
8742
+ }
8743
+ lines.push(chalk5.dim(" \u2500".repeat(24)));
8744
+ if (state.notification) {
8745
+ lines.push("");
8746
+ for (const notifLine of state.notification.split("\n")) {
8747
+ lines.push(" " + chalk5.yellowBright("\u26A0 ") + chalk5.yellow(notifLine));
8748
+ }
8749
+ }
8750
+ lines.push(` ${phaseLabel(state.phase, state.provider, state.mode)}` + chalk5.dim(` ${totalElapsed}`));
8751
+ if (state.phase === "dispatching" || state.phase === "paused" || state.phase === "done") {
8752
+ lines.push("");
8753
+ lines.push(` ${progressBar(done + failed, total)} ${chalk5.dim(`${done + failed}/${total} tasks`)}`);
8754
+ lines.push("");
8755
+ const activeWorktrees = new Set(
8756
+ state.tasks.map((t) => t.worktree).filter(Boolean)
8757
+ );
8758
+ const showWorktree = activeWorktrees.size > 1;
8759
+ const maxTextLen = cols - 30;
8760
+ const paused = state.tasks.filter((t) => t.status === "paused");
8761
+ const running = state.tasks.filter((t) => isActiveStatus(t.status));
8762
+ const completed = state.tasks.filter(
8763
+ (t) => t.status === "done" || t.status === "failed"
8764
+ );
8765
+ const pending = state.tasks.filter((t) => t.status === "pending");
8766
+ if (showWorktree) {
8767
+ const groups = /* @__PURE__ */ new Map();
8768
+ const ungrouped = [];
8769
+ for (const ts of state.tasks) {
8770
+ if (ts.worktree) {
8771
+ const arr = groups.get(ts.worktree) ?? [];
8772
+ arr.push(ts);
8773
+ groups.set(ts.worktree, arr);
8774
+ } else {
8775
+ ungrouped.push(ts);
8776
+ }
8777
+ }
8778
+ const doneGroups = [];
8779
+ const activeGroups = [];
8780
+ for (const [wt, tasks] of groups) {
8781
+ const allDone = tasks.every((t) => t.status === "done" || t.status === "failed");
8782
+ if (allDone) {
8783
+ doneGroups.push([wt, tasks]);
8784
+ } else {
8785
+ activeGroups.push([wt, tasks]);
8786
+ }
8787
+ }
8788
+ if (doneGroups.length > 3) {
8789
+ lines.push(chalk5.dim(` \xB7\xB7\xB7 ${doneGroups.length - 3} earlier issue(s) completed`));
8790
+ }
8791
+ for (const [wt, tasks] of doneGroups.slice(-3)) {
8792
+ const issueNum = wt.match(/^(\d+)/)?.[1] ?? wt.slice(0, 12);
8793
+ const anyFailed = tasks.some((t) => t.status === "failed");
8794
+ const icon = anyFailed ? chalk5.red("\u2716") : chalk5.green("\u25CF");
8795
+ const doneCount = tasks.filter((t) => t.status === "done").length;
8796
+ const maxElapsed = Math.max(...tasks.map((t) => t.elapsed ?? 0));
8797
+ lines.push(` ${icon} ${chalk5.dim(`#${issueNum}`)} ${chalk5.dim(`${doneCount}/${tasks.length} tasks`)} ${chalk5.dim(elapsed(maxElapsed))}`);
8798
+ }
8799
+ for (const [wt, tasks] of activeGroups) {
8800
+ const issueNum = wt.match(/^(\d+)/)?.[1] ?? wt.slice(0, 12);
8801
+ const activeTasks = tasks.filter((t) => isActiveStatus(t.status) || t.status === "paused");
8802
+ const firstActive = activeTasks[0];
8803
+ const displayStatus = firstActive?.status ?? "pending";
8804
+ const truncLen = Math.min(cols - 26, 60);
8805
+ let text = firstActive?.task.text ?? tasks[0]?.task.text ?? "";
8806
+ if (text.length > truncLen) {
8807
+ text = text.slice(0, truncLen - 1) + "\u2026";
8808
+ }
8809
+ const earliest = activeTasks.length > 0 ? Math.min(...activeTasks.map((t) => t.elapsed ?? now)) : now;
8810
+ const elapsedStr = elapsed(now - earliest);
8811
+ const countLabel = activeTasks.length > 0 ? `${activeTasks.length} active` : `${tasks.length} pending`;
8812
+ lines.push(` ${statusIcon(displayStatus)} ${chalk5.white(`#${issueNum}`)} ${countLabel} ${text} ${chalk5.dim(elapsedStr)}`);
8813
+ }
8814
+ for (const ts of ungrouped) {
8815
+ if (!isActiveStatus(ts.status) && ts.status !== "paused") continue;
8816
+ const icon = statusIcon(ts.status);
8817
+ const idx = chalk5.dim(`#${state.tasks.indexOf(ts) + 1}`);
8818
+ const text = truncateText(ts.task.text, maxTextLen);
8819
+ const elapsedStr = chalk5.dim(` ${elapsed(now - (ts.elapsed || now))}`);
8820
+ const label = statusLabel(ts.status);
8821
+ lines.push(` ${icon} ${idx} ${text} ${label}${elapsedStr}`);
8822
+ const feedbackLine = ts.status === "generating" ? renderTaskFeedback(ts.feedback, cols) : null;
8823
+ if (feedbackLine) {
8824
+ lines.push(feedbackLine);
8825
+ }
8826
+ const errorLine = renderTaskError(ts.error);
8827
+ if (errorLine) {
8828
+ lines.push(errorLine);
8829
+ }
8830
+ }
8831
+ } else {
8832
+ const visibleRunning = running.slice(0, 8);
8833
+ const visible = [
8834
+ ...completed.slice(-3),
8835
+ ...paused.slice(0, 3),
8836
+ ...visibleRunning,
8837
+ ...pending.slice(0, 3)
8838
+ ];
8839
+ if (completed.length > 3) {
8840
+ lines.push(chalk5.dim(` \xB7\xB7\xB7 ${completed.length - 3} earlier task(s) completed`));
8841
+ }
8842
+ for (const ts of visible) {
8843
+ const icon = statusIcon(ts.status);
8844
+ const idx = chalk5.dim(`#${state.tasks.indexOf(ts) + 1}`);
8845
+ const text = truncateText(ts.task.text, maxTextLen);
8846
+ const elapsedStr = isActiveStatus(ts.status) ? chalk5.dim(` ${elapsed(now - (ts.elapsed || now))}`) : ts.status === "done" && ts.elapsed ? chalk5.dim(` ${elapsed(ts.elapsed)}`) : "";
8847
+ const label = statusLabel(ts.status);
8848
+ lines.push(` ${icon} ${idx} ${text} ${label}${elapsedStr}`);
8849
+ const feedbackLine = ts.status === "generating" ? renderTaskFeedback(ts.feedback, cols) : null;
8850
+ if (feedbackLine) {
8851
+ lines.push(feedbackLine);
8852
+ }
8853
+ const errorLine = renderTaskError(ts.error);
8854
+ if (errorLine) {
8855
+ lines.push(errorLine);
8856
+ }
8857
+ }
8858
+ if (running.length > 8) {
8859
+ lines.push(chalk5.dim(` \xB7\xB7\xB7 ${running.length - 8} more running`));
8860
+ }
8861
+ if (pending.length > 3) {
8862
+ lines.push(chalk5.dim(` \xB7\xB7\xB7 ${pending.length - 3} more task(s) pending`));
8863
+ }
8864
+ }
8865
+ if (state.phase === "paused" && state.recovery) {
8866
+ const selectedAction = state.recovery.selectedAction ?? "rerun";
8867
+ lines.push("");
8868
+ lines.push(` ${chalk5.yellow("Recovery")}: ${chalk5.white(`#${state.recovery.taskIndex + 1}`)} ${state.recovery.taskText}`);
8869
+ lines.push(` ${chalk5.red(state.recovery.error)}`);
8870
+ if (state.recovery.issue) {
8871
+ lines.push(` ${chalk5.dim(`Issue #${state.recovery.issue.number} - ${state.recovery.issue.title}`)}`);
8872
+ }
8873
+ if (state.recovery.worktree) {
8874
+ lines.push(` ${chalk5.dim(`Worktree: ${state.recovery.worktree}`)}`);
8875
+ }
8876
+ lines.push(` ${chalk5.red("\u2716")} ${renderRecoveryAction("rerun", selectedAction)} ${renderRecoveryAction("quit", selectedAction)}`);
8877
+ lines.push(` ${chalk5.dim("Tab/\u2190/\u2192 switch \xB7 Enter/Space runs selection \xB7 r reruns \xB7 q quits")}`);
8878
+ }
8879
+ lines.push("");
8880
+ const parts = [];
8881
+ if (done > 0) parts.push(chalk5.green(`${done} passed`));
8882
+ if (failed > 0) parts.push(chalk5.red(`${failed} failed`));
8883
+ if (total - done - failed > 0)
8884
+ parts.push(chalk5.dim(`${total - done - failed} remaining`));
8885
+ lines.push(` ${parts.join(chalk5.dim(" \xB7 "))}`);
8886
+ } else if (state.filesFound > 0) {
8887
+ lines.push(chalk5.dim(` Found ${state.filesFound} file(s)`));
8888
+ }
8889
+ lines.push("");
8890
+ return lines.join("\n");
8891
+ }
8892
+ function drawToOutput(state, output) {
8893
+ const cols = output.columns || 80;
8894
+ const rendered = render(state, cols);
8895
+ const newLineCount = countVisualRows(rendered, cols);
8896
+ let buffer = "";
8897
+ if (lastLineCount > 0) {
8898
+ buffer += `\x1B[${lastLineCount}A`;
8899
+ }
8900
+ const lines = rendered.split("\n");
8901
+ buffer += lines.map((line) => line + "\x1B[K").join("\n");
8902
+ const leftover = lastLineCount - newLineCount;
8903
+ if (leftover > 0) {
8904
+ for (let i = 0; i < leftover; i++) {
8905
+ buffer += "\n\x1B[K";
8906
+ }
8907
+ buffer += `\x1B[${leftover}A`;
8908
+ }
8909
+ output.write(buffer);
8910
+ lastLineCount = newLineCount;
8911
+ }
8912
+ function createTui(options) {
8913
+ const input3 = options?.input ?? process.stdin;
8914
+ const output = options?.output ?? process.stdout;
8915
+ const state = {
8916
+ tasks: [],
8917
+ phase: "discovering",
8918
+ mode: "dispatch",
8919
+ startTime: Date.now(),
8920
+ filesFound: 0
8921
+ };
8922
+ lastLineCount = 0;
8923
+ spinnerIndex = 0;
8924
+ let activeRecoveryPromise = null;
8925
+ let cleanupRecoveryPrompt = null;
8926
+ interval = setInterval(() => {
8927
+ spinnerIndex++;
8928
+ drawToOutput(state, output);
8929
+ }, 80);
8930
+ const update = () => drawToOutput(state, output);
8931
+ const waitForRecoveryAction = () => {
8932
+ if (activeRecoveryPromise) {
8933
+ return activeRecoveryPromise;
8934
+ }
8935
+ activeRecoveryPromise = new Promise((resolve5) => {
8936
+ const ttyInput = input3;
8937
+ const wasRaw = ttyInput.isRaw ?? false;
8938
+ const canToggleRawMode = ttyInput.isTTY === true && typeof ttyInput.setRawMode === "function";
8939
+ if (state.recovery) {
8940
+ state.recovery.selectedAction = state.recovery.selectedAction ?? "rerun";
8941
+ drawToOutput(state, output);
8942
+ }
8943
+ emitKeypressEvents(input3);
8944
+ if (canToggleRawMode) {
8945
+ ttyInput.setRawMode(true);
8946
+ }
8947
+ const finish = (action) => {
8948
+ cleanupRecoveryPrompt?.();
8949
+ resolve5(action);
8950
+ };
8951
+ const updateSelection = (nextAction) => {
8952
+ if (!state.recovery || state.recovery.selectedAction === nextAction) {
8953
+ return;
8954
+ }
8955
+ state.recovery.selectedAction = nextAction;
8956
+ drawToOutput(state, output);
8957
+ };
8958
+ const onKeypress = (str, key) => {
8959
+ const name = key?.name ?? str;
8960
+ if (key?.ctrl && name === "c") {
8961
+ finish("quit");
8962
+ return;
8963
+ }
8964
+ if (name === "r" || name === "R") {
8965
+ finish("rerun");
8966
+ return;
8967
+ }
8968
+ if (name === "q" || name === "Q") {
8969
+ finish("quit");
8970
+ return;
8971
+ }
8972
+ if (name === "tab" || name === "left" || name === "right") {
8973
+ updateSelection(toggleRecoveryAction(state.recovery?.selectedAction ?? "rerun"));
8974
+ return;
8975
+ }
8976
+ if (name === "return" || name === "enter" || name === "space" || str === " ") {
8977
+ finish(state.recovery?.selectedAction ?? "rerun");
8978
+ }
8979
+ };
8980
+ cleanupRecoveryPrompt = () => {
8981
+ input3.off("keypress", onKeypress);
8982
+ if (canToggleRawMode) {
8983
+ ttyInput.setRawMode(wasRaw);
8984
+ }
8985
+ cleanupRecoveryPrompt = null;
8986
+ activeRecoveryPromise = null;
8987
+ };
8988
+ input3.on("keypress", onKeypress);
8989
+ });
8990
+ return activeRecoveryPromise;
8991
+ };
8992
+ const stop = () => {
8993
+ if (interval) {
8994
+ clearInterval(interval);
8995
+ interval = null;
8996
+ }
8997
+ if (activeRecoveryPromise) {
8998
+ cleanupRecoveryPrompt?.();
8999
+ }
9000
+ drawToOutput(state, output);
9001
+ };
9002
+ drawToOutput(state, output);
9003
+ return { state, update, stop, waitForRecoveryAction };
9004
+ }
9005
+
8329
9006
  // src/helpers/retry.ts
8330
9007
  init_logger();
9008
+ var DEFAULT_RETRY_COUNT = 3;
8331
9009
  async function withRetry(fn, maxRetries, options) {
8332
9010
  const maxAttempts = maxRetries + 1;
8333
9011
  const label = options?.label;
@@ -8351,6 +9029,48 @@ async function withRetry(fn, maxRetries, options) {
8351
9029
 
8352
9030
  // src/orchestrator/spec-pipeline.ts
8353
9031
  init_timeout();
9032
+
9033
+ // src/helpers/concurrency.ts
9034
+ async function runWithConcurrency(options) {
9035
+ const { items, concurrency, worker, shouldStop } = options;
9036
+ if (items.length === 0) return [];
9037
+ const limit = Math.max(1, concurrency);
9038
+ const results = new Array(items.length);
9039
+ let nextIndex = 0;
9040
+ return new Promise((resolve5) => {
9041
+ let active = 0;
9042
+ const launch = () => {
9043
+ while (active < limit && nextIndex < items.length) {
9044
+ if (shouldStop?.()) break;
9045
+ const idx = nextIndex++;
9046
+ active++;
9047
+ worker(items[idx], idx).then(
9048
+ (value) => {
9049
+ results[idx] = { status: "fulfilled", value };
9050
+ active--;
9051
+ launch();
9052
+ },
9053
+ (reason) => {
9054
+ results[idx] = { status: "rejected", reason };
9055
+ active--;
9056
+ launch();
9057
+ }
9058
+ );
9059
+ }
9060
+ if (active === 0) {
9061
+ for (let i = 0; i < results.length; i++) {
9062
+ if (!(i in results)) {
9063
+ results[i] = { status: "skipped" };
9064
+ }
9065
+ }
9066
+ resolve5(results);
9067
+ }
9068
+ };
9069
+ launch();
9070
+ });
9071
+ }
9072
+
9073
+ // src/orchestrator/spec-pipeline.ts
8354
9074
  var FETCH_TIMEOUT_MS = 3e4;
8355
9075
  async function resolveDatasource(issues, issueSource, specCwd, org, project, workItemType, iteration, area) {
8356
9076
  const source = await resolveSource(issues, issueSource, specCwd);
@@ -8493,13 +9213,14 @@ async function bootPipeline(provider, serverUrl, specCwd, model, source) {
8493
9213
  for (const line of headerLines) {
8494
9214
  console.log(line);
8495
9215
  }
8496
- console.log(chalk5.dim(" \u2500".repeat(24)));
9216
+ console.log(chalk6.dim(" \u2500".repeat(24)));
8497
9217
  console.log("");
8498
9218
  const specAgent = await boot5({ provider: instance, cwd: specCwd });
8499
9219
  return { specAgent, instance };
8500
9220
  }
8501
- async function generateSpecsBatch(validItems, items, specAgent, instance, isTrackerMode, isInlineText, datasource4, fetchOpts, outputDir, specCwd, concurrency, retries) {
9221
+ async function generateSpecsBatch(validItems, items, specAgent, instance, isTrackerMode, isInlineText, datasource4, fetchOpts, outputDir, specCwd, concurrency, retries, specWarnMs, specKillMs, tuiState, tuiUpdate) {
8502
9222
  await mkdir5(outputDir, { recursive: true });
9223
+ const quiet = !!tuiState && !log.verbose;
8503
9224
  const generatedFiles = [];
8504
9225
  const issueNumbers = [];
8505
9226
  const dispatchIdentifiers = [];
@@ -8507,140 +9228,175 @@ async function generateSpecsBatch(validItems, items, specAgent, instance, isTrac
8507
9228
  const fileDurationsMs = {};
8508
9229
  const genQueue = [...validItems];
8509
9230
  let modelLoggedInBanner = !!instance.model;
8510
- while (genQueue.length > 0) {
8511
- const batch = genQueue.splice(0, concurrency);
8512
- log.info(`Generating specs for batch of ${batch.length} (${generatedFiles.length + failed}/${items.length} done)...`);
8513
- const batchResults = await Promise.all(
8514
- batch.map(async ({ id, details }) => {
8515
- const specStart = Date.now();
8516
- if (!details) {
8517
- log.error(`Skipping item ${id}: missing issue details`);
8518
- return null;
9231
+ async function processItem({ id, details }) {
9232
+ const specStart = Date.now();
9233
+ const tuiTask = tuiState?.tasks.find((t) => t.task.file === id);
9234
+ if (tuiTask) {
9235
+ tuiTask.status = "generating";
9236
+ tuiTask.elapsed = specStart;
9237
+ tuiUpdate?.();
9238
+ }
9239
+ if (!details) {
9240
+ log.error(`Skipping item ${id}: missing issue details`);
9241
+ failed++;
9242
+ return;
9243
+ }
9244
+ const itemBody = async () => {
9245
+ let filepath;
9246
+ if (isTrackerMode) {
9247
+ const slug = slugify(details.title, MAX_SLUG_LENGTH);
9248
+ const filename = `${id}-${slug}.md`;
9249
+ filepath = join11(outputDir, filename);
9250
+ } else if (isInlineText) {
9251
+ filepath = id;
9252
+ } else {
9253
+ filepath = id;
9254
+ }
9255
+ fileLoggerStorage.getStore()?.info(`Output path: ${filepath}`);
9256
+ try {
9257
+ fileLoggerStorage.getStore()?.info(`Starting spec generation for ${isTrackerMode ? `#${id}` : filepath}`);
9258
+ if (!quiet) log.info(`Generating spec for ${isTrackerMode ? `#${id}` : filepath}: ${details.title}...`);
9259
+ const generateLabel = `specAgent.generate(${isTrackerMode ? `#${id}` : filepath})`;
9260
+ const result = await withRetry(
9261
+ () => withTimeout(
9262
+ specAgent.generate({
9263
+ issue: isTrackerMode ? details : void 0,
9264
+ filePath: isTrackerMode ? void 0 : id,
9265
+ fileContent: isTrackerMode ? void 0 : details.body,
9266
+ cwd: specCwd,
9267
+ outputPath: filepath,
9268
+ timeboxWarnMs: specWarnMs,
9269
+ timeboxKillMs: specKillMs,
9270
+ onProgress: tuiTask ? (snapshot) => {
9271
+ tuiTask.feedback = snapshot.text;
9272
+ tuiUpdate?.();
9273
+ } : void 0
9274
+ }),
9275
+ specWarnMs + specKillMs,
9276
+ generateLabel
9277
+ ),
9278
+ retries,
9279
+ { label: generateLabel }
9280
+ );
9281
+ if (!result.success) {
9282
+ throw new Error(result.error ?? "Spec generation failed");
9283
+ }
9284
+ fileLoggerStorage.getStore()?.info(`Spec generated successfully`);
9285
+ if (isTrackerMode || isInlineText) {
9286
+ const h1Title = extractTitle(result.data.content, filepath);
9287
+ const h1Slug = slugify(h1Title, MAX_SLUG_LENGTH);
9288
+ const finalFilename = isTrackerMode ? `${id}-${h1Slug}.md` : `${h1Slug}.md`;
9289
+ const finalFilepath = join11(outputDir, finalFilename);
9290
+ if (finalFilepath !== filepath) {
9291
+ await rename2(filepath, finalFilepath);
9292
+ filepath = finalFilepath;
9293
+ }
9294
+ }
9295
+ const specDuration = Date.now() - specStart;
9296
+ fileDurationsMs[filepath] = specDuration;
9297
+ if (!quiet) log.success(`Spec written: ${filepath} (${elapsed(specDuration)})`);
9298
+ if (tuiTask) {
9299
+ tuiTask.status = "done";
9300
+ tuiTask.elapsed = specDuration;
9301
+ tuiTask.feedback = void 0;
9302
+ tuiUpdate?.();
9303
+ }
9304
+ let identifier = filepath;
9305
+ fileLoggerStorage.getStore()?.phase("Datasource sync");
9306
+ if (tuiTask) {
9307
+ tuiTask.status = "syncing";
9308
+ tuiTask.feedback = void 0;
9309
+ tuiUpdate?.();
8519
9310
  }
8520
- const itemBody = async () => {
8521
- let filepath;
9311
+ try {
8522
9312
  if (isTrackerMode) {
8523
- const slug = slugify(details.title, MAX_SLUG_LENGTH);
8524
- const filename = `${id}-${slug}.md`;
8525
- filepath = join11(outputDir, filename);
8526
- } else if (isInlineText) {
8527
- filepath = id;
8528
- } else {
8529
- filepath = id;
8530
- }
8531
- fileLoggerStorage.getStore()?.info(`Output path: ${filepath}`);
8532
- try {
8533
- fileLoggerStorage.getStore()?.info(`Starting spec generation for ${isTrackerMode ? `#${id}` : filepath}`);
8534
- log.info(`Generating spec for ${isTrackerMode ? `#${id}` : filepath}: ${details.title}...`);
8535
- const result = await withRetry(
8536
- () => specAgent.generate({
8537
- issue: isTrackerMode ? details : void 0,
8538
- filePath: isTrackerMode ? void 0 : id,
8539
- fileContent: isTrackerMode ? void 0 : details.body,
8540
- cwd: specCwd,
8541
- outputPath: filepath
8542
- }),
8543
- retries,
8544
- { label: `specAgent.generate(${isTrackerMode ? `#${id}` : filepath})` }
8545
- );
8546
- if (!result.success) {
8547
- throw new Error(result.error ?? "Spec generation failed");
8548
- }
8549
- fileLoggerStorage.getStore()?.info(`Spec generated successfully`);
8550
- if (isTrackerMode || isInlineText) {
8551
- const h1Title = extractTitle(result.data.content, filepath);
8552
- const h1Slug = slugify(h1Title, MAX_SLUG_LENGTH);
8553
- const finalFilename = isTrackerMode ? `${id}-${h1Slug}.md` : `${h1Slug}.md`;
8554
- const finalFilepath = join11(outputDir, finalFilename);
8555
- if (finalFilepath !== filepath) {
8556
- await rename2(filepath, finalFilepath);
8557
- filepath = finalFilepath;
8558
- }
8559
- }
8560
- const specDuration = Date.now() - specStart;
8561
- fileDurationsMs[filepath] = specDuration;
8562
- log.success(`Spec written: ${filepath} (${elapsed(specDuration)})`);
8563
- let identifier = filepath;
8564
- fileLoggerStorage.getStore()?.phase("Datasource sync");
8565
- try {
8566
- if (isTrackerMode) {
8567
- await datasource4.update(id, details.title, result.data.content, fetchOpts);
8568
- log.success(`Updated issue #${id} with spec content`);
8569
- await unlink2(filepath);
8570
- log.success(`Deleted local spec ${filepath} (now tracked as issue #${id})`);
8571
- identifier = id;
8572
- issueNumbers.push(id);
8573
- } else if (datasource4.name === "md") {
8574
- const parsed = parseIssueFilename(filepath);
8575
- if (parsed) {
8576
- await datasource4.update(parsed.issueId, details.title, result.data.content, fetchOpts);
8577
- log.success(`Updated spec #${parsed.issueId} in-place`);
8578
- identifier = parsed.issueId;
8579
- issueNumbers.push(parsed.issueId);
8580
- } else {
8581
- const created = await datasource4.create(details.title, result.data.content, fetchOpts);
8582
- log.success(`Created spec #${created.number} from ${filepath}`);
8583
- identifier = created.number;
8584
- issueNumbers.push(created.number);
8585
- try {
8586
- await unlink2(filepath);
8587
- log.success(`Deleted local spec ${filepath} (now tracked as spec #${created.number})`);
8588
- } catch (unlinkErr) {
8589
- log.warn(`Could not delete local spec ${filepath}: ${log.formatErrorChain(unlinkErr)}`);
8590
- }
8591
- const oldDuration = fileDurationsMs[filepath];
8592
- delete fileDurationsMs[filepath];
8593
- filepath = created.url;
8594
- fileDurationsMs[filepath] = oldDuration;
8595
- }
8596
- } else {
8597
- const created = await datasource4.create(details.title, result.data.content, fetchOpts);
8598
- log.success(`Created issue #${created.number} from ${filepath}`);
9313
+ await datasource4.update(id, details.title, result.data.content, fetchOpts);
9314
+ if (!quiet) log.success(`Updated issue #${id} with spec content`);
9315
+ await unlink2(filepath);
9316
+ if (!quiet) log.success(`Deleted local spec ${filepath} (now tracked as issue #${id})`);
9317
+ identifier = id;
9318
+ issueNumbers.push(id);
9319
+ } else if (datasource4.name === "md") {
9320
+ const parsed = parseIssueFilename(filepath);
9321
+ if (parsed) {
9322
+ await datasource4.update(parsed.issueId, details.title, result.data.content, fetchOpts);
9323
+ if (!quiet) log.success(`Updated spec #${parsed.issueId} in-place`);
9324
+ identifier = parsed.issueId;
9325
+ issueNumbers.push(parsed.issueId);
9326
+ } else {
9327
+ const created = await datasource4.create(details.title, result.data.content, fetchOpts);
9328
+ if (!quiet) log.success(`Created spec #${created.number} from ${filepath}`);
9329
+ identifier = created.number;
9330
+ issueNumbers.push(created.number);
9331
+ try {
8599
9332
  await unlink2(filepath);
8600
- log.success(`Deleted local spec ${filepath} (now tracked as issue #${created.number})`);
8601
- identifier = created.number;
8602
- issueNumbers.push(created.number);
9333
+ if (!quiet) log.success(`Deleted local spec ${filepath} (now tracked as spec #${created.number})`);
9334
+ } catch (unlinkErr) {
9335
+ log.warn(`Could not delete local spec ${filepath}: ${log.formatErrorChain(unlinkErr)}`);
8603
9336
  }
8604
- } catch (err) {
8605
- const label = isTrackerMode ? `issue #${id}` : filepath;
8606
- log.warn(`Could not sync ${label} to datasource: ${log.formatErrorChain(err)}`);
9337
+ const oldDuration = fileDurationsMs[filepath];
9338
+ delete fileDurationsMs[filepath];
9339
+ filepath = created.url;
9340
+ fileDurationsMs[filepath] = oldDuration;
8607
9341
  }
8608
- return { filepath, identifier };
8609
- } catch (err) {
8610
- fileLoggerStorage.getStore()?.error(`Spec generation failed for ${id}: ${log.extractMessage(err)}${err instanceof Error && err.stack ? `
8611
- ${err.stack}` : ""}`);
8612
- log.error(`Failed to generate spec for ${isTrackerMode ? `#${id}` : filepath}: ${log.formatErrorChain(err)}`);
8613
- log.debug(log.formatErrorChain(err));
8614
- return null;
9342
+ } else {
9343
+ const created = await datasource4.create(details.title, result.data.content, fetchOpts);
9344
+ if (!quiet) log.success(`Created issue #${created.number} from ${filepath}`);
9345
+ await unlink2(filepath);
9346
+ if (!quiet) log.success(`Deleted local spec ${filepath} (now tracked as issue #${created.number})`);
9347
+ identifier = created.number;
9348
+ issueNumbers.push(created.number);
8615
9349
  }
8616
- };
8617
- const fileLogger = log.verbose ? new FileLogger(id, specCwd) : null;
8618
- if (fileLogger) {
8619
- return fileLoggerStorage.run(fileLogger, async () => {
8620
- try {
8621
- fileLogger.phase(`Spec generation: ${id}`);
8622
- return await itemBody();
8623
- } finally {
8624
- fileLogger.close();
8625
- }
8626
- });
9350
+ } catch (err) {
9351
+ const label = isTrackerMode ? `issue #${id}` : filepath;
9352
+ log.warn(`Could not sync ${label} to datasource: ${log.formatErrorChain(err)}`);
8627
9353
  }
8628
- return itemBody();
8629
- })
8630
- );
8631
- for (const result of batchResults) {
8632
- if (result !== null) {
8633
- generatedFiles.push(result.filepath);
8634
- dispatchIdentifiers.push(result.identifier);
8635
- } else {
8636
- failed++;
9354
+ return { filepath, identifier };
9355
+ } catch (err) {
9356
+ fileLoggerStorage.getStore()?.error(`Spec generation failed for ${id}: ${log.extractMessage(err)}${err instanceof Error && err.stack ? `
9357
+ ${err.stack}` : ""}`);
9358
+ log.error(`Failed to generate spec for ${isTrackerMode ? `#${id}` : filepath}: ${log.formatErrorChain(err)}`);
9359
+ log.debug(log.formatErrorChain(err));
9360
+ if (tuiTask) {
9361
+ tuiTask.status = "failed";
9362
+ tuiTask.elapsed = Date.now() - specStart;
9363
+ tuiTask.error = log.extractMessage(err);
9364
+ tuiTask.feedback = void 0;
9365
+ tuiUpdate?.();
9366
+ }
9367
+ return null;
8637
9368
  }
9369
+ };
9370
+ let itemResult;
9371
+ const fileLogger = log.verbose ? new FileLogger(id, specCwd) : null;
9372
+ if (fileLogger) {
9373
+ itemResult = await fileLoggerStorage.run(fileLogger, async () => {
9374
+ try {
9375
+ fileLogger.phase(`Spec generation: ${id}`);
9376
+ return await itemBody();
9377
+ } finally {
9378
+ fileLogger.close();
9379
+ }
9380
+ });
9381
+ } else {
9382
+ itemResult = await itemBody();
9383
+ }
9384
+ if (itemResult !== null) {
9385
+ generatedFiles.push(itemResult.filepath);
9386
+ dispatchIdentifiers.push(itemResult.identifier);
9387
+ } else {
9388
+ failed++;
8638
9389
  }
8639
9390
  if (!modelLoggedInBanner && instance.model) {
8640
- log.info(`Detected model: ${instance.model}`);
9391
+ if (!quiet) log.info(`Detected model: ${instance.model}`);
8641
9392
  modelLoggedInBanner = true;
8642
9393
  }
8643
9394
  }
9395
+ await runWithConcurrency({
9396
+ items: genQueue,
9397
+ concurrency,
9398
+ worker: async (item) => processItem(item)
9399
+ });
8644
9400
  return { generatedFiles, issueNumbers, dispatchIdentifiers, failed, fileDurationsMs };
8645
9401
  }
8646
9402
  async function cleanupPipeline(specAgent, instance) {
@@ -8687,14 +9443,18 @@ async function runSpecPipeline(opts) {
8687
9443
  area,
8688
9444
  concurrency = defaultConcurrency(),
8689
9445
  dryRun,
8690
- retries = 2
9446
+ retries = DEFAULT_RETRY_COUNT
8691
9447
  } = opts;
8692
9448
  const pipelineStart = Date.now();
9449
+ const specWarnMs = (opts.specWarnTimeout ?? DEFAULT_SPEC_WARN_MIN) * 6e4;
9450
+ const specKillMs = (opts.specKillTimeout ?? DEFAULT_SPEC_KILL_MIN) * 6e4;
9451
+ log.debug(`Spec timebox: warn=${opts.specWarnTimeout ?? DEFAULT_SPEC_WARN_MIN}m, kill=${opts.specKillTimeout ?? DEFAULT_SPEC_KILL_MIN}m, total=${specWarnMs + specKillMs}ms`);
8693
9452
  const resolved = await resolveDatasource(issues, opts.issueSource, specCwd, org, project, workItemType, iteration, area);
8694
9453
  if (!resolved) {
8695
9454
  return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
8696
9455
  }
8697
9456
  const { source, datasource: datasource4, fetchOpts } = resolved;
9457
+ await ensureAuthReady(source, specCwd, org);
8698
9458
  const isTrackerMode = isIssueNumbers(issues);
8699
9459
  const isInlineText = !isTrackerMode && !isGlobOrFilePath(issues);
8700
9460
  let items;
@@ -8724,33 +9484,85 @@ async function runSpecPipeline(opts) {
8724
9484
  return { total: 0, generated: 0, failed: 0, files: [], issueNumbers: [], durationMs: Date.now() - pipelineStart, fileDurationsMs: {} };
8725
9485
  }
8726
9486
  const { specAgent, instance } = await bootPipeline(provider, serverUrl, specCwd, model, source);
8727
- const results = await generateSpecsBatch(
8728
- validItems,
8729
- items,
8730
- specAgent,
8731
- instance,
8732
- isTrackerMode,
8733
- isInlineText,
8734
- datasource4,
8735
- fetchOpts,
8736
- outputDir,
8737
- specCwd,
8738
- concurrency,
8739
- retries
8740
- );
8741
- await cleanupPipeline(specAgent, instance);
8742
- const totalDuration = Date.now() - pipelineStart;
8743
- logSummary(results.generatedFiles, results.dispatchIdentifiers, results.failed, totalDuration);
8744
- return {
8745
- total: items.length,
8746
- generated: results.generatedFiles.length,
8747
- failed: results.failed,
8748
- files: results.generatedFiles,
8749
- issueNumbers: results.issueNumbers,
8750
- identifiers: results.dispatchIdentifiers,
8751
- durationMs: totalDuration,
8752
- fileDurationsMs: results.fileDurationsMs
8753
- };
9487
+ const verbose = log.verbose;
9488
+ let tui;
9489
+ if (verbose) {
9490
+ const state = {
9491
+ tasks: [],
9492
+ phase: "booting",
9493
+ mode: "spec",
9494
+ startTime: Date.now(),
9495
+ filesFound: 0,
9496
+ provider,
9497
+ model: instance.model,
9498
+ source
9499
+ };
9500
+ tui = {
9501
+ state,
9502
+ update: () => {
9503
+ },
9504
+ stop: () => {
9505
+ },
9506
+ waitForRecoveryAction: async () => "quit"
9507
+ };
9508
+ } else {
9509
+ tui = createTui();
9510
+ tui.state.mode = "spec";
9511
+ tui.state.provider = provider;
9512
+ tui.state.model = instance.model;
9513
+ tui.state.source = source;
9514
+ }
9515
+ tui.state.tasks = validItems.map((item, index) => ({
9516
+ task: {
9517
+ index,
9518
+ text: item.details?.title ?? `Item ${item.id}`,
9519
+ line: 0,
9520
+ raw: item.details?.title ?? item.id,
9521
+ file: item.id
9522
+ },
9523
+ status: "pending"
9524
+ }));
9525
+ tui.state.phase = "dispatching";
9526
+ tui.update();
9527
+ try {
9528
+ const results = await generateSpecsBatch(
9529
+ validItems,
9530
+ items,
9531
+ specAgent,
9532
+ instance,
9533
+ isTrackerMode,
9534
+ isInlineText,
9535
+ datasource4,
9536
+ fetchOpts,
9537
+ outputDir,
9538
+ specCwd,
9539
+ concurrency,
9540
+ retries,
9541
+ specWarnMs,
9542
+ specKillMs,
9543
+ tui.state,
9544
+ tui.update
9545
+ );
9546
+ await cleanupPipeline(specAgent, instance);
9547
+ tui.state.phase = "done";
9548
+ tui.stop();
9549
+ const totalDuration = Date.now() - pipelineStart;
9550
+ logSummary(results.generatedFiles, results.dispatchIdentifiers, results.failed, totalDuration);
9551
+ return {
9552
+ total: items.length,
9553
+ generated: results.generatedFiles.length,
9554
+ failed: results.failed,
9555
+ files: results.generatedFiles,
9556
+ issueNumbers: results.issueNumbers,
9557
+ identifiers: results.dispatchIdentifiers,
9558
+ durationMs: totalDuration,
9559
+ fileDurationsMs: results.fileDurationsMs
9560
+ };
9561
+ } catch (err) {
9562
+ tui.state.phase = "done";
9563
+ tui.stop();
9564
+ throw err;
9565
+ }
8754
9566
  }
8755
9567
 
8756
9568
  // src/orchestrator/dispatch-pipeline.ts
@@ -9296,268 +10108,6 @@ function formatOutputFile(parsed) {
9296
10108
  // src/orchestrator/dispatch-pipeline.ts
9297
10109
  init_logger();
9298
10110
  init_cleanup();
9299
-
9300
- // src/tui.ts
9301
- import chalk6 from "chalk";
9302
- var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
9303
- var BAR_WIDTH = 30;
9304
- var spinnerIndex = 0;
9305
- var interval = null;
9306
- var lastLineCount = 0;
9307
- function spinner() {
9308
- return chalk6.cyan(SPINNER_FRAMES[spinnerIndex % SPINNER_FRAMES.length]);
9309
- }
9310
- function progressBar(done, total) {
9311
- if (total === 0) return chalk6.dim("\u2591".repeat(BAR_WIDTH));
9312
- const filled = Math.round(done / total * BAR_WIDTH);
9313
- const empty = BAR_WIDTH - filled;
9314
- const pct = Math.round(done / total * 100);
9315
- return chalk6.green("\u2588".repeat(filled)) + chalk6.dim("\u2591".repeat(empty)) + chalk6.white(` ${pct}%`);
9316
- }
9317
- function statusIcon(status) {
9318
- switch (status) {
9319
- case "pending":
9320
- return chalk6.dim("\u25CB");
9321
- case "planning":
9322
- return spinner();
9323
- case "running":
9324
- return spinner();
9325
- case "done":
9326
- return chalk6.green("\u25CF");
9327
- case "failed":
9328
- return chalk6.red("\u2716");
9329
- }
9330
- }
9331
- function statusLabel(status) {
9332
- switch (status) {
9333
- case "pending":
9334
- return chalk6.dim("pending");
9335
- case "planning":
9336
- return chalk6.magenta("planning");
9337
- case "running":
9338
- return chalk6.cyan("executing");
9339
- case "done":
9340
- return chalk6.green("done");
9341
- case "failed":
9342
- return chalk6.red("failed");
9343
- }
9344
- }
9345
- function phaseLabel(phase, provider) {
9346
- switch (phase) {
9347
- case "discovering":
9348
- return `${spinner()} Discovering task files...`;
9349
- case "parsing":
9350
- return `${spinner()} Parsing tasks...`;
9351
- case "booting": {
9352
- const name = provider ?? "provider";
9353
- return `${spinner()} Connecting to ${name}...`;
9354
- }
9355
- case "dispatching":
9356
- return `${spinner()} Dispatching tasks...`;
9357
- case "done":
9358
- return chalk6.green("\u2714") + " Complete";
9359
- }
9360
- }
9361
- function countVisualRows(text, cols) {
9362
- const stripped = text.replace(/\x1B\[[0-9;]*m/g, "");
9363
- const safeCols = Math.max(1, cols);
9364
- return stripped.split("\n").reduce((sum, line) => {
9365
- return sum + Math.max(1, Math.ceil(line.length / safeCols));
9366
- }, 0);
9367
- }
9368
- function render(state) {
9369
- const lines = [];
9370
- const now = Date.now();
9371
- const totalElapsed = elapsed(now - state.startTime);
9372
- const done = state.tasks.filter((t) => t.status === "done").length;
9373
- const failed = state.tasks.filter((t) => t.status === "failed").length;
9374
- const total = state.tasks.length;
9375
- lines.push("");
9376
- lines.push(
9377
- ...renderHeaderLines({
9378
- provider: state.provider,
9379
- model: state.model,
9380
- source: state.source
9381
- })
9382
- );
9383
- if (state.currentIssue) {
9384
- lines.push(
9385
- chalk6.dim(` issue: `) + chalk6.white(`#${state.currentIssue.number}`) + chalk6.dim(` \u2014 ${state.currentIssue.title}`)
9386
- );
9387
- }
9388
- lines.push(chalk6.dim(" \u2500".repeat(24)));
9389
- if (state.notification) {
9390
- lines.push("");
9391
- for (const notifLine of state.notification.split("\n")) {
9392
- lines.push(" " + chalk6.yellowBright("\u26A0 ") + chalk6.yellow(notifLine));
9393
- }
9394
- }
9395
- lines.push(` ${phaseLabel(state.phase, state.provider)}` + chalk6.dim(` ${totalElapsed}`));
9396
- if (state.phase === "dispatching" || state.phase === "done") {
9397
- lines.push("");
9398
- lines.push(` ${progressBar(done + failed, total)} ${chalk6.dim(`${done + failed}/${total} tasks`)}`);
9399
- lines.push("");
9400
- const activeWorktrees = new Set(
9401
- state.tasks.map((t) => t.worktree).filter(Boolean)
9402
- );
9403
- const showWorktree = activeWorktrees.size > 1;
9404
- const cols = process.stdout.columns || 80;
9405
- const maxTextLen = cols - 30;
9406
- const running = state.tasks.filter((t) => t.status === "running" || t.status === "planning");
9407
- const completed = state.tasks.filter(
9408
- (t) => t.status === "done" || t.status === "failed"
9409
- );
9410
- const pending = state.tasks.filter((t) => t.status === "pending");
9411
- if (showWorktree) {
9412
- const groups = /* @__PURE__ */ new Map();
9413
- const ungrouped = [];
9414
- for (const ts of state.tasks) {
9415
- if (ts.worktree) {
9416
- const arr = groups.get(ts.worktree) ?? [];
9417
- arr.push(ts);
9418
- groups.set(ts.worktree, arr);
9419
- } else {
9420
- ungrouped.push(ts);
9421
- }
9422
- }
9423
- const doneGroups = [];
9424
- const activeGroups = [];
9425
- for (const [wt, tasks] of groups) {
9426
- const allDone = tasks.every((t) => t.status === "done" || t.status === "failed");
9427
- if (allDone) {
9428
- doneGroups.push([wt, tasks]);
9429
- } else {
9430
- activeGroups.push([wt, tasks]);
9431
- }
9432
- }
9433
- if (doneGroups.length > 3) {
9434
- lines.push(chalk6.dim(` \xB7\xB7\xB7 ${doneGroups.length - 3} earlier issue(s) completed`));
9435
- }
9436
- for (const [wt, tasks] of doneGroups.slice(-3)) {
9437
- const issueNum = wt.match(/^(\d+)/)?.[1] ?? wt.slice(0, 12);
9438
- const anyFailed = tasks.some((t) => t.status === "failed");
9439
- const icon = anyFailed ? chalk6.red("\u2716") : chalk6.green("\u25CF");
9440
- const doneCount = tasks.filter((t) => t.status === "done").length;
9441
- const maxElapsed = Math.max(...tasks.map((t) => t.elapsed ?? 0));
9442
- lines.push(` ${icon} ${chalk6.dim(`#${issueNum}`)} ${chalk6.dim(`${doneCount}/${tasks.length} tasks`)} ${chalk6.dim(elapsed(maxElapsed))}`);
9443
- }
9444
- for (const [wt, tasks] of activeGroups) {
9445
- const issueNum = wt.match(/^(\d+)/)?.[1] ?? wt.slice(0, 12);
9446
- const activeTasks = tasks.filter((t) => t.status === "running" || t.status === "planning");
9447
- const activeCount = activeTasks.length;
9448
- const firstActive = activeTasks[0];
9449
- const truncLen = Math.min(cols - 26, 60);
9450
- let text = firstActive?.task.text ?? "";
9451
- if (text.length > truncLen) {
9452
- text = text.slice(0, truncLen - 1) + "\u2026";
9453
- }
9454
- const earliest = Math.min(...activeTasks.map((t) => t.elapsed ?? now));
9455
- const elapsedStr = elapsed(now - earliest);
9456
- lines.push(` ${spinner()} ${chalk6.white(`#${issueNum}`)} ${activeCount} active ${text} ${chalk6.dim(elapsedStr)}`);
9457
- }
9458
- for (const ts of ungrouped) {
9459
- if (ts.status !== "running" && ts.status !== "planning") continue;
9460
- const icon = statusIcon(ts.status);
9461
- const idx = chalk6.dim(`#${state.tasks.indexOf(ts) + 1}`);
9462
- let text = ts.task.text;
9463
- if (text.length > maxTextLen) {
9464
- text = text.slice(0, maxTextLen - 1) + "\u2026";
9465
- }
9466
- const elapsedStr = chalk6.dim(` ${elapsed(now - (ts.elapsed || now))}`);
9467
- const label = statusLabel(ts.status);
9468
- lines.push(` ${icon} ${idx} ${text} ${label}${elapsedStr}`);
9469
- if (ts.error) {
9470
- lines.push(chalk6.red(` \u2514\u2500 ${ts.error}`));
9471
- }
9472
- }
9473
- } else {
9474
- const visibleRunning = running.slice(0, 8);
9475
- const visible = [
9476
- ...completed.slice(-3),
9477
- ...visibleRunning,
9478
- ...pending.slice(0, 3)
9479
- ];
9480
- if (completed.length > 3) {
9481
- lines.push(chalk6.dim(` \xB7\xB7\xB7 ${completed.length - 3} earlier task(s) completed`));
9482
- }
9483
- for (const ts of visible) {
9484
- const icon = statusIcon(ts.status);
9485
- const idx = chalk6.dim(`#${state.tasks.indexOf(ts) + 1}`);
9486
- let text = ts.task.text;
9487
- if (text.length > maxTextLen) {
9488
- text = text.slice(0, maxTextLen - 1) + "\u2026";
9489
- }
9490
- const elapsedStr = ts.status === "running" || ts.status === "planning" ? chalk6.dim(` ${elapsed(now - (ts.elapsed || now))}`) : ts.status === "done" && ts.elapsed ? chalk6.dim(` ${elapsed(ts.elapsed)}`) : "";
9491
- const label = statusLabel(ts.status);
9492
- lines.push(` ${icon} ${idx} ${text} ${label}${elapsedStr}`);
9493
- if (ts.error) {
9494
- lines.push(chalk6.red(` \u2514\u2500 ${ts.error}`));
9495
- }
9496
- }
9497
- if (running.length > 8) {
9498
- lines.push(chalk6.dim(` \xB7\xB7\xB7 ${running.length - 8} more running`));
9499
- }
9500
- if (pending.length > 3) {
9501
- lines.push(chalk6.dim(` \xB7\xB7\xB7 ${pending.length - 3} more task(s) pending`));
9502
- }
9503
- }
9504
- lines.push("");
9505
- const parts = [];
9506
- if (done > 0) parts.push(chalk6.green(`${done} passed`));
9507
- if (failed > 0) parts.push(chalk6.red(`${failed} failed`));
9508
- if (total - done - failed > 0)
9509
- parts.push(chalk6.dim(`${total - done - failed} remaining`));
9510
- lines.push(` ${parts.join(chalk6.dim(" \xB7 "))}`);
9511
- } else if (state.filesFound > 0) {
9512
- lines.push(chalk6.dim(` Found ${state.filesFound} file(s)`));
9513
- }
9514
- lines.push("");
9515
- return lines.join("\n");
9516
- }
9517
- function draw(state) {
9518
- const output = render(state);
9519
- const cols = process.stdout.columns || 80;
9520
- const newLineCount = countVisualRows(output, cols);
9521
- let buffer = "";
9522
- if (lastLineCount > 0) {
9523
- buffer += `\x1B[${lastLineCount}A`;
9524
- }
9525
- const lines = output.split("\n");
9526
- buffer += lines.map((line) => line + "\x1B[K").join("\n");
9527
- const leftover = lastLineCount - newLineCount;
9528
- if (leftover > 0) {
9529
- for (let i = 0; i < leftover; i++) {
9530
- buffer += "\n\x1B[K";
9531
- }
9532
- buffer += `\x1B[${leftover}A`;
9533
- }
9534
- process.stdout.write(buffer);
9535
- lastLineCount = newLineCount;
9536
- }
9537
- function createTui() {
9538
- const state = {
9539
- tasks: [],
9540
- phase: "discovering",
9541
- startTime: Date.now(),
9542
- filesFound: 0
9543
- };
9544
- interval = setInterval(() => {
9545
- spinnerIndex++;
9546
- draw(state);
9547
- }, 80);
9548
- const update = () => draw(state);
9549
- const stop = () => {
9550
- if (interval) {
9551
- clearInterval(interval);
9552
- interval = null;
9553
- }
9554
- draw(state);
9555
- };
9556
- draw(state);
9557
- return { state, update, stop };
9558
- }
9559
-
9560
- // src/orchestrator/dispatch-pipeline.ts
9561
10111
  init_providers();
9562
10112
  init_timeout();
9563
10113
  import chalk7 from "chalk";
@@ -9591,12 +10141,10 @@ async function resolveGlobItems(patterns, cwd) {
9591
10141
  }
9592
10142
  return items;
9593
10143
  }
9594
- var DEFAULT_PLAN_TIMEOUT_MIN = 10;
9595
- var DEFAULT_PLAN_RETRIES = 1;
9596
10144
  async function runDispatchPipeline(opts, cwd) {
9597
10145
  const {
9598
10146
  issueIds,
9599
- concurrency,
10147
+ concurrency = 1,
9600
10148
  dryRun,
9601
10149
  serverUrl,
9602
10150
  noPlan,
@@ -9613,36 +10161,22 @@ async function runDispatchPipeline(opts, cwd) {
9613
10161
  area,
9614
10162
  planTimeout,
9615
10163
  planRetries,
9616
- retries
10164
+ retries,
10165
+ username: usernameOverride
9617
10166
  } = opts;
9618
10167
  let noBranch = noBranchOpt;
9619
- const planTimeoutMs = (planTimeout ?? DEFAULT_PLAN_TIMEOUT_MIN) * 6e4;
9620
- const maxPlanAttempts = (planRetries ?? retries ?? DEFAULT_PLAN_RETRIES) + 1;
9621
- log.debug(`Plan timeout: ${planTimeout ?? DEFAULT_PLAN_TIMEOUT_MIN}m (${planTimeoutMs}ms), max attempts: ${maxPlanAttempts}`);
10168
+ const resolvedRetries = retries ?? DEFAULT_RETRY_COUNT;
10169
+ const resolvedPlanTimeoutMin = planTimeout ?? DEFAULT_PLAN_TIMEOUT_MIN;
10170
+ const planTimeoutMs = resolvedPlanTimeoutMin * 6e4;
10171
+ const resolvedPlanRetries = planRetries ?? resolvedRetries;
10172
+ const maxPlanAttempts = resolvedPlanRetries + 1;
10173
+ log.debug(`Plan timeout: ${resolvedPlanTimeoutMin}m (${planTimeoutMs}ms), max attempts: ${maxPlanAttempts}`);
9622
10174
  if (dryRun) {
9623
- return dryRunMode(issueIds, cwd, source, org, project, workItemType, iteration, area);
9624
- }
9625
- if (source === "github") {
9626
- const remoteUrl = await getGitRemoteUrl(cwd);
9627
- if (remoteUrl && parseGitHubRemoteUrl(remoteUrl)) {
9628
- await getGithubOctokit();
9629
- } else if (!remoteUrl) {
9630
- log.warn("No git remote found \u2014 skipping GitHub pre-authentication");
9631
- } else {
9632
- log.warn("Remote URL is not a GitHub repository \u2014 skipping GitHub pre-authentication");
9633
- }
9634
- } else if (source === "azdevops") {
9635
- let orgUrl = org;
9636
- if (!orgUrl) {
9637
- const remoteUrl = await getGitRemoteUrl(cwd);
9638
- if (remoteUrl) {
9639
- const parsed = parseAzDevOpsRemoteUrl(remoteUrl);
9640
- if (parsed) orgUrl = parsed.orgUrl;
9641
- }
9642
- }
9643
- if (orgUrl) await getAzureConnection(orgUrl);
10175
+ return dryRunMode(issueIds, cwd, source, org, project, workItemType, iteration, area, usernameOverride);
9644
10176
  }
10177
+ await ensureAuthReady(source, cwd, org);
9645
10178
  const verbose = log.verbose;
10179
+ const canRecoverInteractively = !verbose && process.stdin.isTTY === true && process.stdout.isTTY === true;
9646
10180
  let tui;
9647
10181
  if (verbose) {
9648
10182
  const headerLines = renderHeaderLines({ provider, source });
@@ -9659,9 +10193,14 @@ async function runDispatchPipeline(opts, cwd) {
9659
10193
  provider,
9660
10194
  source
9661
10195
  };
9662
- tui = { state, update: () => {
9663
- }, stop: () => {
9664
- } };
10196
+ tui = {
10197
+ state,
10198
+ update: () => {
10199
+ },
10200
+ stop: () => {
10201
+ },
10202
+ waitForRecoveryAction: async () => "quit"
10203
+ };
9665
10204
  } else {
9666
10205
  tui = createTui();
9667
10206
  tui.state.provider = provider;
@@ -9702,7 +10241,6 @@ async function runDispatchPipeline(opts, cwd) {
9702
10241
  setAuthPromptHandler(null);
9703
10242
  if (items.length === 0) {
9704
10243
  tui.state.phase = "done";
9705
- setAuthPromptHandler(null);
9706
10244
  tui.stop();
9707
10245
  const label = issueIds.length > 0 ? `issue(s) ${issueIds.join(", ")}` : `datasource: ${source}`;
9708
10246
  log.warn("No work items found from " + label);
@@ -9727,7 +10265,6 @@ async function runDispatchPipeline(opts, cwd) {
9727
10265
  }
9728
10266
  if (allTasks.length === 0) {
9729
10267
  tui.state.phase = "done";
9730
- setAuthPromptHandler(null);
9731
10268
  tui.stop();
9732
10269
  log.warn("No unchecked tasks found");
9733
10270
  return { total: 0, completed: 0, failed: 0, skipped: 0, results: [] };
@@ -9767,9 +10304,8 @@ async function runDispatchPipeline(opts, cwd) {
9767
10304
  tui.state.phase = "dispatching";
9768
10305
  if (verbose) log.info(`Dispatching ${allTasks.length} task(s)...`);
9769
10306
  const results = [];
9770
- let completed = 0;
9771
- let failed = 0;
9772
- const lifecycleOpts = { cwd };
10307
+ let halted = false;
10308
+ const lifecycleOpts = { cwd, username: usernameOverride };
9773
10309
  const startingBranch = await datasource4.getCurrentBranch(lifecycleOpts);
9774
10310
  let featureBranchName;
9775
10311
  let featureDefaultBranch;
@@ -9829,6 +10365,15 @@ async function runDispatchPipeline(opts, cwd) {
9829
10365
  let branchName;
9830
10366
  let worktreePath;
9831
10367
  let issueCwd = cwd;
10368
+ let preserveContext = false;
10369
+ const upsertResult = (collection, result) => {
10370
+ const index = collection.findIndex((entry) => entry.task === result.task);
10371
+ if (index >= 0) {
10372
+ collection[index] = result;
10373
+ } else {
10374
+ collection.push(result);
10375
+ }
10376
+ };
9832
10377
  if (!noBranch && details) {
9833
10378
  fileLogger?.phase("Branch/worktree setup");
9834
10379
  try {
@@ -9863,14 +10408,13 @@ ${err.stack}` : ""}`);
9863
10408
  tuiTask.status = "failed";
9864
10409
  tuiTask.error = errorMsg;
9865
10410
  }
9866
- results.push({ task, success: false, error: errorMsg });
10411
+ upsertResult(results, { task, success: false, error: errorMsg });
9867
10412
  }
9868
- failed += fileTasks.length;
9869
- return;
10413
+ return { halted: false };
9870
10414
  }
9871
10415
  }
9872
10416
  const worktreeRoot = useWorktrees ? worktreePath : void 0;
9873
- const issueLifecycleOpts = { cwd: issueCwd };
10417
+ const issueLifecycleOpts = { cwd: issueCwd, username: usernameOverride };
9874
10418
  fileLogger?.phase("Provider/agent boot");
9875
10419
  let localInstance;
9876
10420
  let localPlanner;
@@ -9893,286 +10437,345 @@ ${err.stack}` : ""}`);
9893
10437
  localExecutor = executor;
9894
10438
  localCommitAgent = commitAgent;
9895
10439
  }
9896
- const groups = groupTasksByMode(fileTasks);
9897
10440
  const issueResults = [];
9898
- for (const group of groups) {
9899
- const groupQueue = [...group];
9900
- while (groupQueue.length > 0) {
9901
- const batch = groupQueue.splice(0, concurrency);
9902
- const batchResults = await Promise.all(
9903
- batch.map(async (task) => {
9904
- const tuiTask = tui.state.tasks.find((t) => t.task === task);
9905
- const startTime = Date.now();
9906
- tuiTask.elapsed = startTime;
9907
- let plan;
9908
- if (localPlanner) {
9909
- tuiTask.status = "planning";
9910
- fileLogger?.phase(`Planning task: ${task.text}`);
9911
- if (verbose) log.info(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: planning \u2014 "${task.text}"`);
9912
- const rawContent = fileContentMap.get(task.file);
9913
- const fileContext = rawContent ? buildTaskContext(rawContent, task) : void 0;
9914
- let planResult;
9915
- for (let attempt = 1; attempt <= maxPlanAttempts; attempt++) {
9916
- try {
9917
- planResult = await withTimeout(
9918
- localPlanner.plan(task, fileContext, issueCwd, worktreeRoot),
9919
- planTimeoutMs,
9920
- "planner.plan()"
9921
- );
9922
- break;
9923
- } catch (err) {
9924
- if (err instanceof TimeoutError) {
9925
- log.warn(
9926
- `Planning timed out for task "${task.text}" (attempt ${attempt}/${maxPlanAttempts})`
9927
- );
9928
- fileLogger?.warn(`Planning timeout (attempt ${attempt}/${maxPlanAttempts})`);
9929
- if (attempt < maxPlanAttempts) {
9930
- log.debug(`Retrying planning (attempt ${attempt + 1}/${maxPlanAttempts})`);
9931
- fileLogger?.info(`Retrying planning (attempt ${attempt + 1}/${maxPlanAttempts})`);
9932
- }
9933
- } else {
9934
- planResult = {
9935
- data: null,
9936
- success: false,
9937
- error: log.extractMessage(err),
9938
- durationMs: 0
9939
- };
9940
- break;
9941
- }
9942
- }
9943
- }
9944
- if (!planResult) {
9945
- const timeoutMin = planTimeout ?? 10;
9946
- planResult = {
9947
- data: null,
9948
- success: false,
9949
- error: `Planning timed out after ${timeoutMin}m (${maxPlanAttempts} attempts)`,
9950
- durationMs: 0
9951
- };
9952
- }
9953
- if (!planResult.success) {
9954
- tuiTask.status = "failed";
9955
- tuiTask.error = `Planning failed: ${planResult.error}`;
9956
- fileLogger?.error(`Planning failed: ${planResult.error}`);
9957
- tuiTask.elapsed = Date.now() - startTime;
9958
- if (verbose) log.error(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: failed \u2014 ${tuiTask.error} (${elapsed(tuiTask.elapsed)})`);
9959
- failed++;
9960
- return { task, success: false, error: tuiTask.error };
9961
- }
9962
- plan = planResult.data.prompt;
9963
- fileLogger?.info(`Planning completed (${planResult.durationMs ?? 0}ms)`);
9964
- }
9965
- tuiTask.status = "running";
9966
- fileLogger?.phase(`Executing task: ${task.text}`);
9967
- if (verbose) log.info(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: executing \u2014 "${task.text}"`);
9968
- const execRetries = 2;
9969
- const execResult = await withRetry(
9970
- async () => {
9971
- const result = await localExecutor.execute({
9972
- task,
9973
- cwd: issueCwd,
9974
- plan: plan ?? null,
9975
- worktreeRoot
9976
- });
9977
- if (!result.success) {
9978
- throw new Error(result.error ?? "Execution failed");
9979
- }
9980
- return result;
9981
- },
9982
- execRetries,
9983
- { label: `executor "${task.text}"` }
9984
- ).catch((err) => ({
9985
- data: null,
9986
- success: false,
9987
- error: log.extractMessage(err),
9988
- durationMs: 0
9989
- }));
9990
- if (execResult.success) {
9991
- fileLogger?.info(`Execution completed successfully (${Date.now() - startTime}ms)`);
9992
- try {
9993
- const parsed = parseIssueFilename(task.file);
9994
- const updatedContent = await readFile8(task.file, "utf-8");
9995
- if (parsed) {
9996
- const issueDetails = issueDetailsByFile.get(task.file);
9997
- const title = issueDetails?.title ?? parsed.slug;
9998
- await datasource4.update(parsed.issueId, title, updatedContent, fetchOpts);
9999
- log.success(`Synced task completion to issue #${parsed.issueId}`);
10000
- } else {
10001
- const issueDetails = issueDetailsByFile.get(task.file);
10002
- if (issueDetails) {
10003
- await datasource4.update(issueDetails.number, issueDetails.title, updatedContent, fetchOpts);
10004
- log.success(`Synced task completion to issue #${issueDetails.number}`);
10005
- }
10006
- }
10007
- } catch (err) {
10008
- log.warn(`Could not sync task completion to datasource: ${log.formatErrorChain(err)}`);
10441
+ const pauseTask = (task, error) => {
10442
+ const tuiTask = tui.state.tasks.find((entry) => entry.task === task);
10443
+ tuiTask.status = "paused";
10444
+ tuiTask.error = error;
10445
+ tui.state.phase = "paused";
10446
+ tui.state.recovery = {
10447
+ taskIndex: tui.state.tasks.indexOf(tuiTask),
10448
+ taskText: task.text,
10449
+ error,
10450
+ issue: details ? { number: details.number, title: details.title } : void 0,
10451
+ worktree: tuiTask.worktree ?? worktreeRoot,
10452
+ selectedAction: "rerun"
10453
+ };
10454
+ tui.update();
10455
+ return tuiTask;
10456
+ };
10457
+ const clearRecovery = () => {
10458
+ tui.state.recovery = void 0;
10459
+ tui.state.phase = "dispatching";
10460
+ tui.update();
10461
+ };
10462
+ const runTaskLifecycle = async (task) => {
10463
+ const tuiTask = tui.state.tasks.find((entry) => entry.task === task);
10464
+ const startTime = Date.now();
10465
+ let plan;
10466
+ tuiTask.elapsed = startTime;
10467
+ tuiTask.error = void 0;
10468
+ if (localPlanner) {
10469
+ tuiTask.status = "planning";
10470
+ fileLogger?.phase(`Planning task: ${task.text}`);
10471
+ if (verbose) log.info(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: planning \u2014 "${task.text}"`);
10472
+ const rawContent = fileContentMap.get(task.file);
10473
+ const fileContext = rawContent ? buildTaskContext(rawContent, task) : void 0;
10474
+ let planResult;
10475
+ for (let attempt = 1; attempt <= maxPlanAttempts; attempt++) {
10476
+ try {
10477
+ planResult = await withTimeout(
10478
+ localPlanner.plan(task, fileContext, issueCwd, worktreeRoot),
10479
+ planTimeoutMs,
10480
+ "planner.plan()"
10481
+ );
10482
+ break;
10483
+ } catch (err) {
10484
+ if (err instanceof TimeoutError) {
10485
+ log.warn(`Planning timed out for task "${task.text}" (attempt ${attempt}/${maxPlanAttempts})`);
10486
+ fileLogger?.warn(`Planning timeout (attempt ${attempt}/${maxPlanAttempts})`);
10487
+ if (attempt < maxPlanAttempts) {
10488
+ log.debug(`Retrying planning (attempt ${attempt + 1}/${maxPlanAttempts})`);
10489
+ fileLogger?.info(`Retrying planning (attempt ${attempt + 1}/${maxPlanAttempts})`);
10009
10490
  }
10010
- tuiTask.status = "done";
10011
- tuiTask.elapsed = Date.now() - startTime;
10012
- if (verbose) log.success(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: done \u2014 "${task.text}" (${elapsed(tuiTask.elapsed)})`);
10013
- completed++;
10014
10491
  } else {
10015
- fileLogger?.error(`Execution failed: ${execResult.error}`);
10016
- tuiTask.status = "failed";
10017
- tuiTask.error = execResult.error;
10018
- tuiTask.elapsed = Date.now() - startTime;
10019
- if (verbose) log.error(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: failed \u2014 "${task.text}" (${elapsed(tuiTask.elapsed)})${tuiTask.error ? `: ${tuiTask.error}` : ""}`);
10020
- failed++;
10492
+ planResult = {
10493
+ data: null,
10494
+ success: false,
10495
+ error: log.extractMessage(err),
10496
+ durationMs: 0
10497
+ };
10498
+ break;
10021
10499
  }
10022
- const dispatchResult = execResult.success ? execResult.data.dispatchResult : {
10023
- task,
10024
- success: false,
10025
- error: execResult.error ?? "Executor failed without returning a dispatch result."
10026
- };
10027
- return dispatchResult;
10028
- })
10029
- );
10030
- issueResults.push(...batchResults);
10031
- if (!tui.state.model && localInstance.model) {
10032
- tui.state.model = localInstance.model;
10500
+ }
10033
10501
  }
10502
+ if (!planResult) {
10503
+ planResult = {
10504
+ data: null,
10505
+ success: false,
10506
+ error: `Planning timed out after ${resolvedPlanTimeoutMin}m (${maxPlanAttempts} attempts)`,
10507
+ durationMs: 0
10508
+ };
10509
+ }
10510
+ if (!planResult.success) {
10511
+ const error = `Planning failed: ${planResult.error}`;
10512
+ fileLogger?.error(error);
10513
+ tuiTask.elapsed = Date.now() - startTime;
10514
+ pauseTask(task, error);
10515
+ if (verbose) log.error(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: paused \u2014 ${error} (${elapsed(tuiTask.elapsed)})`);
10516
+ return { kind: "paused", error };
10517
+ }
10518
+ plan = planResult.data.prompt;
10519
+ fileLogger?.info(`Planning completed (${planResult.durationMs ?? 0}ms)`);
10034
10520
  }
10035
- }
10036
- results.push(...issueResults);
10037
- if (!noBranch && branchName && defaultBranch && details && datasource4.supportsGit()) {
10038
- try {
10039
- await datasource4.commitAllChanges(
10040
- `chore: stage uncommitted changes for issue #${details.number}`,
10041
- issueLifecycleOpts
10042
- );
10043
- log.debug(`Staged uncommitted changes for issue #${details.number}`);
10044
- } catch (err) {
10045
- log.warn(`Could not commit uncommitted changes for issue #${details.number}: ${log.formatErrorChain(err)}`);
10046
- }
10047
- }
10048
- fileLogger?.phase("Commit generation");
10049
- let commitAgentResult;
10050
- if (!noBranch && branchName && defaultBranch && details && datasource4.supportsGit()) {
10051
- try {
10052
- const branchDiff = await getBranchDiff(defaultBranch, issueCwd);
10053
- if (branchDiff) {
10054
- const result = await localCommitAgent.generate({
10055
- branchDiff,
10056
- issue: details,
10057
- taskResults: issueResults,
10521
+ tuiTask.status = "running";
10522
+ fileLogger?.phase(`Executing task: ${task.text}`);
10523
+ if (verbose) log.info(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: executing \u2014 "${task.text}"`);
10524
+ const execResult = await withRetry(
10525
+ async () => {
10526
+ const result = await localExecutor.execute({
10527
+ task,
10058
10528
  cwd: issueCwd,
10529
+ plan: plan ?? null,
10059
10530
  worktreeRoot
10060
10531
  });
10061
- if (result.success) {
10062
- commitAgentResult = result;
10063
- fileLogger?.info(`Commit message generated for issue #${details.number}`);
10064
- try {
10065
- await squashBranchCommits(defaultBranch, result.commitMessage, issueCwd);
10066
- log.debug(`Rewrote commit message for issue #${details.number}`);
10067
- fileLogger?.info(`Rewrote commit history for issue #${details.number}`);
10068
- } catch (err) {
10069
- log.warn(`Could not rewrite commit message for issue #${details.number}: ${log.formatErrorChain(err)}`);
10070
- }
10071
- } else {
10072
- log.warn(`Commit agent failed for issue #${details.number}: ${result.error}`);
10073
- fileLogger?.warn(`Commit agent failed: ${result.error}`);
10532
+ if (!result.success) {
10533
+ throw new Error(result.error ?? "Execution failed");
10534
+ }
10535
+ return result;
10536
+ },
10537
+ resolvedRetries,
10538
+ { label: `executor "${task.text}"` }
10539
+ ).catch((err) => ({
10540
+ data: null,
10541
+ success: false,
10542
+ error: log.extractMessage(err),
10543
+ durationMs: 0
10544
+ }));
10545
+ if (!execResult.success) {
10546
+ const error = execResult.error ?? "Executor failed without returning a dispatch result.";
10547
+ fileLogger?.error(`Execution failed: ${error}`);
10548
+ tuiTask.elapsed = Date.now() - startTime;
10549
+ pauseTask(task, error);
10550
+ if (verbose) log.error(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: paused \u2014 "${task.text}" (${elapsed(tuiTask.elapsed)})${error ? `: ${error}` : ""}`);
10551
+ return { kind: "paused", error };
10552
+ }
10553
+ fileLogger?.info(`Execution completed successfully (${Date.now() - startTime}ms)`);
10554
+ try {
10555
+ const parsed = parseIssueFilename(task.file);
10556
+ const updatedContent = await readFile8(task.file, "utf-8");
10557
+ if (parsed) {
10558
+ const issueDetails = issueDetailsByFile.get(task.file);
10559
+ const title = issueDetails?.title ?? parsed.slug;
10560
+ await datasource4.update(parsed.issueId, title, updatedContent, fetchOpts);
10561
+ log.success(`Synced task completion to issue #${parsed.issueId}`);
10562
+ } else {
10563
+ const issueDetails = issueDetailsByFile.get(task.file);
10564
+ if (issueDetails) {
10565
+ await datasource4.update(issueDetails.number, issueDetails.title, updatedContent, fetchOpts);
10566
+ log.success(`Synced task completion to issue #${issueDetails.number}`);
10074
10567
  }
10075
10568
  }
10076
10569
  } catch (err) {
10077
- log.warn(`Commit agent error for issue #${details.number}: ${log.formatErrorChain(err)}`);
10570
+ log.warn(`Could not sync task completion to datasource: ${log.formatErrorChain(err)}`);
10078
10571
  }
10079
- }
10080
- fileLogger?.phase("PR lifecycle");
10081
- if (!noBranch && branchName && defaultBranch && details) {
10082
- if (feature && featureBranchName) {
10083
- if (worktreePath) {
10084
- try {
10085
- await removeWorktree(cwd, file);
10086
- } catch (err) {
10087
- log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
10088
- }
10572
+ tuiTask.status = "done";
10573
+ tuiTask.error = void 0;
10574
+ tuiTask.elapsed = Date.now() - startTime;
10575
+ if (verbose) log.success(`Task #${tui.state.tasks.indexOf(tuiTask) + 1}: done \u2014 "${task.text}" (${elapsed(tuiTask.elapsed)})`);
10576
+ return { kind: "success", result: execResult.data.dispatchResult };
10577
+ };
10578
+ const recoverPausedTask = async (task, error) => {
10579
+ while (true) {
10580
+ const tuiTask = pauseTask(task, error);
10581
+ if (!canRecoverInteractively) {
10582
+ log.warn("Manual rerun requires an interactive terminal; verbose or non-TTY runs will not wait for input, and the current branch/worktree will be left intact.");
10583
+ tuiTask.status = "failed";
10584
+ clearRecovery();
10585
+ return { halted: true, result: { task, success: false, error } };
10089
10586
  }
10090
- try {
10091
- await datasource4.switchBranch(featureBranchName, lifecycleOpts);
10092
- await exec9("git", ["merge", branchName, "--no-ff", "-m", `merge: issue #${details.number}`], { cwd, shell: process.platform === "win32" });
10093
- log.debug(`Merged ${branchName} into ${featureBranchName}`);
10094
- } catch (err) {
10095
- const mergeError = `Could not merge ${branchName} into feature branch: ${log.formatErrorChain(err)}`;
10096
- log.warn(mergeError);
10097
- try {
10098
- await exec9("git", ["merge", "--abort"], { cwd, shell: process.platform === "win32" });
10099
- } catch {
10100
- }
10101
- for (const task of fileTasks) {
10102
- const tuiTask = tui.state.tasks.find((t) => t.task === task);
10103
- if (tuiTask) {
10104
- tuiTask.status = "failed";
10105
- tuiTask.error = mergeError;
10106
- }
10107
- const existingResult = results.find((r) => r.task === task);
10108
- if (existingResult) {
10109
- existingResult.success = false;
10110
- existingResult.error = mergeError;
10111
- }
10112
- }
10113
- return;
10587
+ const action = await tui.waitForRecoveryAction();
10588
+ if (action === "quit") {
10589
+ tuiTask.status = "failed";
10590
+ clearRecovery();
10591
+ return { halted: true, result: { task, success: false, error } };
10592
+ }
10593
+ clearRecovery();
10594
+ const rerun = await runTaskLifecycle(task);
10595
+ if (rerun.kind === "success") {
10596
+ return { halted: false, result: rerun.result };
10597
+ }
10598
+ error = rerun.error;
10599
+ }
10600
+ };
10601
+ const groups = groupTasksByMode(fileTasks);
10602
+ let stopAfterIssue = false;
10603
+ for (const group of groups) {
10604
+ const groupResults = await runWithConcurrency({
10605
+ items: group,
10606
+ concurrency,
10607
+ worker: async (task) => runTaskLifecycle(task),
10608
+ shouldStop: () => stopAfterIssue
10609
+ });
10610
+ const pausedTasks = [];
10611
+ for (let i = 0; i < group.length; i++) {
10612
+ const result = groupResults[i];
10613
+ if (result.status === "skipped") continue;
10614
+ if (result.status === "rejected") {
10615
+ pausedTasks.push({ task: group[i], error: String(result.reason) });
10616
+ continue;
10114
10617
  }
10618
+ const outcome = result.value;
10619
+ if (outcome.kind === "success") {
10620
+ upsertResult(issueResults, outcome.result);
10621
+ upsertResult(results, outcome.result);
10622
+ } else {
10623
+ pausedTasks.push({ task: group[i], error: outcome.error });
10624
+ }
10625
+ }
10626
+ for (const pausedTask of pausedTasks) {
10627
+ const resolution = await recoverPausedTask(pausedTask.task, pausedTask.error);
10628
+ upsertResult(issueResults, resolution.result);
10629
+ upsertResult(results, resolution.result);
10630
+ if (resolution.halted) {
10631
+ preserveContext = true;
10632
+ stopAfterIssue = true;
10633
+ halted = true;
10634
+ break;
10635
+ }
10636
+ }
10637
+ if (!tui.state.model && localInstance.model) {
10638
+ tui.state.model = localInstance.model;
10639
+ }
10640
+ if (stopAfterIssue) break;
10641
+ }
10642
+ if (!preserveContext) {
10643
+ if (!noBranch && branchName && defaultBranch && details && datasource4.supportsGit()) {
10115
10644
  try {
10116
- await exec9("git", ["branch", "-d", branchName], { cwd, shell: process.platform === "win32" });
10117
- log.debug(`Deleted local branch ${branchName}`);
10645
+ await datasource4.commitAllChanges(
10646
+ `chore: stage uncommitted changes for issue #${details.number}`,
10647
+ issueLifecycleOpts
10648
+ );
10649
+ log.debug(`Staged uncommitted changes for issue #${details.number}`);
10118
10650
  } catch (err) {
10119
- log.warn(`Could not delete local branch ${branchName}: ${log.formatErrorChain(err)}`);
10651
+ log.warn(`Could not commit uncommitted changes for issue #${details.number}: ${log.formatErrorChain(err)}`);
10120
10652
  }
10653
+ }
10654
+ fileLogger?.phase("Commit generation");
10655
+ let commitAgentResult;
10656
+ if (!noBranch && branchName && defaultBranch && details && datasource4.supportsGit()) {
10121
10657
  try {
10122
- await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
10658
+ const branchDiff = await getBranchDiff(defaultBranch, issueCwd);
10659
+ if (branchDiff) {
10660
+ const result = await localCommitAgent.generate({
10661
+ branchDiff,
10662
+ issue: details,
10663
+ taskResults: issueResults,
10664
+ cwd: issueCwd,
10665
+ worktreeRoot
10666
+ });
10667
+ if (result.success) {
10668
+ commitAgentResult = result;
10669
+ fileLogger?.info(`Commit message generated for issue #${details.number}`);
10670
+ try {
10671
+ await squashBranchCommits(defaultBranch, result.commitMessage, issueCwd);
10672
+ log.debug(`Rewrote commit message for issue #${details.number}`);
10673
+ fileLogger?.info(`Rewrote commit history for issue #${details.number}`);
10674
+ } catch (err) {
10675
+ log.warn(`Could not rewrite commit message for issue #${details.number}: ${log.formatErrorChain(err)}`);
10676
+ }
10677
+ } else {
10678
+ log.warn(`Commit agent failed for issue #${details.number}: ${result.error}`);
10679
+ fileLogger?.warn(`Commit agent failed: ${result.error}`);
10680
+ }
10681
+ }
10123
10682
  } catch (err) {
10124
- log.warn(`Could not switch back to ${featureDefaultBranch}: ${log.formatErrorChain(err)}`);
10683
+ log.warn(`Commit agent error for issue #${details.number}: ${log.formatErrorChain(err)}`);
10125
10684
  }
10126
- } else {
10127
- if (datasource4.supportsGit()) {
10128
- try {
10129
- await datasource4.pushBranch(branchName, issueLifecycleOpts);
10130
- log.debug(`Pushed branch ${branchName}`);
10131
- fileLogger?.info(`Pushed branch ${branchName}`);
10132
- } catch (err) {
10133
- log.warn(`Could not push branch ${branchName}: ${log.formatErrorChain(err)}`);
10685
+ }
10686
+ fileLogger?.phase("PR lifecycle");
10687
+ if (!noBranch && branchName && defaultBranch && details) {
10688
+ if (feature && featureBranchName) {
10689
+ if (worktreePath) {
10690
+ try {
10691
+ await removeWorktree(cwd, file);
10692
+ } catch (err) {
10693
+ log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
10694
+ }
10134
10695
  }
10135
- }
10136
- if (datasource4.supportsGit()) {
10137
10696
  try {
10138
- const prTitle = commitAgentResult?.prTitle || await buildPrTitle(details.title, defaultBranch, issueLifecycleOpts.cwd);
10139
- const prBody = commitAgentResult?.prDescription || await buildPrBody(
10140
- details,
10141
- fileTasks,
10142
- issueResults,
10143
- defaultBranch,
10144
- datasource4.name,
10145
- issueLifecycleOpts.cwd
10146
- );
10147
- const prUrl = await datasource4.createPullRequest(
10148
- branchName,
10149
- details.number,
10150
- prTitle,
10151
- prBody,
10152
- issueLifecycleOpts,
10153
- startingBranch
10154
- );
10155
- if (prUrl) {
10156
- log.success(`Created PR for issue #${details.number}: ${prUrl}`);
10157
- fileLogger?.info(`Created PR: ${prUrl}`);
10158
- }
10697
+ await datasource4.switchBranch(featureBranchName, lifecycleOpts);
10698
+ await exec9("git", ["merge", branchName, "--no-ff", "-m", `merge: issue #${details.number}`], { cwd, shell: process.platform === "win32" });
10699
+ log.debug(`Merged ${branchName} into ${featureBranchName}`);
10159
10700
  } catch (err) {
10160
- log.warn(`Could not create PR for issue #${details.number}: ${log.formatErrorChain(err)}`);
10161
- fileLogger?.warn(`PR creation failed: ${log.extractMessage(err)}`);
10701
+ const mergeError = `Could not merge ${branchName} into feature branch: ${log.formatErrorChain(err)}`;
10702
+ log.warn(mergeError);
10703
+ try {
10704
+ await exec9("git", ["merge", "--abort"], { cwd, shell: process.platform === "win32" });
10705
+ } catch {
10706
+ }
10707
+ for (const task of fileTasks) {
10708
+ const tuiTask = tui.state.tasks.find((t) => t.task === task);
10709
+ if (tuiTask) {
10710
+ tuiTask.status = "failed";
10711
+ tuiTask.error = mergeError;
10712
+ }
10713
+ upsertResult(results, { task, success: false, error: mergeError });
10714
+ }
10715
+ return { halted: false };
10162
10716
  }
10163
- }
10164
- if (useWorktrees && worktreePath) {
10165
10717
  try {
10166
- await removeWorktree(cwd, file);
10718
+ await exec9("git", ["branch", "-d", branchName], { cwd, shell: process.platform === "win32" });
10719
+ log.debug(`Deleted local branch ${branchName}`);
10167
10720
  } catch (err) {
10168
- log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
10721
+ log.warn(`Could not delete local branch ${branchName}: ${log.formatErrorChain(err)}`);
10169
10722
  }
10170
- } else if (!useWorktrees && datasource4.supportsGit()) {
10171
10723
  try {
10172
- await datasource4.switchBranch(defaultBranch, lifecycleOpts);
10173
- log.debug(`Switched back to ${defaultBranch}`);
10724
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
10174
10725
  } catch (err) {
10175
- log.warn(`Could not switch back to ${defaultBranch}: ${log.formatErrorChain(err)}`);
10726
+ log.warn(`Could not switch back to ${featureDefaultBranch}: ${log.formatErrorChain(err)}`);
10727
+ }
10728
+ } else {
10729
+ if (datasource4.supportsGit()) {
10730
+ try {
10731
+ await datasource4.pushBranch(branchName, issueLifecycleOpts);
10732
+ log.debug(`Pushed branch ${branchName}`);
10733
+ fileLogger?.info(`Pushed branch ${branchName}`);
10734
+ } catch (err) {
10735
+ log.warn(`Could not push branch ${branchName}: ${log.formatErrorChain(err)}`);
10736
+ }
10737
+ }
10738
+ if (datasource4.supportsGit()) {
10739
+ try {
10740
+ const prTitle = commitAgentResult?.prTitle || await buildPrTitle(details.title, defaultBranch, issueLifecycleOpts.cwd);
10741
+ const prBody = commitAgentResult?.prDescription || await buildPrBody(
10742
+ details,
10743
+ fileTasks,
10744
+ issueResults,
10745
+ defaultBranch,
10746
+ datasource4.name,
10747
+ issueLifecycleOpts.cwd
10748
+ );
10749
+ const prUrl = await datasource4.createPullRequest(
10750
+ branchName,
10751
+ details.number,
10752
+ prTitle,
10753
+ prBody,
10754
+ issueLifecycleOpts,
10755
+ startingBranch
10756
+ );
10757
+ if (prUrl) {
10758
+ log.success(`Created PR for issue #${details.number}: ${prUrl}`);
10759
+ fileLogger?.info(`Created PR: ${prUrl}`);
10760
+ }
10761
+ } catch (err) {
10762
+ log.warn(`Could not create PR for issue #${details.number}: ${log.formatErrorChain(err)}`);
10763
+ fileLogger?.warn(`PR creation failed: ${log.extractMessage(err)}`);
10764
+ }
10765
+ }
10766
+ if (useWorktrees && worktreePath) {
10767
+ try {
10768
+ await removeWorktree(cwd, file);
10769
+ } catch (err) {
10770
+ log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
10771
+ }
10772
+ } else if (!useWorktrees && datasource4.supportsGit()) {
10773
+ try {
10774
+ await datasource4.switchBranch(defaultBranch, lifecycleOpts);
10775
+ log.debug(`Switched back to ${defaultBranch}`);
10776
+ } catch (err) {
10777
+ log.warn(`Could not switch back to ${defaultBranch}: ${log.formatErrorChain(err)}`);
10778
+ }
10176
10779
  }
10177
10780
  }
10178
10781
  }
@@ -10183,31 +10786,42 @@ ${err.stack}` : ""}`);
10183
10786
  await localPlanner?.cleanup();
10184
10787
  await localInstance.cleanup();
10185
10788
  }
10789
+ return { halted: stopAfterIssue };
10186
10790
  };
10187
10791
  if (fileLogger) {
10188
- await fileLoggerStorage.run(fileLogger, async () => {
10792
+ return fileLoggerStorage.run(fileLogger, async () => {
10189
10793
  try {
10190
- await body();
10794
+ return await body();
10191
10795
  } finally {
10192
10796
  fileLogger.close();
10193
10797
  }
10194
10798
  });
10195
- } else {
10196
- await body();
10197
10799
  }
10800
+ return body();
10198
10801
  };
10199
10802
  if (useWorktrees && !feature) {
10200
- await Promise.all(
10201
- Array.from(tasksByFile).map(
10202
- ([file, fileTasks]) => processIssueFile(file, fileTasks)
10203
- )
10204
- );
10803
+ const issueEntries = Array.from(tasksByFile.entries());
10804
+ const concurrencyResults = await runWithConcurrency({
10805
+ items: issueEntries,
10806
+ concurrency,
10807
+ worker: async ([file, fileTasks]) => processIssueFile(file, fileTasks),
10808
+ shouldStop: () => halted
10809
+ });
10810
+ for (const result of concurrencyResults) {
10811
+ if (result.status === "fulfilled" && result.value?.halted) {
10812
+ halted = true;
10813
+ }
10814
+ }
10205
10815
  } else {
10206
10816
  for (const [file, fileTasks] of tasksByFile) {
10207
- await processIssueFile(file, fileTasks);
10817
+ const issueResult = await processIssueFile(file, fileTasks);
10818
+ if (issueResult?.halted) {
10819
+ halted = true;
10820
+ break;
10821
+ }
10208
10822
  }
10209
10823
  }
10210
- if (feature && featureBranchName && featureDefaultBranch) {
10824
+ if (!halted && feature && featureBranchName && featureDefaultBranch) {
10211
10825
  try {
10212
10826
  await datasource4.switchBranch(featureBranchName, lifecycleOpts);
10213
10827
  log.debug(`Switched to feature branch ${featureBranchName}`);
@@ -10249,7 +10863,10 @@ ${err.stack}` : ""}`);
10249
10863
  await executor?.cleanup();
10250
10864
  await planner?.cleanup();
10251
10865
  await instance?.cleanup();
10866
+ const completed = results.filter((result) => result.success).length;
10867
+ const failed = results.filter((result) => !result.success).length;
10252
10868
  tui.state.phase = "done";
10869
+ setAuthPromptHandler(null);
10253
10870
  tui.stop();
10254
10871
  if (verbose) log.success(`Done \u2014 ${completed} completed, ${failed} failed (${elapsed(Date.now() - tui.state.startTime)})`);
10255
10872
  return { total: allTasks.length, completed, failed, skipped: 0, results };
@@ -10259,17 +10876,17 @@ ${err.stack}` : ""}`);
10259
10876
  throw err;
10260
10877
  }
10261
10878
  }
10262
- async function dryRunMode(issueIds, cwd, source, org, project, workItemType, iteration, area) {
10879
+ async function dryRunMode(issueIds, cwd, source, org, project, workItemType, iteration, area, username) {
10263
10880
  if (!source) {
10264
10881
  log.error("No datasource configured. Use --source or run 'dispatch config' to set up defaults.");
10265
10882
  return { total: 0, completed: 0, failed: 0, skipped: 0, results: [] };
10266
10883
  }
10267
10884
  const datasource4 = getDatasource(source);
10268
10885
  const fetchOpts = { cwd, org, project, workItemType, iteration, area };
10269
- const lifecycleOpts = { cwd };
10270
- let username = "";
10886
+ const lifecycleOpts = { cwd, username };
10887
+ let resolvedUsername = "";
10271
10888
  try {
10272
- username = await datasource4.getUsername(lifecycleOpts);
10889
+ resolvedUsername = await datasource4.getUsername(lifecycleOpts);
10273
10890
  } catch {
10274
10891
  }
10275
10892
  let items;
@@ -10303,7 +10920,7 @@ async function dryRunMode(issueIds, cwd, source, org, project, workItemType, ite
10303
10920
  for (const task of allTasks) {
10304
10921
  const parsed = parseIssueFilename(task.file);
10305
10922
  const details = parsed ? items.find((item) => item.number === parsed.issueId) : issueDetailsByFile.get(task.file);
10306
- const branchInfo = details ? ` [branch: ${datasource4.buildBranchName(details.number, details.title, username)}]` : "";
10923
+ const branchInfo = details ? ` [branch: ${datasource4.buildBranchName(details.number, details.title, resolvedUsername)}]` : "";
10307
10924
  log.task(allTasks.indexOf(task), allTasks.length, `${task.file}:${task.line} \u2014 ${task.text}${branchInfo}`);
10308
10925
  }
10309
10926
  return {
@@ -10327,7 +10944,7 @@ async function runMultiIssueFixTests(opts) {
10327
10944
  }
10328
10945
  let username = "";
10329
10946
  try {
10330
- username = await datasource4.getUsername({ cwd: opts.cwd });
10947
+ username = await datasource4.getUsername({ cwd: opts.cwd, username: opts.username });
10331
10948
  } catch (err) {
10332
10949
  log.warn(`Could not resolve git username for branch naming: ${log.formatErrorChain(err)}`);
10333
10950
  }
@@ -10446,7 +11063,8 @@ async function boot9(opts) {
10446
11063
  verbose: m.verbose,
10447
11064
  testTimeout: m.testTimeout,
10448
11065
  org: m.org,
10449
- project: m.project
11066
+ project: m.project,
11067
+ username: m.username
10450
11068
  });
10451
11069
  }
10452
11070
  if (m.spec) {
@@ -10464,7 +11082,11 @@ async function boot9(opts) {
10464
11082
  iteration: m.iteration,
10465
11083
  area: m.area,
10466
11084
  concurrency: m.concurrency,
10467
- dryRun: m.dryRun
11085
+ dryRun: m.dryRun,
11086
+ retries: m.retries,
11087
+ specTimeout: m.specTimeout ?? DEFAULT_SPEC_TIMEOUT_MIN,
11088
+ specWarnTimeout: m.specWarnTimeout,
11089
+ specKillTimeout: m.specKillTimeout
10468
11090
  });
10469
11091
  }
10470
11092
  if (m.respec) {
@@ -10506,7 +11128,11 @@ async function boot9(opts) {
10506
11128
  iteration: m.iteration,
10507
11129
  area: m.area,
10508
11130
  concurrency: m.concurrency,
10509
- dryRun: m.dryRun
11131
+ dryRun: m.dryRun,
11132
+ retries: m.retries,
11133
+ specTimeout: m.specTimeout ?? DEFAULT_SPEC_TIMEOUT_MIN,
11134
+ specWarnTimeout: m.specWarnTimeout,
11135
+ specKillTimeout: m.specKillTimeout
10510
11136
  });
10511
11137
  }
10512
11138
  return this.orchestrate({
@@ -10529,7 +11155,8 @@ async function boot9(opts) {
10529
11155
  planRetries: m.planRetries,
10530
11156
  retries: m.retries,
10531
11157
  force: m.force,
10532
- feature: m.feature
11158
+ feature: m.feature,
11159
+ username: m.username
10533
11160
  });
10534
11161
  }
10535
11162
  };
@@ -10565,8 +11192,8 @@ var HELP = `
10565
11192
  --provider <name> Agent backend: ${PROVIDER_NAMES.join(", ")} (default: opencode)
10566
11193
  --source <name> Issue source: ${DATASOURCE_NAMES.join(", ")} (optional; auto-detected from git remote)
10567
11194
  --server-url <url> URL of a running provider server
10568
- --plan-timeout <min> Planning timeout in minutes (default: 10)
10569
- --retries <n> Retry attempts for all agents (default: 2)
11195
+ --plan-timeout <min> Planning timeout in minutes (default: 30)
11196
+ --retries <n> Retry attempts for all agents (default: 3)
10570
11197
  --plan-retries <n> Retry attempts after planning timeout (overrides --retries for planner)
10571
11198
  --test-timeout <min> Test timeout in minutes (default: 5)
10572
11199
  --cwd <dir> Working directory (default: cwd)
@@ -10574,6 +11201,9 @@ var HELP = `
10574
11201
  Spec options:
10575
11202
  --spec <value> Comma-separated issue numbers, glob pattern for .md files, or inline text description
10576
11203
  --respec [value] Regenerate specs: issue numbers, glob, or omit to regenerate all existing specs
11204
+ --spec-timeout <min> Spec generation timeout in minutes (default: 10)
11205
+ --spec-warn-timeout <min> Spec warn-phase timeout in minutes (default: 10)
11206
+ --spec-kill-timeout <min> Spec kill-phase timeout in minutes (default: 10)
10577
11207
  --output-dir <dir> Output directory for specs (default: .dispatch/specs)
10578
11208
 
10579
11209
  Azure DevOps options:
@@ -10585,6 +11215,9 @@ var HELP = `
10585
11215
  -h, --help Show this help
10586
11216
  -v, --version Show version
10587
11217
 
11218
+ Interactive dispatch runs pause exhausted failed tasks so you can rerun them
11219
+ in place; verbose or non-TTY runs do not wait for input.
11220
+
10588
11221
  Config:
10589
11222
  dispatch config Launch interactive configuration wizard
10590
11223
 
@@ -10632,6 +11265,9 @@ var CLI_OPTIONS_MAP = {
10632
11265
  concurrency: "concurrency",
10633
11266
  serverUrl: "serverUrl",
10634
11267
  planTimeout: "planTimeout",
11268
+ specTimeout: "specTimeout",
11269
+ specWarnTimeout: "specWarnTimeout",
11270
+ specKillTimeout: "specKillTimeout",
10635
11271
  retries: "retries",
10636
11272
  planRetries: "planRetries",
10637
11273
  testTimeout: "testTimeout",
@@ -10671,6 +11307,45 @@ function parseArgs(argv) {
10671
11307
  if (n > CONFIG_BOUNDS.planTimeout.max) throw new CommanderError(1, "commander.invalidArgument", `--plan-timeout must not exceed ${CONFIG_BOUNDS.planTimeout.max}`);
10672
11308
  return n;
10673
11309
  }
11310
+ ).option(
11311
+ "--spec-timeout <min>",
11312
+ "Spec generation timeout in minutes",
11313
+ (val) => {
11314
+ const n = parseFloat(val);
11315
+ if (isNaN(n) || n < CONFIG_BOUNDS.specTimeout.min) {
11316
+ throw new CommanderError(1, "commander.invalidArgument", "--spec-timeout must be a positive number (minutes)");
11317
+ }
11318
+ if (n > CONFIG_BOUNDS.specTimeout.max) {
11319
+ throw new CommanderError(1, "commander.invalidArgument", `--spec-timeout must not exceed ${CONFIG_BOUNDS.specTimeout.max}`);
11320
+ }
11321
+ return n;
11322
+ }
11323
+ ).option(
11324
+ "--spec-warn-timeout <min>",
11325
+ "Spec warn-phase timeout in minutes",
11326
+ (val) => {
11327
+ const n = parseFloat(val);
11328
+ if (isNaN(n) || n < CONFIG_BOUNDS.specWarnTimeout.min) {
11329
+ throw new CommanderError(1, "commander.invalidArgument", "--spec-warn-timeout must be a positive number (minutes)");
11330
+ }
11331
+ if (n > CONFIG_BOUNDS.specWarnTimeout.max) {
11332
+ throw new CommanderError(1, "commander.invalidArgument", `--spec-warn-timeout must not exceed ${CONFIG_BOUNDS.specWarnTimeout.max}`);
11333
+ }
11334
+ return n;
11335
+ }
11336
+ ).option(
11337
+ "--spec-kill-timeout <min>",
11338
+ "Spec kill-phase timeout in minutes",
11339
+ (val) => {
11340
+ const n = parseFloat(val);
11341
+ if (isNaN(n) || n < CONFIG_BOUNDS.specKillTimeout.min) {
11342
+ throw new CommanderError(1, "commander.invalidArgument", "--spec-kill-timeout must be a positive number (minutes)");
11343
+ }
11344
+ if (n > CONFIG_BOUNDS.specKillTimeout.max) {
11345
+ throw new CommanderError(1, "commander.invalidArgument", `--spec-kill-timeout must not exceed ${CONFIG_BOUNDS.specKillTimeout.max}`);
11346
+ }
11347
+ return n;
11348
+ }
10674
11349
  ).option(
10675
11350
  "--retries <n>",
10676
11351
  "Retry attempts",
@@ -10735,6 +11410,9 @@ function parseArgs(argv) {
10735
11410
  if (opts.concurrency !== void 0) args.concurrency = opts.concurrency;
10736
11411
  if (opts.serverUrl !== void 0) args.serverUrl = opts.serverUrl;
10737
11412
  if (opts.planTimeout !== void 0) args.planTimeout = opts.planTimeout;
11413
+ if (opts.specTimeout !== void 0) args.specTimeout = opts.specTimeout;
11414
+ if (opts.specWarnTimeout !== void 0) args.specWarnTimeout = opts.specWarnTimeout;
11415
+ if (opts.specKillTimeout !== void 0) args.specKillTimeout = opts.specKillTimeout;
10738
11416
  if (opts.retries !== void 0) args.retries = opts.retries;
10739
11417
  if (opts.planRetries !== void 0) args.planRetries = opts.planRetries;
10740
11418
  if (opts.testTimeout !== void 0) args.testTimeout = opts.testTimeout;
@@ -10785,7 +11463,7 @@ async function main() {
10785
11463
  process.exit(0);
10786
11464
  }
10787
11465
  if (args.version) {
10788
- console.log(`dispatch v${"1.4.3"}`);
11466
+ console.log(`dispatch v${"1.4.4"}`);
10789
11467
  process.exit(0);
10790
11468
  }
10791
11469
  const orchestrator = await boot9({ cwd: args.cwd });