@pruddiman/dispatch 1.5.0-beta.7469bf9 → 1.5.0-beta.9b425f0-beta.9b425f0

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.
@@ -1,26 +1,946 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'module';
3
3
  const require = createRequire(import.meta.url);
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
6
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
5
7
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
6
8
  }) : x)(function(x) {
7
9
  if (typeof require !== "undefined") return require.apply(this, arguments);
8
10
  throw Error('Dynamic require of "' + x + '" is not supported');
9
11
  });
12
+ var __esm = (fn, res) => function __init() {
13
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
14
+ };
15
+ var __export = (target, all) => {
16
+ for (var name in all)
17
+ __defProp(target, name, { get: all[name], enumerable: true });
18
+ };
10
19
 
11
- // src/spec-generator.ts
12
- import { cpus, freemem } from "os";
20
+ // src/helpers/format.ts
21
+ import chalk2 from "chalk";
22
+ function elapsed(ms) {
23
+ const s = Math.floor(ms / 1e3);
24
+ const m = Math.floor(s / 60);
25
+ const sec = s % 60;
26
+ if (m > 0) return `${m}m ${sec}s`;
27
+ return `${sec}s`;
28
+ }
29
+ function renderHeaderLines(info) {
30
+ const lines = [];
31
+ lines.push(chalk2.bold.white(" \u26A1 dispatch") + chalk2.dim(` \u2014 AI task orchestration`));
32
+ if (info.provider) {
33
+ lines.push(chalk2.dim(` provider: ${info.provider}`));
34
+ }
35
+ if (info.model) {
36
+ lines.push(chalk2.dim(` model: ${info.model}`));
37
+ }
38
+ if (info.source) {
39
+ lines.push(chalk2.dim(` source: ${info.source}`));
40
+ }
41
+ return lines;
42
+ }
43
+ var PALETTE;
44
+ var init_format = __esm({
45
+ "src/helpers/format.ts"() {
46
+ "use strict";
47
+ PALETTE = {
48
+ brand: "#58A6FF",
49
+ subtitle: "#484F58",
50
+ chrome: "#30363D",
51
+ text: "#C9D1D9",
52
+ muted: "#484F58",
53
+ success: "#56D364",
54
+ error: "#F85149",
55
+ warn: "#D29922",
56
+ accent: "#79C0FF",
57
+ planning: "#D2A8FF"
58
+ };
59
+ }
60
+ });
13
61
 
14
- // src/datasources/index.ts
15
- import { execFile as execFile6 } from "child_process";
16
- import { promisify as promisify6 } from "util";
62
+ // src/helpers/ink-prompts.tsx
63
+ var ink_prompts_exports = {};
64
+ __export(ink_prompts_exports, {
65
+ confirm: () => confirm,
66
+ input: () => input,
67
+ multiSelect: () => multiSelect,
68
+ multiSelectWithActions: () => multiSelectWithActions,
69
+ select: () => select
70
+ });
71
+ import { useState, useRef, useEffect } from "react";
72
+ import { render, Box, Text, useInput, useApp } from "ink";
73
+ import SelectInput from "ink-select-input";
74
+ import TextInput from "ink-text-input";
75
+ import { jsx, jsxs } from "react/jsx-runtime";
76
+ function select(opts) {
77
+ return new Promise((resolve3) => {
78
+ function SelectPrompt() {
79
+ const { exit } = useApp();
80
+ const items = opts.choices.map((c) => ({ label: c.name, value: c.value }));
81
+ const initialIndex = opts.default !== void 0 ? opts.choices.findIndex((c) => c.value === opts.default) : 0;
82
+ const visibleLimit = opts.limit ?? Math.max(5, (process.stdout.rows ?? 24) - 4);
83
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
84
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: PALETTE.accent, bold: true, children: opts.message }) }),
85
+ /* @__PURE__ */ jsx(
86
+ SelectInput,
87
+ {
88
+ items,
89
+ initialIndex: Math.max(0, initialIndex),
90
+ limit: visibleLimit,
91
+ onSelect: (item) => {
92
+ resolve3(item.value);
93
+ exit();
94
+ }
95
+ }
96
+ )
97
+ ] });
98
+ }
99
+ const instance = render(/* @__PURE__ */ jsx(SelectPrompt, {}));
100
+ instance.waitUntilExit().catch(() => {
101
+ });
102
+ });
103
+ }
104
+ function confirm(opts) {
105
+ const defaultVal = opts.default ?? true;
106
+ return new Promise((resolve3) => {
107
+ function ConfirmPrompt() {
108
+ const { exit } = useApp();
109
+ const hint = defaultVal ? "(Y/n)" : "(y/N)";
110
+ useInput((input2) => {
111
+ const lower = input2.toLowerCase();
112
+ if (lower === "y") {
113
+ resolve3(true);
114
+ exit();
115
+ } else if (lower === "n") {
116
+ resolve3(false);
117
+ exit();
118
+ } else if (input2 === "\r" || input2 === "\n" || input2 === "") {
119
+ resolve3(defaultVal);
120
+ exit();
121
+ }
122
+ });
123
+ return /* @__PURE__ */ jsxs(Box, { children: [
124
+ /* @__PURE__ */ jsx(Text, { color: PALETTE.accent, bold: true, children: "? " }),
125
+ /* @__PURE__ */ jsxs(Text, { children: [
126
+ opts.message,
127
+ " "
128
+ ] }),
129
+ /* @__PURE__ */ jsxs(Text, { color: PALETTE.muted, children: [
130
+ hint,
131
+ " "
132
+ ] })
133
+ ] });
134
+ }
135
+ const instance = render(/* @__PURE__ */ jsx(ConfirmPrompt, {}));
136
+ instance.waitUntilExit().catch(() => {
137
+ });
138
+ });
139
+ }
140
+ function multiSelect(opts) {
141
+ return new Promise((resolve3) => {
142
+ function MultiSelectPrompt() {
143
+ const { exit } = useApp();
144
+ const [cursor, setCursor] = useState(0);
145
+ const [selected, setSelected] = useState(() => {
146
+ const initial = /* @__PURE__ */ new Set();
147
+ opts.choices.forEach((c, i) => {
148
+ if (c.default) initial.add(i);
149
+ });
150
+ return initial;
151
+ });
152
+ const selectedRef = useRef(selected);
153
+ useEffect(() => {
154
+ selectedRef.current = selected;
155
+ }, [selected]);
156
+ useInput((input2, key) => {
157
+ if (key.upArrow) {
158
+ setCursor((prev) => prev > 0 ? prev - 1 : opts.choices.length - 1);
159
+ } else if (key.downArrow) {
160
+ setCursor((prev) => prev < opts.choices.length - 1 ? prev + 1 : 0);
161
+ } else if (input2 === " ") {
162
+ setSelected((prev) => {
163
+ const next = new Set(prev);
164
+ if (next.has(cursor)) next.delete(cursor);
165
+ else next.add(cursor);
166
+ return next;
167
+ });
168
+ } else if (key.return) {
169
+ const result = opts.choices.filter((_, i) => selectedRef.current.has(i)).map((c) => c.value);
170
+ resolve3(result);
171
+ exit();
172
+ }
173
+ });
174
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
175
+ /* @__PURE__ */ jsxs(Box, { children: [
176
+ /* @__PURE__ */ jsx(Text, { color: PALETTE.accent, bold: true, children: "? " }),
177
+ /* @__PURE__ */ jsx(Text, { children: opts.message }),
178
+ /* @__PURE__ */ jsx(Text, { color: PALETTE.muted, children: " (space to toggle, enter to confirm)" })
179
+ ] }),
180
+ opts.choices.map((choice, i) => {
181
+ const isSelected = selected.has(i);
182
+ const isCursor = i === cursor;
183
+ const checkbox = isSelected ? "\u25C9" : "\u25EF";
184
+ const pointer = isCursor ? "\u276F" : " ";
185
+ return /* @__PURE__ */ jsxs(Box, { children: [
186
+ /* @__PURE__ */ jsxs(Text, { color: isCursor ? PALETTE.accent : void 0, children: [
187
+ pointer,
188
+ " ",
189
+ checkbox,
190
+ " ",
191
+ choice.name
192
+ ] }),
193
+ choice.description && isCursor && /* @__PURE__ */ jsxs(Text, { color: PALETTE.muted, children: [
194
+ " \u2014 ",
195
+ choice.description
196
+ ] })
197
+ ] }, choice.name);
198
+ })
199
+ ] });
200
+ }
201
+ const instance = render(/* @__PURE__ */ jsx(MultiSelectPrompt, {}));
202
+ instance.waitUntilExit().catch(() => {
203
+ });
204
+ });
205
+ }
206
+ function multiSelectWithActions(opts) {
207
+ return new Promise((resolve3) => {
208
+ function MultiSelectActionsPrompt() {
209
+ const { exit } = useApp();
210
+ const [cursor, setCursor] = useState(0);
211
+ const [selected, setSelected] = useState(() => {
212
+ const initial = /* @__PURE__ */ new Set();
213
+ opts.choices.forEach((c, i) => {
214
+ if (c.default) initial.add(i);
215
+ });
216
+ return initial;
217
+ });
218
+ const [marked, setMarked] = useState(/* @__PURE__ */ new Set());
219
+ const selectedRef = useRef(selected);
220
+ const markedRef = useRef(marked);
221
+ useEffect(() => {
222
+ selectedRef.current = selected;
223
+ }, [selected]);
224
+ useEffect(() => {
225
+ markedRef.current = marked;
226
+ }, [marked]);
227
+ useInput((input2, key) => {
228
+ if (key.upArrow) {
229
+ setCursor((prev) => prev > 0 ? prev - 1 : opts.choices.length - 1);
230
+ } else if (key.downArrow) {
231
+ setCursor((prev) => prev < opts.choices.length - 1 ? prev + 1 : 0);
232
+ } else if (input2 === " ") {
233
+ setSelected((prev) => {
234
+ const next = new Set(prev);
235
+ if (next.has(cursor)) next.delete(cursor);
236
+ else next.add(cursor);
237
+ return next;
238
+ });
239
+ } else if (key.return) {
240
+ const values = opts.choices.filter((_, i) => selectedRef.current.has(i)).map((c) => c.value);
241
+ const markedValues = opts.choices.filter((_, i) => markedRef.current.has(i)).map((c) => c.value);
242
+ resolve3({ values, marked: markedValues });
243
+ exit();
244
+ } else {
245
+ const action = opts.actions.find((a) => a.key === input2.toLowerCase());
246
+ if (action) {
247
+ setMarked((prev) => {
248
+ const next = new Set(prev);
249
+ if (next.has(cursor)) next.delete(cursor);
250
+ else next.add(cursor);
251
+ return next;
252
+ });
253
+ }
254
+ }
255
+ });
256
+ const actionHints = opts.actions.map((a) => `${a.key} to ${a.label}`).join(", ");
257
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
258
+ /* @__PURE__ */ jsxs(Box, { children: [
259
+ /* @__PURE__ */ jsx(Text, { color: PALETTE.accent, bold: true, children: "? " }),
260
+ /* @__PURE__ */ jsx(Text, { children: opts.message }),
261
+ /* @__PURE__ */ jsxs(Text, { color: PALETTE.muted, children: [
262
+ " (space to toggle, ",
263
+ actionHints,
264
+ ", enter to confirm)"
265
+ ] })
266
+ ] }),
267
+ opts.choices.map((choice, i) => {
268
+ const isSelected = selected.has(i);
269
+ const isMarked = marked.has(i);
270
+ const isCursor = i === cursor;
271
+ const checkbox = isSelected ? "\u25C9" : "\u25EF";
272
+ const pointer = isCursor ? "\u276F" : " ";
273
+ const markIndicator = isMarked ? " \u27F3" : "";
274
+ return /* @__PURE__ */ jsxs(Box, { children: [
275
+ /* @__PURE__ */ jsxs(Text, { color: isCursor ? PALETTE.accent : void 0, children: [
276
+ pointer,
277
+ " ",
278
+ checkbox,
279
+ " ",
280
+ choice.name
281
+ ] }),
282
+ isMarked && /* @__PURE__ */ jsx(Text, { color: PALETTE.warn, children: markIndicator }),
283
+ choice.description && isCursor && /* @__PURE__ */ jsxs(Text, { color: PALETTE.muted, children: [
284
+ " \u2014 ",
285
+ choice.description
286
+ ] })
287
+ ] }, choice.name);
288
+ })
289
+ ] });
290
+ }
291
+ const instance = render(/* @__PURE__ */ jsx(MultiSelectActionsPrompt, {}));
292
+ instance.waitUntilExit().catch(() => {
293
+ });
294
+ });
295
+ }
296
+ function input(opts) {
297
+ return new Promise((resolve3) => {
298
+ function InputPrompt() {
299
+ const { exit } = useApp();
300
+ const [value, setValue] = useState(opts.default ?? "");
301
+ return /* @__PURE__ */ jsxs(Box, { children: [
302
+ /* @__PURE__ */ jsx(Text, { color: PALETTE.accent, bold: true, children: "? " }),
303
+ /* @__PURE__ */ jsxs(Text, { children: [
304
+ opts.message,
305
+ " "
306
+ ] }),
307
+ /* @__PURE__ */ jsx(
308
+ TextInput,
309
+ {
310
+ value,
311
+ onChange: setValue,
312
+ onSubmit: (val) => {
313
+ resolve3(val);
314
+ exit();
315
+ }
316
+ }
317
+ )
318
+ ] });
319
+ }
320
+ const instance = render(/* @__PURE__ */ jsx(InputPrompt, {}));
321
+ instance.waitUntilExit().catch(() => {
322
+ });
323
+ });
324
+ }
325
+ var init_ink_prompts = __esm({
326
+ "src/helpers/ink-prompts.tsx"() {
327
+ "use strict";
328
+ init_format();
329
+ }
330
+ });
17
331
 
18
- // src/datasources/interface.ts
19
- var DATASOURCE_NAMES = ["github", "azdevops", "md"];
332
+ // src/mcp/state/database.ts
333
+ import Database from "better-sqlite3";
334
+ import { join as join8 } from "path";
335
+ import { mkdirSync as mkdirSync2 } from "fs";
336
+ function createSchema(db) {
337
+ db.exec(`
338
+ CREATE TABLE IF NOT EXISTS schema_version (
339
+ version INTEGER NOT NULL
340
+ );
20
341
 
21
- // src/datasources/github.ts
342
+ CREATE TABLE IF NOT EXISTS runs (
343
+ run_id TEXT PRIMARY KEY,
344
+ cwd TEXT NOT NULL,
345
+ issue_ids TEXT NOT NULL DEFAULT '[]',
346
+ status TEXT NOT NULL DEFAULT 'running',
347
+ started_at INTEGER NOT NULL,
348
+ finished_at INTEGER,
349
+ total INTEGER NOT NULL DEFAULT 0,
350
+ completed INTEGER NOT NULL DEFAULT 0,
351
+ failed INTEGER NOT NULL DEFAULT 0,
352
+ error TEXT,
353
+ worker_message TEXT,
354
+ queued_at INTEGER,
355
+ session_id TEXT
356
+ );
357
+
358
+ CREATE TABLE IF NOT EXISTS tasks (
359
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
360
+ run_id TEXT NOT NULL,
361
+ task_id TEXT NOT NULL,
362
+ task_text TEXT NOT NULL DEFAULT '',
363
+ file TEXT NOT NULL DEFAULT '',
364
+ line INTEGER NOT NULL DEFAULT 0,
365
+ status TEXT NOT NULL DEFAULT 'pending',
366
+ branch TEXT,
367
+ error TEXT,
368
+ started_at INTEGER,
369
+ finished_at INTEGER,
370
+ FOREIGN KEY (run_id) REFERENCES runs(run_id)
371
+ );
372
+
373
+ CREATE TABLE IF NOT EXISTS spec_runs (
374
+ run_id TEXT PRIMARY KEY,
375
+ cwd TEXT NOT NULL,
376
+ issues TEXT NOT NULL DEFAULT '',
377
+ status TEXT NOT NULL DEFAULT 'running',
378
+ started_at INTEGER NOT NULL,
379
+ finished_at INTEGER,
380
+ total INTEGER NOT NULL DEFAULT 0,
381
+ generated INTEGER NOT NULL DEFAULT 0,
382
+ failed INTEGER NOT NULL DEFAULT 0,
383
+ error TEXT,
384
+ worker_message TEXT,
385
+ queued_at INTEGER,
386
+ session_id TEXT
387
+ );
388
+ `);
389
+ const row = db.prepare("SELECT version FROM schema_version LIMIT 1").get();
390
+ if (!row) {
391
+ db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(CURRENT_SCHEMA_VERSION);
392
+ } else {
393
+ if (row.version < 2) migrateToV2(db);
394
+ if (row.version < 3) migrateToV3(db);
395
+ if (row.version < CURRENT_SCHEMA_VERSION) {
396
+ db.prepare("UPDATE schema_version SET version = ?").run(CURRENT_SCHEMA_VERSION);
397
+ }
398
+ }
399
+ db.exec(`
400
+ CREATE INDEX IF NOT EXISTS idx_runs_session_id ON runs(session_id);
401
+ CREATE INDEX IF NOT EXISTS idx_tasks_run_id ON tasks(run_id);
402
+ CREATE INDEX IF NOT EXISTS idx_tasks_task_id ON tasks(task_id);
403
+ CREATE INDEX IF NOT EXISTS idx_spec_runs_session_id ON spec_runs(session_id);
404
+ `);
405
+ }
406
+ function migrateToV2(db) {
407
+ const runCols = db.prepare("PRAGMA table_info(runs)").all();
408
+ const runColNames = new Set(runCols.map((c) => c.name));
409
+ if (!runColNames.has("worker_message")) {
410
+ db.exec("ALTER TABLE runs ADD COLUMN worker_message TEXT");
411
+ }
412
+ if (!runColNames.has("queued_at")) {
413
+ db.exec("ALTER TABLE runs ADD COLUMN queued_at INTEGER");
414
+ }
415
+ const specCols = db.prepare("PRAGMA table_info(spec_runs)").all();
416
+ const specColNames = new Set(specCols.map((c) => c.name));
417
+ if (!specColNames.has("worker_message")) {
418
+ db.exec("ALTER TABLE spec_runs ADD COLUMN worker_message TEXT");
419
+ }
420
+ if (!specColNames.has("queued_at")) {
421
+ db.exec("ALTER TABLE spec_runs ADD COLUMN queued_at INTEGER");
422
+ }
423
+ }
424
+ function migrateToV3(db) {
425
+ const runCols = db.prepare("PRAGMA table_info(runs)").all();
426
+ if (!runCols.some((c) => c.name === "session_id")) {
427
+ db.exec("ALTER TABLE runs ADD COLUMN session_id TEXT");
428
+ db.exec("CREATE INDEX IF NOT EXISTS idx_runs_session_id ON runs(session_id)");
429
+ }
430
+ const specCols = db.prepare("PRAGMA table_info(spec_runs)").all();
431
+ if (!specCols.some((c) => c.name === "session_id")) {
432
+ db.exec("ALTER TABLE spec_runs ADD COLUMN session_id TEXT");
433
+ db.exec("CREATE INDEX IF NOT EXISTS idx_spec_runs_session_id ON spec_runs(session_id)");
434
+ }
435
+ }
436
+ function openDatabase(cwd) {
437
+ if (_db) return _db;
438
+ const dispatchDir = join8(cwd, ".dispatch");
439
+ mkdirSync2(dispatchDir, { recursive: true });
440
+ const dbPath = join8(dispatchDir, "dispatch.db");
441
+ const db = new Database(dbPath);
442
+ db.pragma("journal_mode = WAL");
443
+ db.pragma("synchronous = NORMAL");
444
+ db.pragma("foreign_keys = ON");
445
+ createSchema(db);
446
+ _db = db;
447
+ return db;
448
+ }
449
+ function getDb() {
450
+ if (!_db) {
451
+ throw new Error("Database not open. Call openDatabase(cwd) first.");
452
+ }
453
+ return _db;
454
+ }
455
+ var RUN_STATUSES, TASK_STATUSES, SPEC_STATUSES, _db, CURRENT_SCHEMA_VERSION;
456
+ var init_database = __esm({
457
+ "src/mcp/state/database.ts"() {
458
+ "use strict";
459
+ RUN_STATUSES = ["queued", "running", "completed", "failed", "cancelled"];
460
+ TASK_STATUSES = ["pending", "running", "success", "failed", "skipped"];
461
+ SPEC_STATUSES = ["queued", "running", "completed", "failed"];
462
+ _db = null;
463
+ CURRENT_SCHEMA_VERSION = 3;
464
+ }
465
+ });
466
+
467
+ // src/mcp/state/manager.ts
468
+ var manager_exports = {};
469
+ __export(manager_exports, {
470
+ addCompletionCallback: () => addCompletionCallback,
471
+ addLogCallback: () => addLogCallback,
472
+ countActiveRuns: () => countActiveRuns,
473
+ createRun: () => createRun,
474
+ createSpecRun: () => createSpecRun,
475
+ createTask: () => createTask,
476
+ emitLog: () => emitLog,
477
+ failAllQueuedRuns: () => failAllQueuedRuns,
478
+ finishRun: () => finishRun,
479
+ finishSpecRun: () => finishSpecRun,
480
+ getNextQueued: () => getNextQueued,
481
+ getQueuePosition: () => getQueuePosition,
482
+ getRun: () => getRun,
483
+ getSpecRun: () => getSpecRun,
484
+ getTasksForRun: () => getTasksForRun,
485
+ isLiveRun: () => isLiveRun,
486
+ listResumableSessions: () => listResumableSessions,
487
+ listRuns: () => listRuns,
488
+ listRunsByStatus: () => listRunsByStatus,
489
+ listSpecRuns: () => listSpecRuns,
490
+ markOrphanedRunsFailed: () => markOrphanedRunsFailed,
491
+ markRunStarted: () => markRunStarted,
492
+ markSpecRunStarted: () => markSpecRunStarted,
493
+ registerLiveRun: () => registerLiveRun,
494
+ requeueSessionRuns: () => requeueSessionRuns,
495
+ unregisterLiveRun: () => unregisterLiveRun,
496
+ updateRunCounters: () => updateRunCounters,
497
+ updateTaskStatus: () => updateTaskStatus,
498
+ waitForRunCompletion: () => waitForRunCompletion
499
+ });
500
+ import { randomUUID as randomUUID3 } from "crypto";
501
+ function registerLiveRun(runId) {
502
+ liveRuns.set(runId, { runId, callbacks: [], completionCallbacks: [] });
503
+ }
504
+ function unregisterLiveRun(runId) {
505
+ const run = liveRuns.get(runId);
506
+ if (run) {
507
+ for (const cb of run.completionCallbacks) {
508
+ try {
509
+ cb();
510
+ } catch {
511
+ }
512
+ }
513
+ }
514
+ liveRuns.delete(runId);
515
+ }
516
+ function addLogCallback(runId, cb) {
517
+ const run = liveRuns.get(runId);
518
+ if (run) {
519
+ run.callbacks.push(cb);
520
+ }
521
+ }
522
+ function emitLog(runId, message, level = "info") {
523
+ const run = liveRuns.get(runId);
524
+ if (run) {
525
+ for (const cb of run.callbacks) {
526
+ try {
527
+ cb(message, level);
528
+ } catch (err) {
529
+ if (process.env["DEBUG"]) console.error("[dispatch-mcp] log callback error:", err);
530
+ }
531
+ }
532
+ }
533
+ }
534
+ function isLiveRun(runId) {
535
+ return liveRuns.has(runId);
536
+ }
537
+ function addCompletionCallback(runId, cb) {
538
+ const run = liveRuns.get(runId);
539
+ if (run) {
540
+ run.completionCallbacks.push(cb);
541
+ }
542
+ }
543
+ function waitForRunCompletion(runId, waitMs, getStatus) {
544
+ const effectiveWait = Math.min(Math.max(waitMs, 0), 12e4);
545
+ if (effectiveWait <= 0) return Promise.resolve(false);
546
+ const currentStatus = getStatus();
547
+ if (currentStatus !== null && currentStatus !== "running" && currentStatus !== "queued") {
548
+ return Promise.resolve(true);
549
+ }
550
+ return new Promise((resolve3) => {
551
+ let settled = false;
552
+ let pollTimer;
553
+ let timeoutTimer;
554
+ const cleanup = () => {
555
+ if (pollTimer) clearInterval(pollTimer);
556
+ if (timeoutTimer) clearTimeout(timeoutTimer);
557
+ };
558
+ const settle = (completed) => {
559
+ if (settled) return;
560
+ settled = true;
561
+ cleanup();
562
+ resolve3(completed);
563
+ };
564
+ if (isLiveRun(runId)) {
565
+ addCompletionCallback(runId, () => settle(true));
566
+ }
567
+ pollTimer = setInterval(() => {
568
+ const s = getStatus();
569
+ if (s !== null && s !== "running" && s !== "queued") {
570
+ settle(true);
571
+ }
572
+ }, 2e3);
573
+ timeoutTimer = setTimeout(() => settle(false), effectiveWait);
574
+ });
575
+ }
576
+ function assertRunStatus(value) {
577
+ if (RUN_STATUSES.includes(value)) return value;
578
+ throw new Error(`Invalid RunStatus from database: "${value}"`);
579
+ }
580
+ function assertTaskStatus(value) {
581
+ if (TASK_STATUSES.includes(value)) return value;
582
+ throw new Error(`Invalid TaskStatus from database: "${value}"`);
583
+ }
584
+ function assertSpecStatus(value) {
585
+ if (SPEC_STATUSES.includes(value)) return value;
586
+ throw new Error(`Invalid SpecStatus from database: "${value}"`);
587
+ }
588
+ function rowToRun(row) {
589
+ return {
590
+ runId: row.run_id,
591
+ cwd: row.cwd,
592
+ issueIds: row.issue_ids,
593
+ status: assertRunStatus(row.status),
594
+ startedAt: row.started_at,
595
+ finishedAt: row.finished_at,
596
+ total: row.total,
597
+ completed: row.completed,
598
+ failed: row.failed,
599
+ error: row.error,
600
+ workerMessage: row.worker_message,
601
+ queuedAt: row.queued_at,
602
+ sessionId: row.session_id
603
+ };
604
+ }
605
+ function rowToTask(row) {
606
+ return {
607
+ rowId: row.id,
608
+ runId: row.run_id,
609
+ taskId: row.task_id,
610
+ taskText: row.task_text,
611
+ file: row.file,
612
+ line: row.line,
613
+ status: assertTaskStatus(row.status),
614
+ branch: row.branch,
615
+ error: row.error,
616
+ startedAt: row.started_at,
617
+ finishedAt: row.finished_at
618
+ };
619
+ }
620
+ function rowToSpecRun(row) {
621
+ return {
622
+ runId: row.run_id,
623
+ cwd: row.cwd,
624
+ issues: row.issues,
625
+ status: assertSpecStatus(row.status),
626
+ startedAt: row.started_at,
627
+ finishedAt: row.finished_at,
628
+ total: row.total,
629
+ generated: row.generated,
630
+ failed: row.failed,
631
+ error: row.error,
632
+ workerMessage: row.worker_message,
633
+ queuedAt: row.queued_at,
634
+ sessionId: row.session_id
635
+ };
636
+ }
637
+ function createRun(opts) {
638
+ const runId = randomUUID3();
639
+ const db = getDb();
640
+ const now = Date.now();
641
+ const status = opts.status ?? "running";
642
+ db.prepare(`
643
+ INSERT INTO runs (run_id, cwd, issue_ids, status, started_at, worker_message, queued_at, session_id)
644
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
645
+ `).run(runId, opts.cwd, JSON.stringify(opts.issueIds), status, now, opts.workerMessage ?? null, status === "queued" ? now : null, opts.sessionId ?? null);
646
+ registerLiveRun(runId);
647
+ return runId;
648
+ }
649
+ function updateRunCounters(runId, total, completed, failed) {
650
+ getDb().prepare(`
651
+ UPDATE runs SET total = ?, completed = ?, failed = ? WHERE run_id = ?
652
+ `).run(total, completed, failed, runId);
653
+ }
654
+ function finishRun(runId, status, error) {
655
+ getDb().prepare(`
656
+ UPDATE runs SET status = ?, finished_at = ?, error = ? WHERE run_id = ?
657
+ `).run(status, Date.now(), error ?? null, runId);
658
+ unregisterLiveRun(runId);
659
+ }
660
+ function getRun(runId) {
661
+ const row = getDb().prepare("SELECT * FROM runs WHERE run_id = ?").get(runId);
662
+ return row ? rowToRun(row) : null;
663
+ }
664
+ function listRuns(limit = 50) {
665
+ const rows = getDb().prepare(
666
+ "SELECT * FROM runs ORDER BY started_at DESC LIMIT ?"
667
+ ).all(limit);
668
+ return rows.map(rowToRun);
669
+ }
670
+ function listRunsByStatus(status, limit = 20) {
671
+ const rows = getDb().prepare(
672
+ "SELECT * FROM runs WHERE status = ? ORDER BY started_at DESC LIMIT ?"
673
+ ).all(status, limit);
674
+ return rows.map(rowToRun);
675
+ }
676
+ function createTask(opts) {
677
+ getDb().prepare(`
678
+ INSERT INTO tasks (run_id, task_id, task_text, file, line, status)
679
+ VALUES (?, ?, ?, ?, ?, 'pending')
680
+ `).run(opts.runId, opts.taskId, opts.taskText, opts.file, opts.line);
681
+ }
682
+ function updateTaskStatus(runId, taskId, status, opts) {
683
+ const now = Date.now();
684
+ const isTerminal = status === "success" || status === "failed" || status === "skipped";
685
+ const isStart = status === "running";
686
+ if (isStart) {
687
+ getDb().prepare(`
688
+ UPDATE tasks SET status = ?, started_at = ?, error = NULL
689
+ WHERE run_id = ? AND task_id = ?
690
+ `).run(status, now, runId, taskId);
691
+ } else if (isTerminal) {
692
+ getDb().prepare(`
693
+ UPDATE tasks SET status = ?, finished_at = ?, error = ?, branch = ?
694
+ WHERE run_id = ? AND task_id = ?
695
+ `).run(status, now, opts?.error ?? null, opts?.branch ?? null, runId, taskId);
696
+ } else {
697
+ getDb().prepare(`
698
+ UPDATE tasks SET status = ?, error = ?
699
+ WHERE run_id = ? AND task_id = ?
700
+ `).run(status, opts?.error ?? null, runId, taskId);
701
+ }
702
+ }
703
+ function getTasksForRun(runId) {
704
+ const rows = getDb().prepare(
705
+ "SELECT * FROM tasks WHERE run_id = ? ORDER BY id ASC"
706
+ ).all(runId);
707
+ return rows.map(rowToTask);
708
+ }
709
+ function createSpecRun(opts) {
710
+ const runId = randomUUID3();
711
+ const db = getDb();
712
+ const now = Date.now();
713
+ const status = opts.status ?? "running";
714
+ db.prepare(`
715
+ INSERT INTO spec_runs (run_id, cwd, issues, status, started_at, worker_message, queued_at, session_id)
716
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
717
+ `).run(runId, opts.cwd, JSON.stringify(opts.issues), status, now, opts.workerMessage ?? null, status === "queued" ? now : null, opts.sessionId ?? null);
718
+ registerLiveRun(runId);
719
+ return runId;
720
+ }
721
+ function finishSpecRun(runId, status, counters, error) {
722
+ getDb().prepare(`
723
+ UPDATE spec_runs
724
+ SET status = ?, finished_at = ?, total = ?, generated = ?, failed = ?, error = ?
725
+ WHERE run_id = ?
726
+ `).run(status, Date.now(), counters.total, counters.generated, counters.failed, error ?? null, runId);
727
+ unregisterLiveRun(runId);
728
+ }
729
+ function listSpecRuns(limit = 50) {
730
+ const rows = getDb().prepare(
731
+ "SELECT * FROM spec_runs ORDER BY started_at DESC LIMIT ?"
732
+ ).all(limit);
733
+ return rows.map(rowToSpecRun);
734
+ }
735
+ function getSpecRun(runId) {
736
+ const row = getDb().prepare("SELECT * FROM spec_runs WHERE run_id = ?").get(runId);
737
+ return row ? rowToSpecRun(row) : null;
738
+ }
739
+ function markRunStarted(runId) {
740
+ getDb().prepare(`
741
+ UPDATE runs SET status = 'running', started_at = ? WHERE run_id = ? AND status = 'queued'
742
+ `).run(Date.now(), runId);
743
+ }
744
+ function markSpecRunStarted(runId) {
745
+ getDb().prepare(`
746
+ UPDATE spec_runs SET status = 'running', started_at = ? WHERE run_id = ? AND status = 'queued'
747
+ `).run(Date.now(), runId);
748
+ }
749
+ function getNextQueued() {
750
+ const db = getDb();
751
+ const runRow = db.prepare(
752
+ "SELECT run_id, worker_message FROM runs WHERE status = 'queued' AND worker_message IS NOT NULL ORDER BY queued_at ASC LIMIT 1"
753
+ ).get();
754
+ const specRow = db.prepare(
755
+ "SELECT run_id, worker_message FROM spec_runs WHERE status = 'queued' AND worker_message IS NOT NULL ORDER BY queued_at ASC LIMIT 1"
756
+ ).get();
757
+ if (!runRow && !specRow) return null;
758
+ if (runRow && specRow) {
759
+ const runQueuedAt = db.prepare("SELECT queued_at FROM runs WHERE run_id = ?").get(runRow.run_id).queued_at;
760
+ const specQueuedAt = db.prepare("SELECT queued_at FROM spec_runs WHERE run_id = ?").get(specRow.run_id).queued_at;
761
+ if (specQueuedAt < runQueuedAt) {
762
+ return { runId: specRow.run_id, workerMessage: specRow.worker_message, table: "spec_runs" };
763
+ }
764
+ return { runId: runRow.run_id, workerMessage: runRow.worker_message, table: "runs" };
765
+ }
766
+ if (runRow) return { runId: runRow.run_id, workerMessage: runRow.worker_message, table: "runs" };
767
+ return { runId: specRow.run_id, workerMessage: specRow.worker_message, table: "spec_runs" };
768
+ }
769
+ function countActiveRuns() {
770
+ const db = getDb();
771
+ const r1 = db.prepare("SELECT COUNT(*) as cnt FROM runs WHERE status = 'running'").get();
772
+ const r2 = db.prepare("SELECT COUNT(*) as cnt FROM spec_runs WHERE status = 'running'").get();
773
+ return r1.cnt + r2.cnt;
774
+ }
775
+ function getQueuePosition(runId) {
776
+ const db = getDb();
777
+ const runRow = db.prepare("SELECT queued_at FROM runs WHERE run_id = ? AND status = 'queued'").get(runId);
778
+ if (runRow) {
779
+ const pos = db.prepare(
780
+ "SELECT COUNT(*) as cnt FROM runs WHERE status = 'queued' AND queued_at <= ?"
781
+ ).get(runRow.queued_at);
782
+ const specBefore = db.prepare(
783
+ "SELECT COUNT(*) as cnt FROM spec_runs WHERE status = 'queued' AND queued_at < ?"
784
+ ).get(runRow.queued_at);
785
+ return pos.cnt + specBefore.cnt;
786
+ }
787
+ const specRow = db.prepare("SELECT queued_at FROM spec_runs WHERE run_id = ? AND status = 'queued'").get(runId);
788
+ if (specRow) {
789
+ const pos = db.prepare(
790
+ "SELECT COUNT(*) as cnt FROM spec_runs WHERE status = 'queued' AND queued_at <= ?"
791
+ ).get(specRow.queued_at);
792
+ const runsBefore = db.prepare(
793
+ "SELECT COUNT(*) as cnt FROM runs WHERE status = 'queued' AND queued_at < ?"
794
+ ).get(specRow.queued_at);
795
+ return pos.cnt + runsBefore.cnt;
796
+ }
797
+ return null;
798
+ }
799
+ function markOrphanedRunsFailed(opts) {
800
+ const db = getDb();
801
+ const now = Date.now();
802
+ if (opts?.exceptSessionId) {
803
+ db.prepare(`
804
+ UPDATE runs SET status = 'failed', finished_at = ?, error = 'Server restarted'
805
+ WHERE status IN ('queued', 'running') AND (session_id IS NULL OR session_id != ?)
806
+ `).run(now, opts.exceptSessionId);
807
+ db.prepare(`
808
+ UPDATE spec_runs SET status = 'failed', finished_at = ?, error = 'Server restarted'
809
+ WHERE status IN ('queued', 'running') AND (session_id IS NULL OR session_id != ?)
810
+ `).run(now, opts.exceptSessionId);
811
+ } else {
812
+ db.prepare(`
813
+ UPDATE runs SET status = 'failed', finished_at = ?, error = 'Server restarted'
814
+ WHERE status IN ('queued', 'running')
815
+ `).run(now);
816
+ db.prepare(`
817
+ UPDATE spec_runs SET status = 'failed', finished_at = ?, error = 'Server restarted'
818
+ WHERE status IN ('queued', 'running')
819
+ `).run(now);
820
+ }
821
+ }
822
+ function failAllQueuedRuns() {
823
+ const db = getDb();
824
+ const now = Date.now();
825
+ db.prepare(`
826
+ UPDATE runs SET status = 'failed', finished_at = ?, error = 'Server shutting down'
827
+ WHERE status = 'queued'
828
+ `).run(now);
829
+ db.prepare(`
830
+ UPDATE spec_runs SET status = 'failed', finished_at = ?, error = 'Server shutting down'
831
+ WHERE status = 'queued'
832
+ `).run(now);
833
+ }
834
+ function listResumableSessions(cwd) {
835
+ const db = getDb();
836
+ const rows = db.prepare(`
837
+ SELECT session_id, MIN(started_at) as started_at,
838
+ COUNT(*) as total_runs,
839
+ SUM(CASE WHEN status IN ('queued', 'failed') THEN 1 ELSE 0 END) as incomplete,
840
+ (SELECT issue_ids FROM runs r2 WHERE r2.session_id = runs.session_id ORDER BY started_at ASC LIMIT 1) as issue_ids
841
+ FROM runs
842
+ WHERE session_id IS NOT NULL AND cwd = ?
843
+ AND session_id IN (
844
+ SELECT DISTINCT session_id FROM runs
845
+ WHERE status IN ('queued', 'failed') AND session_id IS NOT NULL AND cwd = ?
846
+ )
847
+ GROUP BY session_id
848
+ ORDER BY MIN(started_at) DESC
849
+ `).all(cwd, cwd);
850
+ return rows.map((r) => ({
851
+ sessionId: r.session_id,
852
+ startedAt: r.started_at,
853
+ totalRuns: r.total_runs,
854
+ incompleteRuns: r.incomplete,
855
+ issueIds: r.issue_ids
856
+ }));
857
+ }
858
+ function requeueSessionRuns(sessionId) {
859
+ const db = getDb();
860
+ const now = Date.now();
861
+ const runRows = db.prepare(`
862
+ SELECT run_id FROM runs
863
+ WHERE session_id = ? AND status IN ('failed', 'queued')
864
+ AND worker_message IS NOT NULL
865
+ `).all(sessionId);
866
+ const specRows = db.prepare(`
867
+ SELECT run_id FROM spec_runs
868
+ WHERE session_id = ? AND status IN ('failed', 'queued')
869
+ AND worker_message IS NOT NULL
870
+ `).all(sessionId);
871
+ if (runRows.length > 0) {
872
+ db.prepare(`
873
+ UPDATE runs SET status = 'queued', queued_at = ?, finished_at = NULL, error = NULL
874
+ WHERE session_id = ? AND status IN ('failed', 'queued') AND worker_message IS NOT NULL
875
+ `).run(now, sessionId);
876
+ }
877
+ if (specRows.length > 0) {
878
+ db.prepare(`
879
+ UPDATE spec_runs SET status = 'queued', queued_at = ?, finished_at = NULL, error = NULL
880
+ WHERE session_id = ? AND status IN ('failed', 'queued') AND worker_message IS NOT NULL
881
+ `).run(now, sessionId);
882
+ }
883
+ const runIds = [...runRows.map((r) => r.run_id), ...specRows.map((r) => r.run_id)];
884
+ for (const runId of runIds) {
885
+ registerLiveRun(runId);
886
+ }
887
+ return runIds;
888
+ }
889
+ var liveRuns;
890
+ var init_manager = __esm({
891
+ "src/mcp/state/manager.ts"() {
892
+ "use strict";
893
+ init_database();
894
+ liveRuns = /* @__PURE__ */ new Map();
895
+ }
896
+ });
897
+
898
+ // src/orchestrator/runner.ts
899
+ import { randomUUID as randomUUID6 } from "crypto";
900
+
901
+ // src/config.ts
902
+ import { readFile as readFile5, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
903
+ import { join as join6, dirname as dirname4 } from "path";
904
+
905
+ // src/providers/interface.ts
906
+ var PROVIDER_NAMES = ["opencode", "copilot", "claude", "codex"];
907
+
908
+ // src/providers/opencode.ts
22
909
  import { execFile } from "child_process";
23
910
  import { promisify } from "util";
911
+ import {
912
+ createOpencode,
913
+ createOpencodeClient
914
+ } from "@opencode-ai/sdk";
915
+
916
+ // src/providers/progress.ts
917
+ var ANSI_PATTERN = /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u009B[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\))/g;
918
+ var CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
919
+ function sanitizeProgressText(raw, maxLength = 120) {
920
+ const text = raw.replace(ANSI_PATTERN, "").replace(CONTROL_PATTERN, "").replace(/\s+/g, " ").trim();
921
+ if (!text) return "";
922
+ if (text.length <= maxLength) return text;
923
+ if (maxLength <= 1) return maxLength <= 0 ? "" : "\u2026";
924
+ return `${text.slice(0, maxLength - 1).trimEnd()}\u2026`;
925
+ }
926
+ function createProgressReporter(onProgress) {
927
+ let last;
928
+ return {
929
+ emit(raw) {
930
+ if (!onProgress) return;
931
+ const text = sanitizeProgressText(raw ?? "");
932
+ if (!text || text === last) return;
933
+ last = text;
934
+ try {
935
+ onProgress({ text });
936
+ } catch {
937
+ }
938
+ },
939
+ reset() {
940
+ last = void 0;
941
+ }
942
+ };
943
+ }
24
944
 
25
945
  // src/helpers/logger.ts
26
946
  import chalk from "chalk";
@@ -215,956 +1135,98 @@ Object.defineProperty(log, "verbose", {
215
1135
  configurable: true
216
1136
  });
217
1137
 
218
- // src/helpers/branch-validation.ts
219
- var InvalidBranchNameError = class extends Error {
220
- constructor(branch, reason) {
221
- const detail = reason ? ` (${reason})` : "";
222
- super(`Invalid branch name: "${branch}"${detail}`);
223
- this.name = "InvalidBranchNameError";
224
- }
225
- };
226
- var VALID_BRANCH_NAME_RE = /^[a-zA-Z0-9._\-/]+$/;
227
- function isValidBranchName(name) {
228
- if (name.length === 0 || name.length > 255) return false;
229
- if (!VALID_BRANCH_NAME_RE.test(name)) return false;
230
- if (name.startsWith("/") || name.endsWith("/")) return false;
231
- if (name.includes("..")) return false;
232
- if (name.endsWith(".lock")) return false;
233
- if (name.includes("@{")) return false;
234
- if (name.includes("//")) return false;
235
- return true;
1138
+ // src/helpers/guards.ts
1139
+ function hasProperty(value, key) {
1140
+ return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key);
236
1141
  }
237
1142
 
238
- // src/helpers/auth.ts
239
- import { readFile, writeFile, mkdir, chmod } from "fs/promises";
240
- import { join as join2, dirname as dirname2 } from "path";
241
- import { homedir } from "os";
242
- import { Octokit } from "@octokit/rest";
243
- import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
244
- import { DeviceCodeCredential } from "@azure/identity";
245
- import * as azdev from "azure-devops-node-api";
246
- import open from "open";
247
-
248
- // src/constants.ts
249
- var GITHUB_CLIENT_ID = "Ov23liUMP1Oyg811IF58";
250
- var AZURE_CLIENT_ID = "150a3098-01dd-4126-8b10-5e7f77492e5c";
251
- var AZURE_TENANT_ID = "organizations";
252
- var AZURE_DEVOPS_SCOPE = "499b84ac-1321-427f-aa17-267ca6975798/.default";
253
-
254
- // src/helpers/auth.ts
255
- var AUTH_PATH = join2(homedir(), ".dispatch", "auth.json");
256
- var EXPIRY_BUFFER_MS = 5 * 60 * 1e3;
257
- var authPromptHandler = null;
258
- function setAuthPromptHandler(handler) {
259
- authPromptHandler = handler;
260
- }
261
- async function loadAuthCache() {
262
- try {
263
- const raw = await readFile(AUTH_PATH, "utf-8");
264
- return JSON.parse(raw);
265
- } catch {
266
- return {};
267
- }
268
- }
269
- async function saveAuthCache(cache) {
270
- await mkdir(dirname2(AUTH_PATH), { recursive: true });
271
- await writeFile(AUTH_PATH, JSON.stringify(cache, null, 2) + "\n", "utf-8");
272
- if (process.platform !== "win32") {
273
- try {
274
- await chmod(AUTH_PATH, 384);
275
- } catch {
276
- }
277
- }
278
- }
279
- async function getGithubOctokit() {
280
- const cache = await loadAuthCache();
281
- if (cache.github?.token) {
282
- return new Octokit({ auth: cache.github.token });
1143
+ // src/helpers/timeout.ts
1144
+ var TimeoutError = class extends Error {
1145
+ /** Optional label identifying the operation that timed out. */
1146
+ label;
1147
+ constructor(ms, label) {
1148
+ const suffix = label ? ` [${label}]` : "";
1149
+ super(`Timed out after ${ms}ms${suffix}`);
1150
+ this.name = "TimeoutError";
1151
+ this.label = label;
283
1152
  }
284
- const auth = createOAuthDeviceAuth({
285
- clientId: GITHUB_CLIENT_ID,
286
- clientType: "oauth-app",
287
- scopes: ["repo"],
288
- onVerification(verification) {
289
- const msg = `Enter code ${verification.user_code} at ${verification.verification_uri}`;
290
- if (authPromptHandler) {
291
- authPromptHandler(msg);
292
- } else {
293
- log.info(msg);
1153
+ };
1154
+ var DEFAULT_PLAN_TIMEOUT_MIN = 30;
1155
+ function withTimeout(promise, ms, label) {
1156
+ const p = new Promise((resolve3, reject) => {
1157
+ let settled = false;
1158
+ const timer = setTimeout(() => {
1159
+ if (settled) return;
1160
+ settled = true;
1161
+ reject(new TimeoutError(ms, label));
1162
+ }, ms);
1163
+ promise.then(
1164
+ (value) => {
1165
+ if (settled) return;
1166
+ settled = true;
1167
+ clearTimeout(timer);
1168
+ resolve3(value);
1169
+ },
1170
+ (err) => {
1171
+ if (settled) return;
1172
+ settled = true;
1173
+ clearTimeout(timer);
1174
+ reject(err);
294
1175
  }
295
- open(verification.verification_uri).catch(() => {
296
- });
297
- }
1176
+ );
298
1177
  });
299
- const authentication = await auth({ type: "oauth" });
300
- cache.github = { token: authentication.token };
301
- await saveAuthCache(cache);
302
- return new Octokit({ auth: authentication.token });
1178
+ p.catch(() => {
1179
+ });
1180
+ return p;
303
1181
  }
304
- async function getAzureConnection(orgUrl) {
305
- const cache = await loadAuthCache();
306
- if (cache.azure?.token && cache.azure.expiresAt) {
307
- const expiresAt = new Date(cache.azure.expiresAt).getTime();
308
- if (expiresAt - Date.now() > EXPIRY_BUFFER_MS) {
309
- return new azdev.WebApi(
310
- orgUrl,
311
- azdev.getBearerHandler(cache.azure.token)
312
- );
1182
+
1183
+ // src/providers/opencode.ts
1184
+ var SESSION_READY_TIMEOUT_MS = 6e5;
1185
+ async function boot(opts) {
1186
+ let client;
1187
+ let stopServer;
1188
+ let cleaned = false;
1189
+ if (opts?.url) {
1190
+ log.debug(`Connecting to existing OpenCode server at ${opts.url}`);
1191
+ client = createOpencodeClient({ baseUrl: opts.url });
1192
+ } else {
1193
+ log.debug("No --server-url provided, spawning local OpenCode server...");
1194
+ if (opts?.cwd) {
1195
+ log.debug(`Requested cwd "${opts.cwd}" \u2014 OpenCode SDK does not support spawn-level cwd; relying on prompt-level cwd`);
313
1196
  }
314
- }
315
- const credential = new DeviceCodeCredential({
316
- tenantId: AZURE_TENANT_ID,
317
- clientId: AZURE_CLIENT_ID,
318
- userPromptCallback(deviceCodeInfo) {
319
- const note = "Azure DevOps requires a work or school account (personal Microsoft accounts are not supported).";
320
- const msg = `${note}
321
- ${deviceCodeInfo.message}`;
322
- if (authPromptHandler) {
323
- authPromptHandler(msg);
324
- } else {
325
- log.info(msg);
326
- }
327
- open(deviceCodeInfo.verificationUri).catch(() => {
328
- });
1197
+ try {
1198
+ const oc = await createOpencode({ port: 0 });
1199
+ client = oc.client;
1200
+ stopServer = () => oc.server.close();
1201
+ log.debug("OpenCode server started successfully");
1202
+ } catch (err) {
1203
+ log.debug(`Failed to start OpenCode server: ${log.formatErrorChain(err)}`);
1204
+ throw err;
329
1205
  }
330
- });
331
- const accessToken = await credential.getToken(AZURE_DEVOPS_SCOPE);
332
- if (!accessToken) {
333
- throw new Error(
334
- "Azure device-code authentication did not return a token. Please try again."
335
- );
336
1206
  }
337
- cache.azure = {
338
- token: accessToken.token,
339
- expiresAt: new Date(accessToken.expiresOnTimestamp).toISOString()
340
- };
341
- await saveAuthCache(cache);
342
- return new azdev.WebApi(
343
- orgUrl,
344
- azdev.getBearerHandler(accessToken.token)
345
- );
346
- }
347
- async function ensureAuthReady(source, cwd, org) {
348
- if (source === "github") {
349
- const remoteUrl = await getGitRemoteUrl(cwd);
350
- if (remoteUrl && parseGitHubRemoteUrl(remoteUrl)) {
351
- await getGithubOctokit();
352
- } else if (!remoteUrl) {
353
- log.warn("No git remote found \u2014 skipping GitHub pre-authentication");
1207
+ let modelOverride;
1208
+ if (opts?.model) {
1209
+ const slash = opts.model.indexOf("/");
1210
+ if (slash > 0) {
1211
+ modelOverride = {
1212
+ providerID: opts.model.slice(0, slash),
1213
+ modelID: opts.model.slice(slash + 1)
1214
+ };
1215
+ log.debug(`Model override: ${opts.model}`);
354
1216
  } else {
355
- log.warn("Remote URL is not a GitHub repository \u2014 skipping GitHub pre-authentication");
356
- }
357
- } else if (source === "azdevops") {
358
- let orgUrl = org;
359
- if (!orgUrl) {
360
- const remoteUrl = await getGitRemoteUrl(cwd);
361
- if (remoteUrl) {
362
- const parsed = parseAzDevOpsRemoteUrl(remoteUrl);
363
- if (parsed) orgUrl = parsed.orgUrl;
364
- else log.warn("Remote URL is not an Azure DevOps repository \u2014 skipping Azure pre-authentication");
365
- } else {
366
- log.warn("No git remote found \u2014 skipping Azure DevOps pre-authentication");
367
- }
1217
+ log.debug(`Ignoring model override "${opts.model}": must be in "provider/model" format`);
368
1218
  }
369
- if (orgUrl) await getAzureConnection(orgUrl);
370
1219
  }
371
- }
372
-
373
- // node_modules/@octokit/request-error/dist-src/index.js
374
- var RequestError = class extends Error {
375
- name;
376
- /**
377
- * http status code
378
- */
379
- status;
380
- /**
381
- * Request options that lead to the error.
382
- */
383
- request;
384
- /**
385
- * Response object if a response was received
386
- */
387
- response;
388
- constructor(message, statusCode, options) {
389
- super(message);
390
- this.name = "HttpError";
391
- this.status = Number.parseInt(statusCode);
392
- if (Number.isNaN(this.status)) {
393
- this.status = 0;
394
- }
395
- if ("response" in options) {
396
- this.response = options.response;
397
- }
398
- const requestCopy = Object.assign({}, options.request);
399
- if (options.request.headers.authorization) {
400
- requestCopy.headers = Object.assign({}, options.request.headers, {
401
- authorization: options.request.headers.authorization.replace(
402
- /(?<! ) .*$/,
403
- " [REDACTED]"
404
- )
405
- });
406
- }
407
- requestCopy.url = requestCopy.url.replace(/\bclient_secret=\w+/g, "client_secret=[REDACTED]").replace(/\baccess_token=\w+/g, "access_token=[REDACTED]");
408
- this.request = requestCopy;
409
- }
410
- };
411
-
412
- // src/datasources/github.ts
413
- var exec = promisify(execFile);
414
- async function git(args, cwd) {
415
- const { stdout } = await exec("git", args, { cwd, shell: process.platform === "win32" });
416
- return stdout;
417
- }
418
- function redactUrl(url) {
419
- return url.replace(/\/\/[^@/]+@/, "//***@");
420
- }
421
- var ownerRepoCache = /* @__PURE__ */ new Map();
422
- async function getOwnerRepo(cwd) {
423
- const cached = ownerRepoCache.get(cwd);
424
- if (cached) return cached;
425
- const remoteUrl = await getGitRemoteUrl(cwd);
426
- if (!remoteUrl) {
427
- throw new Error("Could not determine git remote URL. Is this a git repository with an origin remote?");
428
- }
429
- const parsed = parseGitHubRemoteUrl(remoteUrl);
430
- if (!parsed) {
431
- throw new Error(`Could not parse GitHub owner/repo from remote URL: ${redactUrl(remoteUrl)}`);
432
- }
433
- ownerRepoCache.set(cwd, parsed);
434
- return parsed;
435
- }
436
- function buildBranchName(issueNumber, _title, username = "unknown") {
437
- return `${username}/dispatch/issue-${issueNumber}`;
438
- }
439
- async function getDefaultBranch(cwd) {
440
- const PREFIX = "refs/remotes/origin/";
441
- try {
442
- const ref = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd);
443
- const trimmed = ref.trim();
444
- const branch = trimmed.startsWith(PREFIX) ? trimmed.slice(PREFIX.length) : trimmed;
445
- if (!isValidBranchName(branch)) {
446
- throw new InvalidBranchNameError(branch, "from symbolic-ref output");
447
- }
448
- return branch;
449
- } catch (err) {
450
- if (err instanceof InvalidBranchNameError) {
451
- throw err;
452
- }
453
- try {
454
- await git(["rev-parse", "--verify", "main"], cwd);
455
- return "main";
456
- } catch {
457
- return "master";
458
- }
459
- }
460
- }
461
- var datasource = {
462
- name: "github",
463
- supportsGit() {
464
- return true;
465
- },
466
- async list(opts = {}) {
467
- const cwd = opts.cwd || process.cwd();
468
- const { owner, repo } = await getOwnerRepo(cwd);
469
- const octokit = await getGithubOctokit();
470
- const issues = await octokit.paginate(
471
- octokit.rest.issues.listForRepo,
472
- {
473
- owner,
474
- repo,
475
- state: "open"
476
- }
477
- );
478
- return issues.filter((issue) => !issue.pull_request).map((issue) => ({
479
- number: String(issue.number),
480
- title: issue.title ?? "",
481
- body: issue.body ?? "",
482
- labels: (issue.labels ?? []).map((l) => typeof l === "string" ? l : l.name ?? "").filter(Boolean),
483
- state: issue.state ?? "open",
484
- url: issue.html_url ?? "",
485
- comments: [],
486
- acceptanceCriteria: ""
487
- }));
488
- },
489
- async fetch(issueId, opts = {}) {
490
- const cwd = opts.cwd || process.cwd();
491
- const { owner, repo } = await getOwnerRepo(cwd);
492
- const octokit = await getGithubOctokit();
493
- const { data: issue } = await octokit.rest.issues.get({
494
- owner,
495
- repo,
496
- issue_number: Number(issueId)
497
- });
498
- const issueComments = await octokit.paginate(
499
- octokit.rest.issues.listComments,
500
- {
501
- owner,
502
- repo,
503
- issue_number: Number(issueId)
504
- }
505
- );
506
- const comments = issueComments.map(
507
- (c) => `**${c.user?.login ?? "unknown"}:** ${c.body ?? ""}`
508
- );
509
- return {
510
- number: String(issue.number),
511
- title: issue.title ?? "",
512
- body: issue.body ?? "",
513
- labels: (issue.labels ?? []).map((l) => typeof l === "string" ? l : l.name ?? "").filter(Boolean),
514
- state: issue.state ?? "open",
515
- url: issue.html_url ?? "",
516
- comments,
517
- acceptanceCriteria: ""
518
- };
519
- },
520
- async update(issueId, title, body, opts = {}) {
521
- const cwd = opts.cwd || process.cwd();
522
- const { owner, repo } = await getOwnerRepo(cwd);
523
- const octokit = await getGithubOctokit();
524
- await octokit.rest.issues.update({
525
- owner,
526
- repo,
527
- issue_number: Number(issueId),
528
- title,
529
- body
530
- });
531
- },
532
- async close(issueId, opts = {}) {
533
- const cwd = opts.cwd || process.cwd();
534
- const { owner, repo } = await getOwnerRepo(cwd);
535
- const octokit = await getGithubOctokit();
536
- await octokit.rest.issues.update({
537
- owner,
538
- repo,
539
- issue_number: Number(issueId),
540
- state: "closed"
541
- });
542
- },
543
- async create(title, body, opts = {}) {
544
- const cwd = opts.cwd || process.cwd();
545
- const { owner, repo } = await getOwnerRepo(cwd);
546
- const octokit = await getGithubOctokit();
547
- const { data: issue } = await octokit.rest.issues.create({
548
- owner,
549
- repo,
550
- title,
551
- body
552
- });
553
- return {
554
- number: String(issue.number),
555
- title: issue.title ?? "",
556
- body: issue.body ?? "",
557
- labels: (issue.labels ?? []).map((l) => typeof l === "string" ? l : l.name ?? "").filter(Boolean),
558
- state: issue.state ?? "open",
559
- url: issue.html_url ?? "",
560
- comments: [],
561
- acceptanceCriteria: ""
562
- };
563
- },
564
- async getUsername(opts) {
565
- if (opts.username) return opts.username;
566
- return deriveShortUsername(opts.cwd, "unknown");
567
- },
568
- getDefaultBranch(opts) {
569
- return getDefaultBranch(opts.cwd);
570
- },
571
- async getCurrentBranch(opts) {
572
- try {
573
- const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], opts.cwd)).trim();
574
- if (branch && branch !== "HEAD") return branch;
575
- } catch {
576
- }
577
- return this.getDefaultBranch(opts);
578
- },
579
- buildBranchName(issueNumber, title, username) {
580
- return buildBranchName(issueNumber, title, username ?? "unknown");
581
- },
582
- async createAndSwitchBranch(branchName, opts) {
583
- const cwd = opts.cwd;
584
- try {
585
- await git(["checkout", "-b", branchName], cwd);
586
- } catch (err) {
587
- const message = log.extractMessage(err);
588
- if (message.includes("already exists")) {
589
- try {
590
- await git(["checkout", branchName], cwd);
591
- } catch (checkoutErr) {
592
- const checkoutMessage = log.extractMessage(checkoutErr);
593
- if (checkoutMessage.includes("already used by worktree")) {
594
- await git(["worktree", "prune"], cwd);
595
- await git(["checkout", branchName], cwd);
596
- } else {
597
- throw checkoutErr;
598
- }
599
- }
600
- } else {
601
- throw err;
602
- }
603
- }
604
- },
605
- async switchBranch(branchName, opts) {
606
- await git(["checkout", branchName], opts.cwd);
607
- },
608
- async pushBranch(branchName, opts) {
609
- await git(["push", "--set-upstream", "origin", branchName], opts.cwd);
610
- },
611
- async commitAllChanges(message, opts) {
612
- const cwd = opts.cwd;
613
- await git(["add", "-A"], cwd);
614
- const status = await git(["diff", "--cached", "--stat"], cwd);
615
- if (!status.trim()) {
616
- return;
617
- }
618
- await git(["commit", "-m", message], cwd);
619
- },
620
- async createPullRequest(branchName, issueNumber, title, body, opts, baseBranch) {
621
- const cwd = opts.cwd;
622
- const { owner, repo } = await getOwnerRepo(cwd);
623
- const octokit = await getGithubOctokit();
624
- const prBody = body || `Closes #${issueNumber}`;
625
- try {
626
- const target = baseBranch ?? await getDefaultBranch(cwd);
627
- const { data: pr } = await octokit.rest.pulls.create({
628
- owner,
629
- repo,
630
- title,
631
- body: prBody,
632
- head: branchName,
633
- base: target
634
- });
635
- return pr.html_url;
636
- } catch (err) {
637
- const isValidationError = err instanceof RequestError && err.status === 422;
638
- if (isValidationError) {
639
- const { data: prs } = await octokit.rest.pulls.list({
640
- owner,
641
- repo,
642
- head: `${owner}:${branchName}`,
643
- state: "open"
644
- });
645
- if (prs.length > 0) {
646
- return prs[0].html_url;
647
- }
648
- }
649
- throw err;
650
- }
651
- }
652
- };
653
-
654
- // src/datasources/azdevops.ts
655
- import { execFile as execFile2 } from "child_process";
656
- import { promisify as promisify2 } from "util";
657
- import { PullRequestStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js";
658
- var exec2 = promisify2(execFile2);
659
- var doneStateCache = /* @__PURE__ */ new Map();
660
- async function git2(args, cwd) {
661
- const { stdout } = await exec2("git", args, { cwd, shell: process.platform === "win32" });
662
- return stdout;
663
- }
664
- function redactUrl2(url) {
665
- return url.replace(/\/\/[^@/]+@/, "//***@");
666
- }
667
- async function getOrgAndProject(opts = {}) {
668
- let orgUrl = opts.org;
669
- let project = opts.project;
670
- if (!orgUrl || !project) {
671
- const cwd = opts.cwd || process.cwd();
672
- const remoteUrl = await getGitRemoteUrl(cwd);
673
- if (!remoteUrl) {
674
- throw new Error(
675
- "Could not determine git remote URL. Is this a git repository with an origin remote?"
676
- );
677
- }
678
- const parsed = parseAzDevOpsRemoteUrl(remoteUrl);
679
- if (!parsed) {
680
- throw new Error(
681
- `Could not parse Azure DevOps org/project from remote URL: ${redactUrl2(remoteUrl)}`
682
- );
683
- }
684
- orgUrl = orgUrl || parsed.orgUrl;
685
- project = project || parsed.project;
686
- }
687
- const connection = await getAzureConnection(orgUrl);
688
- return { orgUrl, project, connection };
689
- }
690
- function mapWorkItemToIssueDetails(item, id, comments, defaults) {
691
- const fields = item.fields ?? {};
692
- return {
693
- number: String(item.id ?? id),
694
- title: fields["System.Title"] ?? defaults?.title ?? "",
695
- body: fields["System.Description"] ?? defaults?.body ?? "",
696
- labels: (fields["System.Tags"] ?? "").split(";").map((t) => t.trim()).filter(Boolean),
697
- state: fields["System.State"] ?? defaults?.state ?? "",
698
- url: item._links?.html?.href ?? item.url ?? "",
699
- comments,
700
- acceptanceCriteria: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? "",
701
- iterationPath: fields["System.IterationPath"] || void 0,
702
- areaPath: fields["System.AreaPath"] || void 0,
703
- assignee: fields["System.AssignedTo"]?.displayName || void 0,
704
- priority: fields["Microsoft.VSTS.Common.Priority"] ?? void 0,
705
- storyPoints: fields["Microsoft.VSTS.Scheduling.StoryPoints"] ?? fields["Microsoft.VSTS.Scheduling.Effort"] ?? fields["Microsoft.VSTS.Scheduling.Size"] ?? void 0,
706
- workItemType: fields["System.WorkItemType"] || defaults?.workItemType || void 0
707
- };
708
- }
709
- async function detectWorkItemType(opts = {}) {
710
- try {
711
- const { project, connection } = await getOrgAndProject(opts);
712
- const witApi = await connection.getWorkItemTrackingApi();
713
- const types = await witApi.getWorkItemTypes(project);
714
- if (!Array.isArray(types) || types.length === 0) return null;
715
- const names = types.map((t) => t.name).filter((n) => !!n);
716
- const preferred = ["User Story", "Product Backlog Item", "Requirement", "Issue"];
717
- for (const p of preferred) {
718
- if (names.includes(p)) return p;
719
- }
720
- return names[0] ?? null;
721
- } catch {
722
- return null;
723
- }
724
- }
725
- async function detectDoneState(workItemType, opts = {}) {
726
- const { orgUrl, project, connection } = await getOrgAndProject(opts);
727
- const cacheKey = `${orgUrl}|${project}|${workItemType}`;
728
- const cached = doneStateCache.get(cacheKey);
729
- if (cached) return cached;
730
- try {
731
- const witApi = await connection.getWorkItemTrackingApi();
732
- const states = await witApi.getWorkItemTypeStates(project, workItemType);
733
- if (Array.isArray(states)) {
734
- const completed = states.find((s) => s.category === "Completed");
735
- if (completed?.name) {
736
- doneStateCache.set(cacheKey, completed.name);
737
- return completed.name;
738
- }
739
- const names = states.map((s) => s.name).filter((n) => !!n);
740
- const fallbacks = ["Done", "Closed", "Resolved", "Completed"];
741
- for (const f of fallbacks) {
742
- if (names.includes(f)) {
743
- doneStateCache.set(cacheKey, f);
744
- return f;
745
- }
746
- }
747
- }
748
- } catch {
749
- }
750
- return "Closed";
751
- }
752
- async function fetchComments(workItemId, project, connection) {
753
- try {
754
- const witApi = await connection.getWorkItemTrackingApi();
755
- const commentList = await witApi.getComments(project, workItemId);
756
- if (commentList.comments && Array.isArray(commentList.comments)) {
757
- return commentList.comments.map((c) => {
758
- const author = c.createdBy?.displayName ?? "unknown";
759
- return `**${author}:** ${c.text ?? ""}`;
760
- });
761
- }
762
- return [];
763
- } catch {
764
- return [];
765
- }
766
- }
767
- var datasource2 = {
768
- name: "azdevops",
769
- supportsGit() {
770
- return true;
771
- },
772
- async list(opts = {}) {
773
- const { project, connection } = await getOrgAndProject(opts);
774
- const witApi = await connection.getWorkItemTrackingApi();
775
- const conditions = [
776
- "[System.State] <> 'Closed'",
777
- "[System.State] <> 'Done'",
778
- "[System.State] <> 'Removed'"
779
- ];
780
- if (opts.iteration) {
781
- const iterValue = String(opts.iteration).trim();
782
- if (iterValue === "@CurrentIteration") {
783
- conditions.push(`[System.IterationPath] UNDER @CurrentIteration`);
784
- } else {
785
- const escaped = iterValue.replace(/'/g, "''");
786
- if (escaped) conditions.push(`[System.IterationPath] UNDER '${escaped}'`);
787
- }
788
- }
789
- if (opts.area) {
790
- const area = String(opts.area).trim().replace(/'/g, "''");
791
- if (area) {
792
- conditions.push(`[System.AreaPath] UNDER '${area}'`);
793
- }
794
- }
795
- const wiql = `SELECT [System.Id] FROM workitems WHERE ${conditions.join(" AND ")} ORDER BY [System.CreatedDate] DESC`;
796
- const queryResult = await witApi.queryByWiql({ query: wiql }, { project });
797
- const workItemRefs = queryResult.workItems ?? [];
798
- if (workItemRefs.length === 0) return [];
799
- const ids = workItemRefs.map((ref) => ref.id).filter((id) => id != null);
800
- if (ids.length === 0) return [];
801
- try {
802
- const items = await witApi.getWorkItems(ids);
803
- const itemsArray = Array.isArray(items) ? items : [items];
804
- const commentsArray = [];
805
- const CONCURRENCY = 5;
806
- for (let i = 0; i < itemsArray.length; i += CONCURRENCY) {
807
- const batch = itemsArray.slice(i, i + CONCURRENCY);
808
- const batchResults = await Promise.all(
809
- // item.id is guaranteed non-null here: the ids array was built by filtering
810
- // out null ids from workItemRefs, and itemsArray came from getWorkItems(ids).
811
- batch.map((item) => fetchComments(item.id, project, connection))
812
- );
813
- commentsArray.push(...batchResults);
814
- }
815
- return itemsArray.map(
816
- (item, i) => mapWorkItemToIssueDetails(item, String(item.id), commentsArray[i])
817
- );
818
- } catch (err) {
819
- log.debug(`Batch getWorkItems failed, falling back to individual fetches: ${log.extractMessage(err)}`);
820
- const results = await Promise.all(
821
- ids.map((id) => datasource2.fetch(String(id), opts))
822
- );
823
- return results;
824
- }
825
- },
826
- async fetch(issueId, opts = {}) {
827
- const { project, connection } = await getOrgAndProject(opts);
828
- const witApi = await connection.getWorkItemTrackingApi();
829
- const item = await witApi.getWorkItem(Number(issueId));
830
- const comments = await fetchComments(Number(issueId), project, connection);
831
- return mapWorkItemToIssueDetails(item, issueId, comments);
832
- },
833
- async update(issueId, title, body, opts = {}) {
834
- const { connection } = await getOrgAndProject(opts);
835
- const witApi = await connection.getWorkItemTrackingApi();
836
- const document = [
837
- { op: "add", path: "/fields/System.Title", value: title },
838
- { op: "add", path: "/fields/System.Description", value: body }
839
- ];
840
- await witApi.updateWorkItem(null, document, Number(issueId));
841
- },
842
- async close(issueId, opts = {}) {
843
- const { connection } = await getOrgAndProject(opts);
844
- const witApi = await connection.getWorkItemTrackingApi();
845
- let workItemType = opts.workItemType;
846
- if (!workItemType) {
847
- const item = await witApi.getWorkItem(Number(issueId));
848
- workItemType = item.fields?.["System.WorkItemType"] ?? void 0;
849
- }
850
- const state = workItemType ? await detectDoneState(workItemType, opts) : "Closed";
851
- const document = [
852
- { op: "add", path: "/fields/System.State", value: state }
853
- ];
854
- await witApi.updateWorkItem(null, document, Number(issueId));
855
- },
856
- async create(title, body, opts = {}) {
857
- const workItemType = opts.workItemType ?? await detectWorkItemType(opts);
858
- if (!workItemType) {
859
- throw new Error(
860
- "Could not determine work item type. Set workItemType in your config (for example via `dispatch config`)."
861
- );
862
- }
863
- const { project, connection } = await getOrgAndProject(opts);
864
- const witApi = await connection.getWorkItemTrackingApi();
865
- const document = [
866
- { op: "add", path: "/fields/System.Title", value: title },
867
- { op: "add", path: "/fields/System.Description", value: body }
868
- ];
869
- const item = await witApi.createWorkItem(
870
- // null as any: SDK customHeaders param — passing null is the documented way to omit it.
871
- null,
872
- document,
873
- project,
874
- workItemType
875
- );
876
- return mapWorkItemToIssueDetails(item, String(item.id), [], {
877
- title,
878
- body,
879
- state: "New",
880
- workItemType
881
- });
882
- },
883
- async getDefaultBranch(opts) {
884
- const PREFIX = "refs/remotes/origin/";
885
- try {
886
- const ref = await git2(["symbolic-ref", "refs/remotes/origin/HEAD"], opts.cwd);
887
- const trimmed = ref.trim();
888
- const branch = trimmed.startsWith(PREFIX) ? trimmed.slice(PREFIX.length) : trimmed;
889
- if (!isValidBranchName(branch)) {
890
- throw new InvalidBranchNameError(branch, "from symbolic-ref output");
891
- }
892
- return branch;
893
- } catch (err) {
894
- if (err instanceof InvalidBranchNameError) {
895
- throw err;
896
- }
897
- try {
898
- await git2(["rev-parse", "--verify", "main"], opts.cwd);
899
- return "main";
900
- } catch {
901
- return "master";
902
- }
903
- }
904
- },
905
- async getCurrentBranch(opts) {
906
- try {
907
- const branch = (await git2(["rev-parse", "--abbrev-ref", "HEAD"], opts.cwd)).trim();
908
- if (branch && branch !== "HEAD") return branch;
909
- } catch {
910
- }
911
- return this.getDefaultBranch(opts);
912
- },
913
- async getUsername(opts) {
914
- if (opts.username) return opts.username;
915
- return deriveShortUsername(opts.cwd, "unknown");
916
- },
917
- buildBranchName(issueNumber, _title, username) {
918
- const branch = `${username}/dispatch/issue-${issueNumber}`;
919
- if (!isValidBranchName(branch)) {
920
- throw new InvalidBranchNameError(branch);
921
- }
922
- return branch;
923
- },
924
- async createAndSwitchBranch(branchName, opts) {
925
- if (!isValidBranchName(branchName)) {
926
- throw new InvalidBranchNameError(branchName);
927
- }
928
- try {
929
- await git2(["checkout", "-b", branchName], opts.cwd);
930
- } catch (err) {
931
- const message = log.extractMessage(err);
932
- if (message.includes("already exists")) {
933
- try {
934
- await git2(["checkout", branchName], opts.cwd);
935
- } catch (checkoutErr) {
936
- const checkoutMessage = log.extractMessage(checkoutErr);
937
- if (checkoutMessage.includes("already used by worktree")) {
938
- await git2(["worktree", "prune"], opts.cwd);
939
- await git2(["checkout", branchName], opts.cwd);
940
- } else {
941
- throw checkoutErr;
942
- }
943
- }
944
- } else {
945
- throw err;
946
- }
947
- }
948
- },
949
- async switchBranch(branchName, opts) {
950
- await git2(["checkout", branchName], opts.cwd);
951
- },
952
- async pushBranch(branchName, opts) {
953
- await git2(["push", "--set-upstream", "origin", branchName], opts.cwd);
954
- },
955
- async commitAllChanges(message, opts) {
956
- await git2(["add", "-A"], opts.cwd);
957
- const status = await git2(["diff", "--cached", "--stat"], opts.cwd);
958
- if (!status.trim()) {
959
- return;
960
- }
961
- await git2(["commit", "-m", message], opts.cwd);
962
- },
963
- async createPullRequest(branchName, issueNumber, title, body, opts, baseBranch) {
964
- const cwd = opts.cwd;
965
- const { orgUrl, project, connection } = await getOrgAndProject(opts);
966
- const gitApi = await connection.getGitApi();
967
- const remoteUrl = await getGitRemoteUrl(cwd);
968
- if (!remoteUrl) {
969
- throw new Error("Could not determine git remote URL.");
970
- }
971
- const repos = await gitApi.getRepositories(project);
972
- const normalizeUrl = (u) => u.replace(/\/\/[^@/]+@/, "//").replace(/\.git$/, "").replace(/\/$/, "").toLowerCase();
973
- const normalizedRemote = normalizeUrl(remoteUrl);
974
- const repo = repos.find(
975
- (r) => r.remoteUrl && normalizeUrl(r.remoteUrl) === normalizedRemote || r.sshUrl && normalizeUrl(r.sshUrl) === normalizedRemote || r.webUrl && normalizeUrl(r.webUrl) === normalizedRemote
976
- );
977
- if (!repo || !repo.id) {
978
- throw new Error(`Could not find Azure DevOps repository matching remote URL: ${redactUrl2(remoteUrl)}`);
979
- }
980
- const target = baseBranch ?? await this.getDefaultBranch(opts);
981
- try {
982
- const pr = await gitApi.createPullRequest(
983
- {
984
- sourceRefName: `refs/heads/${branchName}`,
985
- targetRefName: `refs/heads/${target}`,
986
- title,
987
- description: body || `Resolves AB#${issueNumber}`,
988
- workItemRefs: [{ id: issueNumber }]
989
- },
990
- repo.id,
991
- project
992
- );
993
- const webUrl = repo.webUrl ? `${repo.webUrl}/pullrequest/${pr.pullRequestId}` : pr.url ?? "";
994
- return webUrl;
995
- } catch (err) {
996
- const message = log.extractMessage(err);
997
- if (message.includes("already exists")) {
998
- const prs = await gitApi.getPullRequests(
999
- repo.id,
1000
- {
1001
- sourceRefName: `refs/heads/${branchName}`,
1002
- status: PullRequestStatus.Active
1003
- },
1004
- project
1005
- );
1006
- if (Array.isArray(prs) && prs.length > 0) {
1007
- const existingPr = prs[0];
1008
- const webUrl = repo.webUrl ? `${repo.webUrl}/pullrequest/${existingPr.pullRequestId}` : existingPr.url ?? "";
1009
- return webUrl;
1010
- }
1011
- return "";
1012
- }
1013
- throw err;
1014
- }
1015
- }
1016
- };
1017
-
1018
- // src/datasources/md.ts
1019
- import { execFile as execFile5 } from "child_process";
1020
- import { readFile as readFile5, writeFile as writeFile3, readdir, mkdir as mkdir3, rename } from "fs/promises";
1021
- import { basename, dirname as dirname4, isAbsolute, join as join6, parse as parsePath, resolve } from "path";
1022
- import { promisify as promisify5 } from "util";
1023
- import { glob } from "glob";
1024
-
1025
- // src/helpers/slugify.ts
1026
- var MAX_SLUG_LENGTH = 60;
1027
- function slugify(input2, maxLength) {
1028
- const slug = input2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1029
- return maxLength != null ? slug.slice(0, maxLength) : slug;
1030
- }
1031
-
1032
- // src/config.ts
1033
- import { readFile as readFile4, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
1034
- import { join as join5, dirname as dirname3 } from "path";
1035
-
1036
- // src/providers/interface.ts
1037
- var PROVIDER_NAMES = ["opencode", "copilot", "claude", "codex"];
1038
-
1039
- // src/providers/opencode.ts
1040
- import { execFile as execFile3 } from "child_process";
1041
- import { promisify as promisify3 } from "util";
1042
- import {
1043
- createOpencode,
1044
- createOpencodeClient
1045
- } from "@opencode-ai/sdk";
1046
-
1047
- // src/providers/progress.ts
1048
- var ANSI_PATTERN = /(?:\u001B\[[0-?]*[ -/]*[@-~]|\u009B[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*(?:\u0007|\u001B\\))/g;
1049
- var CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
1050
- function sanitizeProgressText(raw, maxLength = 120) {
1051
- const text = raw.replace(ANSI_PATTERN, "").replace(CONTROL_PATTERN, "").replace(/\s+/g, " ").trim();
1052
- if (!text) return "";
1053
- if (text.length <= maxLength) return text;
1054
- if (maxLength <= 1) return maxLength <= 0 ? "" : "\u2026";
1055
- return `${text.slice(0, maxLength - 1).trimEnd()}\u2026`;
1056
- }
1057
- function createProgressReporter(onProgress) {
1058
- let last;
1059
- return {
1060
- emit(raw) {
1061
- if (!onProgress) return;
1062
- const text = sanitizeProgressText(raw ?? "");
1063
- if (!text || text === last) return;
1064
- last = text;
1065
- try {
1066
- onProgress({ text });
1067
- } catch {
1068
- }
1069
- },
1070
- reset() {
1071
- last = void 0;
1072
- }
1073
- };
1074
- }
1075
-
1076
- // src/helpers/guards.ts
1077
- function hasProperty(value, key) {
1078
- return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key);
1079
- }
1080
-
1081
- // src/helpers/timeout.ts
1082
- var TimeoutError = class extends Error {
1083
- /** Optional label identifying the operation that timed out. */
1084
- label;
1085
- constructor(ms, label) {
1086
- const suffix = label ? ` [${label}]` : "";
1087
- super(`Timed out after ${ms}ms${suffix}`);
1088
- this.name = "TimeoutError";
1089
- this.label = label;
1090
- }
1091
- };
1092
- var DEFAULT_PLAN_TIMEOUT_MIN = 30;
1093
- function withTimeout(promise, ms, label) {
1094
- const p = new Promise((resolve3, reject) => {
1095
- let settled = false;
1096
- const timer = setTimeout(() => {
1097
- if (settled) return;
1098
- settled = true;
1099
- reject(new TimeoutError(ms, label));
1100
- }, ms);
1101
- promise.then(
1102
- (value) => {
1103
- if (settled) return;
1104
- settled = true;
1105
- clearTimeout(timer);
1106
- resolve3(value);
1107
- },
1108
- (err) => {
1109
- if (settled) return;
1110
- settled = true;
1111
- clearTimeout(timer);
1112
- reject(err);
1113
- }
1114
- );
1115
- });
1116
- p.catch(() => {
1117
- });
1118
- return p;
1119
- }
1120
-
1121
- // src/providers/opencode.ts
1122
- var SESSION_READY_TIMEOUT_MS = 6e5;
1123
- async function boot(opts) {
1124
- let client;
1125
- let stopServer;
1126
- let cleaned = false;
1127
- if (opts?.url) {
1128
- log.debug(`Connecting to existing OpenCode server at ${opts.url}`);
1129
- client = createOpencodeClient({ baseUrl: opts.url });
1130
- } else {
1131
- log.debug("No --server-url provided, spawning local OpenCode server...");
1132
- if (opts?.cwd) {
1133
- log.debug(`Requested cwd "${opts.cwd}" \u2014 OpenCode SDK does not support spawn-level cwd; relying on prompt-level cwd`);
1134
- }
1135
- try {
1136
- const oc = await createOpencode({ port: 0 });
1137
- client = oc.client;
1138
- stopServer = () => oc.server.close();
1139
- log.debug("OpenCode server started successfully");
1140
- } catch (err) {
1141
- log.debug(`Failed to start OpenCode server: ${log.formatErrorChain(err)}`);
1142
- throw err;
1143
- }
1144
- }
1145
- let modelOverride;
1146
- if (opts?.model) {
1147
- const slash = opts.model.indexOf("/");
1148
- if (slash > 0) {
1149
- modelOverride = {
1150
- providerID: opts.model.slice(0, slash),
1151
- modelID: opts.model.slice(slash + 1)
1152
- };
1153
- log.debug(`Model override: ${opts.model}`);
1154
- } else {
1155
- log.debug(`Ignoring model override "${opts.model}": must be in "provider/model" format`);
1156
- }
1157
- }
1158
- let model = opts?.model;
1159
- if (!model) {
1160
- try {
1161
- const { data: config } = await client.config.get();
1162
- if (config?.model) {
1163
- model = config.model;
1164
- log.debug(`Detected model: ${model}`);
1165
- }
1166
- } catch (err) {
1167
- log.debug(`Failed to retrieve model from config: ${log.formatErrorChain(err)}`);
1220
+ let model = opts?.model;
1221
+ if (!model) {
1222
+ try {
1223
+ const { data: config } = await client.config.get();
1224
+ if (config?.model) {
1225
+ model = config.model;
1226
+ log.debug(`Detected model: ${model}`);
1227
+ }
1228
+ } catch (err) {
1229
+ log.debug(`Failed to retrieve model from config: ${log.formatErrorChain(err)}`);
1168
1230
  }
1169
1231
  }
1170
1232
  return {
@@ -1440,9 +1502,9 @@ async function boot2(opts) {
1440
1502
 
1441
1503
  // src/providers/claude.ts
1442
1504
  import { randomUUID } from "crypto";
1443
- import { readFile as readFile2 } from "fs/promises";
1444
- import { homedir as homedir2 } from "os";
1445
- import { join as join3 } from "path";
1505
+ import { readFile } from "fs/promises";
1506
+ import { homedir } from "os";
1507
+ import { join as join2 } from "path";
1446
1508
  import { unstable_v2_createSession } from "@anthropic-ai/claude-agent-sdk";
1447
1509
  var SESSION_READY_TIMEOUT_MS3 = 6e5;
1448
1510
  async function boot3(opts) {
@@ -1495,14 +1557,130 @@ async function boot3(opts) {
1495
1557
  }
1496
1558
  }
1497
1559
  }
1498
- })(),
1499
- SESSION_READY_TIMEOUT_MS3,
1500
- "claude session stream"
1560
+ })(),
1561
+ SESSION_READY_TIMEOUT_MS3,
1562
+ "claude session stream"
1563
+ );
1564
+ if (!receivedAssistant) {
1565
+ throw new Error("Claude stream ended before receiving an assistant message");
1566
+ }
1567
+ const result = parts.join("") || null;
1568
+ log.debug(`Prompt response received (${result?.length ?? 0} chars)`);
1569
+ return result;
1570
+ } catch (err) {
1571
+ log.debug(`Prompt failed: ${log.formatErrorChain(err)}`);
1572
+ throw err;
1573
+ }
1574
+ },
1575
+ async send(sessionId, text) {
1576
+ const session = sessions.get(sessionId);
1577
+ if (!session) {
1578
+ throw new Error(`Claude session ${sessionId} not found`);
1579
+ }
1580
+ log.debug(`Sending follow-up to session ${sessionId} (${text.length} chars)...`);
1581
+ try {
1582
+ await session.send(text);
1583
+ } catch (err) {
1584
+ log.debug(`Follow-up send failed: ${log.formatErrorChain(err)}`);
1585
+ throw err;
1586
+ }
1587
+ },
1588
+ async cleanup() {
1589
+ log.debug("Cleaning up Claude provider...");
1590
+ for (const session of sessions.values()) {
1591
+ try {
1592
+ await Promise.resolve(session.close());
1593
+ } catch (err) {
1594
+ log.debug(`Failed to close Claude session: ${log.formatErrorChain(err)}`);
1595
+ }
1596
+ }
1597
+ sessions.clear();
1598
+ }
1599
+ };
1600
+ }
1601
+
1602
+ // src/providers/codex.ts
1603
+ import { randomUUID as randomUUID2 } from "crypto";
1604
+ import { readFile as readFile2 } from "fs/promises";
1605
+ import { homedir as homedir2 } from "os";
1606
+ import { join as join3 } from "path";
1607
+ var SESSION_READY_TIMEOUT_MS4 = 6e5;
1608
+ async function loadCodexSdk() {
1609
+ return import("@openai/codex-sdk");
1610
+ }
1611
+ async function boot4(opts) {
1612
+ const model = opts?.model ?? "o4-mini";
1613
+ log.debug(`Booting Codex provider with model ${model}`);
1614
+ const { Codex, Thread } = await loadCodexSdk();
1615
+ const codex = new Codex();
1616
+ const sessions = /* @__PURE__ */ new Map();
1617
+ return {
1618
+ name: "codex",
1619
+ model,
1620
+ async createSession() {
1621
+ log.debug("Creating Codex session...");
1622
+ try {
1623
+ const sessionId = randomUUID2();
1624
+ const thread = codex.startThread({
1625
+ model,
1626
+ approvalPolicy: "never",
1627
+ sandboxMode: "workspace-write",
1628
+ ...opts?.cwd ? { workingDirectory: opts.cwd } : {}
1629
+ });
1630
+ sessions.set(sessionId, thread);
1631
+ log.debug(`Session created: ${sessionId}`);
1632
+ return sessionId;
1633
+ } catch (err) {
1634
+ log.debug(`Session creation failed: ${log.formatErrorChain(err)}`);
1635
+ throw err;
1636
+ }
1637
+ },
1638
+ async prompt(sessionId, text, options) {
1639
+ const thread = sessions.get(sessionId);
1640
+ if (!thread) {
1641
+ throw new Error(`Codex session ${sessionId} not found`);
1642
+ }
1643
+ log.debug(`Sending prompt to session ${sessionId} (${text.length} chars)...`);
1644
+ const reporter = createProgressReporter(options?.onProgress);
1645
+ try {
1646
+ reporter.emit("Waiting for Codex response");
1647
+ if (options?.onProgress) {
1648
+ const { events } = await withTimeout(
1649
+ thread.runStreamed(text),
1650
+ SESSION_READY_TIMEOUT_MS4,
1651
+ "codex thread runStreamed"
1652
+ );
1653
+ let lastAgentMessage = null;
1654
+ for await (const event of events) {
1655
+ if (event.type === "item.updated" || event.type === "item.completed") {
1656
+ if (event.item.type === "agent_message") {
1657
+ lastAgentMessage = event.item.text;
1658
+ reporter.emit(event.item.text);
1659
+ }
1660
+ }
1661
+ if (event.type === "turn.failed") {
1662
+ throw new Error(`Codex turn failed: ${event.error.message}`);
1663
+ }
1664
+ if (event.type === "item.completed" && event.item.type === "error") {
1665
+ throw new Error(`Codex error: ${event.item.message}`);
1666
+ }
1667
+ }
1668
+ reporter.emit("Finalizing response");
1669
+ log.debug(`Prompt response received (${lastAgentMessage?.length ?? 0} chars, streaming)`);
1670
+ return lastAgentMessage;
1671
+ }
1672
+ const turn = await withTimeout(
1673
+ thread.run(text),
1674
+ SESSION_READY_TIMEOUT_MS4,
1675
+ "codex thread run"
1501
1676
  );
1502
- if (!receivedAssistant) {
1503
- throw new Error("Claude stream ended before receiving an assistant message");
1677
+ for (const item of turn.items) {
1678
+ if (item.type === "error") {
1679
+ throw new Error(`Codex error: ${item.message}`);
1680
+ }
1504
1681
  }
1505
- const result = parts.join("") || null;
1682
+ reporter.emit("Finalizing response");
1683
+ const result = turn.finalResponse || null;
1506
1684
  log.debug(`Prompt response received (${result?.length ?? 0} chars)`);
1507
1685
  return result;
1508
1686
  } catch (err) {
@@ -1511,444 +1689,988 @@ async function boot3(opts) {
1511
1689
  }
1512
1690
  },
1513
1691
  async send(sessionId, text) {
1514
- const session = sessions.get(sessionId);
1515
- if (!session) {
1516
- throw new Error(`Claude session ${sessionId} not found`);
1692
+ const thread = sessions.get(sessionId);
1693
+ if (!thread) {
1694
+ throw new Error(`Codex session ${sessionId} not found`);
1517
1695
  }
1518
1696
  log.debug(`Sending follow-up to session ${sessionId} (${text.length} chars)...`);
1519
- try {
1520
- await session.send(text);
1521
- } catch (err) {
1522
- log.debug(`Follow-up send failed: ${log.formatErrorChain(err)}`);
1523
- throw err;
1524
- }
1697
+ thread.run(text).catch((err) => {
1698
+ log.debug(`Follow-up turn failed: ${log.formatErrorChain(err)}`);
1699
+ });
1525
1700
  },
1526
1701
  async cleanup() {
1527
- log.debug("Cleaning up Claude provider...");
1528
- for (const session of sessions.values()) {
1529
- try {
1530
- await Promise.resolve(session.close());
1531
- } catch (err) {
1532
- log.debug(`Failed to close Claude session: ${log.formatErrorChain(err)}`);
1533
- }
1702
+ log.debug("Cleaning up Codex provider...");
1703
+ sessions.clear();
1704
+ }
1705
+ };
1706
+ }
1707
+
1708
+ // src/providers/registry.ts
1709
+ import { execFile as execFile2 } from "child_process";
1710
+ import { promisify as promisify2 } from "util";
1711
+ var exec = promisify2(execFile2);
1712
+ var AUTH_PROBE_TIMEOUT_MS = 1e4;
1713
+ async function checkCopilotAuth() {
1714
+ if (process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN) {
1715
+ return { status: "authenticated" };
1716
+ }
1717
+ try {
1718
+ await exec("copilot", ["auth", "status"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1719
+ return { status: "authenticated" };
1720
+ } catch {
1721
+ try {
1722
+ await exec("gh", ["auth", "status"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1723
+ return { status: "authenticated" };
1724
+ } catch {
1725
+ return {
1726
+ status: "not-configured",
1727
+ hint: "Run 'copilot login' or set GITHUB_TOKEN"
1728
+ };
1729
+ }
1730
+ }
1731
+ }
1732
+ async function checkClaudeAuth() {
1733
+ if (process.env.ANTHROPIC_API_KEY) {
1734
+ return { status: "authenticated" };
1735
+ }
1736
+ try {
1737
+ await exec("claude", ["auth", "status"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1738
+ return { status: "authenticated" };
1739
+ } catch {
1740
+ return {
1741
+ status: "not-configured",
1742
+ hint: "Run 'claude auth login' or set ANTHROPIC_API_KEY"
1743
+ };
1744
+ }
1745
+ }
1746
+ async function checkCodexAuth() {
1747
+ if (process.env.OPENAI_API_KEY) {
1748
+ return { status: "authenticated" };
1749
+ }
1750
+ try {
1751
+ await exec("codex", ["login", "status"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1752
+ return { status: "authenticated" };
1753
+ } catch {
1754
+ return {
1755
+ status: "not-configured",
1756
+ hint: "Run 'codex login --device-auth' or set OPENAI_API_KEY"
1757
+ };
1758
+ }
1759
+ }
1760
+ async function checkOpencodeAuth() {
1761
+ try {
1762
+ await exec("opencode", ["--version"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1763
+ } catch {
1764
+ return {
1765
+ status: "not-configured",
1766
+ hint: "Install OpenCode (https://opencode.ai)"
1767
+ };
1768
+ }
1769
+ try {
1770
+ await exec("opencode", ["auth", "list"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1771
+ return { status: "authenticated" };
1772
+ } catch {
1773
+ return {
1774
+ status: "not-configured",
1775
+ hint: "OpenCode is installed but no credentials configured. Run 'opencode auth login' or set provider API keys"
1776
+ };
1777
+ }
1778
+ }
1779
+ var PROVIDER_REGISTRY = {
1780
+ copilot: {
1781
+ name: "copilot",
1782
+ displayName: "GitHub Copilot",
1783
+ tier: "free",
1784
+ defaultStrongModel: "claude-sonnet-4-5",
1785
+ defaultFastModel: "claude-haiku-4",
1786
+ costScore: { strong: 1, fast: 1 },
1787
+ checkAuth: checkCopilotAuth
1788
+ },
1789
+ claude: {
1790
+ name: "claude",
1791
+ displayName: "Claude Code",
1792
+ tier: "api-key",
1793
+ defaultStrongModel: "claude-sonnet-4",
1794
+ defaultFastModel: "claude-haiku-3-5",
1795
+ costScore: { strong: 5, fast: 2 },
1796
+ checkAuth: checkClaudeAuth
1797
+ },
1798
+ codex: {
1799
+ name: "codex",
1800
+ displayName: "OpenAI Codex",
1801
+ tier: "api-key",
1802
+ defaultStrongModel: "o4-mini",
1803
+ defaultFastModel: "codex-mini-latest",
1804
+ costScore: { strong: 4, fast: 1 },
1805
+ checkAuth: checkCodexAuth
1806
+ },
1807
+ opencode: {
1808
+ name: "opencode",
1809
+ displayName: "OpenCode",
1810
+ tier: "api-key",
1811
+ defaultStrongModel: "anthropic/claude-sonnet-4",
1812
+ defaultFastModel: "anthropic/claude-haiku-3-5",
1813
+ costScore: { strong: 5, fast: 3 },
1814
+ checkAuth: checkOpencodeAuth
1815
+ }
1816
+ };
1817
+
1818
+ // src/providers/detect.ts
1819
+ async function checkProviderAuthenticated(name) {
1820
+ const meta = PROVIDER_REGISTRY[name];
1821
+ const status = await meta.checkAuth();
1822
+ return status.status === "authenticated";
1823
+ }
1824
+ async function getAuthenticatedProviders(providers) {
1825
+ const results = await Promise.all(
1826
+ providers.map(async (name) => ({
1827
+ name,
1828
+ authenticated: await checkProviderAuthenticated(name)
1829
+ }))
1830
+ );
1831
+ return results.filter((r) => r.authenticated).map((r) => r.name);
1832
+ }
1833
+
1834
+ // src/providers/index.ts
1835
+ var PROVIDERS = {
1836
+ opencode: boot,
1837
+ copilot: boot2,
1838
+ claude: boot3,
1839
+ codex: boot4
1840
+ };
1841
+ async function bootProvider(name, opts) {
1842
+ const bootFn = PROVIDERS[name];
1843
+ if (!bootFn) {
1844
+ throw new Error(
1845
+ `Unknown provider "${name}". Available: ${PROVIDER_NAMES.join(", ")}`
1846
+ );
1847
+ }
1848
+ return bootFn(opts);
1849
+ }
1850
+
1851
+ // src/datasources/index.ts
1852
+ import { execFile as execFile6 } from "child_process";
1853
+ import { promisify as promisify6 } from "util";
1854
+
1855
+ // src/datasources/interface.ts
1856
+ var DATASOURCE_NAMES = ["github", "azdevops", "md"];
1857
+
1858
+ // src/datasources/github.ts
1859
+ import { execFile as execFile3 } from "child_process";
1860
+ import { promisify as promisify3 } from "util";
1861
+
1862
+ // src/helpers/branch-validation.ts
1863
+ var InvalidBranchNameError = class extends Error {
1864
+ constructor(branch, reason) {
1865
+ const detail = reason ? ` (${reason})` : "";
1866
+ super(`Invalid branch name: "${branch}"${detail}`);
1867
+ this.name = "InvalidBranchNameError";
1868
+ }
1869
+ };
1870
+ var VALID_BRANCH_NAME_RE = /^[a-zA-Z0-9._\-/]+$/;
1871
+ function isValidBranchName(name) {
1872
+ if (name.length === 0 || name.length > 255) return false;
1873
+ if (!VALID_BRANCH_NAME_RE.test(name)) return false;
1874
+ if (name.startsWith("/") || name.endsWith("/")) return false;
1875
+ if (name.includes("..")) return false;
1876
+ if (name.endsWith(".lock")) return false;
1877
+ if (name.includes("@{")) return false;
1878
+ if (name.includes("//")) return false;
1879
+ return true;
1880
+ }
1881
+
1882
+ // src/helpers/auth.ts
1883
+ import { readFile as readFile3, writeFile, mkdir, chmod } from "fs/promises";
1884
+ import { join as join4, dirname as dirname2 } from "path";
1885
+ import { homedir as homedir3 } from "os";
1886
+ import { Octokit } from "@octokit/rest";
1887
+ import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
1888
+ import { DeviceCodeCredential } from "@azure/identity";
1889
+ import * as azdev from "azure-devops-node-api";
1890
+ import open from "open";
1891
+
1892
+ // src/constants.ts
1893
+ var GITHUB_CLIENT_ID = "Ov23liUMP1Oyg811IF58";
1894
+ var AZURE_CLIENT_ID = "150a3098-01dd-4126-8b10-5e7f77492e5c";
1895
+ var AZURE_TENANT_ID = "organizations";
1896
+ var AZURE_DEVOPS_SCOPE = "499b84ac-1321-427f-aa17-267ca6975798/.default";
1897
+
1898
+ // src/helpers/auth.ts
1899
+ var AUTH_PATH = join4(homedir3(), ".dispatch", "auth.json");
1900
+ var EXPIRY_BUFFER_MS = 5 * 60 * 1e3;
1901
+ var authPromptHandler = null;
1902
+ function setAuthPromptHandler(handler) {
1903
+ authPromptHandler = handler;
1904
+ }
1905
+ async function loadAuthCache() {
1906
+ try {
1907
+ const raw = await readFile3(AUTH_PATH, "utf-8");
1908
+ return JSON.parse(raw);
1909
+ } catch {
1910
+ return {};
1911
+ }
1912
+ }
1913
+ async function saveAuthCache(cache) {
1914
+ await mkdir(dirname2(AUTH_PATH), { recursive: true });
1915
+ await writeFile(AUTH_PATH, JSON.stringify(cache, null, 2) + "\n", "utf-8");
1916
+ if (process.platform !== "win32") {
1917
+ try {
1918
+ await chmod(AUTH_PATH, 384);
1919
+ } catch {
1920
+ }
1921
+ }
1922
+ }
1923
+ async function getGithubOctokit() {
1924
+ const cache = await loadAuthCache();
1925
+ if (cache.github?.token) {
1926
+ return new Octokit({ auth: cache.github.token });
1927
+ }
1928
+ const auth = createOAuthDeviceAuth({
1929
+ clientId: GITHUB_CLIENT_ID,
1930
+ clientType: "oauth-app",
1931
+ scopes: ["repo"],
1932
+ onVerification(verification) {
1933
+ const msg = `Enter code ${verification.user_code} at ${verification.verification_uri}`;
1934
+ if (authPromptHandler) {
1935
+ authPromptHandler(msg);
1936
+ } else {
1937
+ log.info(msg);
1534
1938
  }
1535
- sessions.clear();
1939
+ open(verification.verification_uri).catch(() => {
1940
+ });
1536
1941
  }
1537
- };
1538
- }
1539
-
1540
- // src/providers/codex.ts
1541
- import { randomUUID as randomUUID2 } from "crypto";
1542
- import { readFile as readFile3 } from "fs/promises";
1543
- import { homedir as homedir3 } from "os";
1544
- import { join as join4 } from "path";
1545
- var SESSION_READY_TIMEOUT_MS4 = 6e5;
1546
- async function loadCodexSdk() {
1547
- return import("@openai/codex-sdk");
1942
+ });
1943
+ const authentication = await auth({ type: "oauth" });
1944
+ cache.github = { token: authentication.token };
1945
+ await saveAuthCache(cache);
1946
+ return new Octokit({ auth: authentication.token });
1548
1947
  }
1549
- async function boot4(opts) {
1550
- const model = opts?.model ?? "o4-mini";
1551
- log.debug(`Booting Codex provider with model ${model}`);
1552
- const { Codex, Thread } = await loadCodexSdk();
1553
- const codex = new Codex();
1554
- const sessions = /* @__PURE__ */ new Map();
1555
- return {
1556
- name: "codex",
1557
- model,
1558
- async createSession() {
1559
- log.debug("Creating Codex session...");
1560
- try {
1561
- const sessionId = randomUUID2();
1562
- const thread = codex.startThread({
1563
- model,
1564
- approvalPolicy: "never",
1565
- sandboxMode: "workspace-write",
1566
- ...opts?.cwd ? { workingDirectory: opts.cwd } : {}
1567
- });
1568
- sessions.set(sessionId, thread);
1569
- log.debug(`Session created: ${sessionId}`);
1570
- return sessionId;
1571
- } catch (err) {
1572
- log.debug(`Session creation failed: ${log.formatErrorChain(err)}`);
1573
- throw err;
1574
- }
1575
- },
1576
- async prompt(sessionId, text, options) {
1577
- const thread = sessions.get(sessionId);
1578
- if (!thread) {
1579
- throw new Error(`Codex session ${sessionId} not found`);
1580
- }
1581
- log.debug(`Sending prompt to session ${sessionId} (${text.length} chars)...`);
1582
- const reporter = createProgressReporter(options?.onProgress);
1583
- try {
1584
- reporter.emit("Waiting for Codex response");
1585
- if (options?.onProgress) {
1586
- const { events } = await withTimeout(
1587
- thread.runStreamed(text),
1588
- SESSION_READY_TIMEOUT_MS4,
1589
- "codex thread runStreamed"
1590
- );
1591
- let lastAgentMessage = null;
1592
- for await (const event of events) {
1593
- if (event.type === "item.updated" || event.type === "item.completed") {
1594
- if (event.item.type === "agent_message") {
1595
- lastAgentMessage = event.item.text;
1596
- reporter.emit(event.item.text);
1597
- }
1598
- }
1599
- if (event.type === "turn.failed") {
1600
- throw new Error(`Codex turn failed: ${event.error.message}`);
1601
- }
1602
- if (event.type === "item.completed" && event.item.type === "error") {
1603
- throw new Error(`Codex error: ${event.item.message}`);
1604
- }
1605
- }
1606
- reporter.emit("Finalizing response");
1607
- log.debug(`Prompt response received (${lastAgentMessage?.length ?? 0} chars, streaming)`);
1608
- return lastAgentMessage;
1609
- }
1610
- const turn = await withTimeout(
1611
- thread.run(text),
1612
- SESSION_READY_TIMEOUT_MS4,
1613
- "codex thread run"
1614
- );
1615
- for (const item of turn.items) {
1616
- if (item.type === "error") {
1617
- throw new Error(`Codex error: ${item.message}`);
1618
- }
1619
- }
1620
- reporter.emit("Finalizing response");
1621
- const result = turn.finalResponse || null;
1622
- log.debug(`Prompt response received (${result?.length ?? 0} chars)`);
1623
- return result;
1624
- } catch (err) {
1625
- log.debug(`Prompt failed: ${log.formatErrorChain(err)}`);
1626
- throw err;
1627
- }
1628
- },
1629
- async send(sessionId, text) {
1630
- const thread = sessions.get(sessionId);
1631
- if (!thread) {
1632
- throw new Error(`Codex session ${sessionId} not found`);
1948
+ async function getAzureConnection(orgUrl) {
1949
+ const cache = await loadAuthCache();
1950
+ if (cache.azure?.token && cache.azure.expiresAt) {
1951
+ const expiresAt = new Date(cache.azure.expiresAt).getTime();
1952
+ if (expiresAt - Date.now() > EXPIRY_BUFFER_MS) {
1953
+ return new azdev.WebApi(
1954
+ orgUrl,
1955
+ azdev.getBearerHandler(cache.azure.token)
1956
+ );
1957
+ }
1958
+ }
1959
+ const credential = new DeviceCodeCredential({
1960
+ tenantId: AZURE_TENANT_ID,
1961
+ clientId: AZURE_CLIENT_ID,
1962
+ userPromptCallback(deviceCodeInfo) {
1963
+ const note = "Azure DevOps requires a work or school account (personal Microsoft accounts are not supported).";
1964
+ const msg = `${note}
1965
+ ${deviceCodeInfo.message}`;
1966
+ if (authPromptHandler) {
1967
+ authPromptHandler(msg);
1968
+ } else {
1969
+ log.info(msg);
1633
1970
  }
1634
- log.debug(`Sending follow-up to session ${sessionId} (${text.length} chars)...`);
1635
- thread.run(text).catch((err) => {
1636
- log.debug(`Follow-up turn failed: ${log.formatErrorChain(err)}`);
1971
+ open(deviceCodeInfo.verificationUri).catch(() => {
1637
1972
  });
1638
- },
1639
- async cleanup() {
1640
- log.debug("Cleaning up Codex provider...");
1641
- sessions.clear();
1642
1973
  }
1974
+ });
1975
+ const accessToken = await credential.getToken(AZURE_DEVOPS_SCOPE);
1976
+ if (!accessToken) {
1977
+ throw new Error(
1978
+ "Azure device-code authentication did not return a token. Please try again."
1979
+ );
1980
+ }
1981
+ cache.azure = {
1982
+ token: accessToken.token,
1983
+ expiresAt: new Date(accessToken.expiresOnTimestamp).toISOString()
1643
1984
  };
1985
+ await saveAuthCache(cache);
1986
+ return new azdev.WebApi(
1987
+ orgUrl,
1988
+ azdev.getBearerHandler(accessToken.token)
1989
+ );
1644
1990
  }
1645
-
1646
- // src/providers/registry.ts
1647
- import { execFile as execFile4 } from "child_process";
1648
- import { promisify as promisify4 } from "util";
1649
- var exec3 = promisify4(execFile4);
1650
- var AUTH_PROBE_TIMEOUT_MS = 1e4;
1651
- async function checkCopilotAuth() {
1652
- if (process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN) {
1653
- return { status: "authenticated" };
1991
+ async function ensureAuthReady(source, cwd, org) {
1992
+ if (source === "github") {
1993
+ const remoteUrl = await getGitRemoteUrl(cwd);
1994
+ if (remoteUrl && parseGitHubRemoteUrl(remoteUrl)) {
1995
+ await getGithubOctokit();
1996
+ } else if (!remoteUrl) {
1997
+ log.warn("No git remote found \u2014 skipping GitHub pre-authentication");
1998
+ } else {
1999
+ log.warn("Remote URL is not a GitHub repository \u2014 skipping GitHub pre-authentication");
2000
+ }
2001
+ } else if (source === "azdevops") {
2002
+ let orgUrl = org;
2003
+ if (!orgUrl) {
2004
+ const remoteUrl = await getGitRemoteUrl(cwd);
2005
+ if (remoteUrl) {
2006
+ const parsed = parseAzDevOpsRemoteUrl(remoteUrl);
2007
+ if (parsed) orgUrl = parsed.orgUrl;
2008
+ else log.warn("Remote URL is not an Azure DevOps repository \u2014 skipping Azure pre-authentication");
2009
+ } else {
2010
+ log.warn("No git remote found \u2014 skipping Azure DevOps pre-authentication");
2011
+ }
2012
+ }
2013
+ if (orgUrl) await getAzureConnection(orgUrl);
1654
2014
  }
1655
- try {
1656
- await exec3("copilot", ["auth", "status"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1657
- return { status: "authenticated" };
1658
- } catch {
1659
- try {
1660
- await exec3("gh", ["auth", "status"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1661
- return { status: "authenticated" };
1662
- } catch {
1663
- return {
1664
- status: "not-configured",
1665
- hint: "Run 'copilot login' or set GITHUB_TOKEN"
1666
- };
2015
+ }
2016
+
2017
+ // node_modules/@octokit/request-error/dist-src/index.js
2018
+ var RequestError = class extends Error {
2019
+ name;
2020
+ /**
2021
+ * http status code
2022
+ */
2023
+ status;
2024
+ /**
2025
+ * Request options that lead to the error.
2026
+ */
2027
+ request;
2028
+ /**
2029
+ * Response object if a response was received
2030
+ */
2031
+ response;
2032
+ constructor(message, statusCode, options) {
2033
+ super(message);
2034
+ this.name = "HttpError";
2035
+ this.status = Number.parseInt(statusCode);
2036
+ if (Number.isNaN(this.status)) {
2037
+ this.status = 0;
2038
+ }
2039
+ if ("response" in options) {
2040
+ this.response = options.response;
2041
+ }
2042
+ const requestCopy = Object.assign({}, options.request);
2043
+ if (options.request.headers.authorization) {
2044
+ requestCopy.headers = Object.assign({}, options.request.headers, {
2045
+ authorization: options.request.headers.authorization.replace(
2046
+ /(?<! ) .*$/,
2047
+ " [REDACTED]"
2048
+ )
2049
+ });
1667
2050
  }
2051
+ requestCopy.url = requestCopy.url.replace(/\bclient_secret=\w+/g, "client_secret=[REDACTED]").replace(/\baccess_token=\w+/g, "access_token=[REDACTED]");
2052
+ this.request = requestCopy;
1668
2053
  }
2054
+ };
2055
+
2056
+ // src/datasources/github.ts
2057
+ var exec2 = promisify3(execFile3);
2058
+ async function git(args, cwd) {
2059
+ const { stdout } = await exec2("git", args, { cwd, shell: process.platform === "win32" });
2060
+ return stdout;
1669
2061
  }
1670
- async function checkClaudeAuth() {
1671
- if (process.env.ANTHROPIC_API_KEY) {
1672
- return { status: "authenticated" };
2062
+ function redactUrl(url) {
2063
+ return url.replace(/\/\/[^@/]+@/, "//***@");
2064
+ }
2065
+ var ownerRepoCache = /* @__PURE__ */ new Map();
2066
+ async function getOwnerRepo(cwd) {
2067
+ const cached = ownerRepoCache.get(cwd);
2068
+ if (cached) return cached;
2069
+ const remoteUrl = await getGitRemoteUrl(cwd);
2070
+ if (!remoteUrl) {
2071
+ throw new Error("Could not determine git remote URL. Is this a git repository with an origin remote?");
1673
2072
  }
1674
- try {
1675
- await exec3("claude", ["auth", "status"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1676
- return { status: "authenticated" };
1677
- } catch {
1678
- return {
1679
- status: "not-configured",
1680
- hint: "Run 'claude auth login' or set ANTHROPIC_API_KEY"
1681
- };
2073
+ const parsed = parseGitHubRemoteUrl(remoteUrl);
2074
+ if (!parsed) {
2075
+ throw new Error(`Could not parse GitHub owner/repo from remote URL: ${redactUrl(remoteUrl)}`);
1682
2076
  }
2077
+ ownerRepoCache.set(cwd, parsed);
2078
+ return parsed;
1683
2079
  }
1684
- async function checkCodexAuth() {
1685
- if (process.env.OPENAI_API_KEY) {
1686
- return { status: "authenticated" };
1687
- }
2080
+ function buildBranchName(issueNumber, _title, username = "unknown") {
2081
+ return `${username}/dispatch/issue-${issueNumber}`;
2082
+ }
2083
+ async function getDefaultBranch(cwd) {
2084
+ const PREFIX = "refs/remotes/origin/";
1688
2085
  try {
1689
- await exec3("codex", ["login", "status"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1690
- return { status: "authenticated" };
1691
- } catch {
1692
- return {
1693
- status: "not-configured",
1694
- hint: "Run 'codex login --device-auth' or set OPENAI_API_KEY"
1695
- };
2086
+ const ref = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd);
2087
+ const trimmed = ref.trim();
2088
+ const branch = trimmed.startsWith(PREFIX) ? trimmed.slice(PREFIX.length) : trimmed;
2089
+ if (!isValidBranchName(branch)) {
2090
+ throw new InvalidBranchNameError(branch, "from symbolic-ref output");
2091
+ }
2092
+ return branch;
2093
+ } catch (err) {
2094
+ if (err instanceof InvalidBranchNameError) {
2095
+ throw err;
2096
+ }
2097
+ try {
2098
+ await git(["rev-parse", "--verify", "main"], cwd);
2099
+ return "main";
2100
+ } catch {
2101
+ return "master";
2102
+ }
1696
2103
  }
1697
2104
  }
1698
- async function checkOpencodeAuth() {
1699
- try {
1700
- await exec3("opencode", ["--version"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1701
- } catch {
2105
+ var datasource = {
2106
+ name: "github",
2107
+ supportsGit() {
2108
+ return true;
2109
+ },
2110
+ async list(opts = {}) {
2111
+ const cwd = opts.cwd || process.cwd();
2112
+ const { owner, repo } = await getOwnerRepo(cwd);
2113
+ const octokit = await getGithubOctokit();
2114
+ const issues = await octokit.paginate(
2115
+ octokit.rest.issues.listForRepo,
2116
+ {
2117
+ owner,
2118
+ repo,
2119
+ state: "open"
2120
+ }
2121
+ );
2122
+ return issues.filter((issue) => !issue.pull_request).map((issue) => ({
2123
+ number: String(issue.number),
2124
+ title: issue.title ?? "",
2125
+ body: issue.body ?? "",
2126
+ labels: (issue.labels ?? []).map((l) => typeof l === "string" ? l : l.name ?? "").filter(Boolean),
2127
+ state: issue.state ?? "open",
2128
+ url: issue.html_url ?? "",
2129
+ comments: [],
2130
+ acceptanceCriteria: ""
2131
+ }));
2132
+ },
2133
+ async fetch(issueId, opts = {}) {
2134
+ const cwd = opts.cwd || process.cwd();
2135
+ const { owner, repo } = await getOwnerRepo(cwd);
2136
+ const octokit = await getGithubOctokit();
2137
+ const { data: issue } = await octokit.rest.issues.get({
2138
+ owner,
2139
+ repo,
2140
+ issue_number: Number(issueId)
2141
+ });
2142
+ const issueComments = await octokit.paginate(
2143
+ octokit.rest.issues.listComments,
2144
+ {
2145
+ owner,
2146
+ repo,
2147
+ issue_number: Number(issueId)
2148
+ }
2149
+ );
2150
+ const comments = issueComments.map(
2151
+ (c) => `**${c.user?.login ?? "unknown"}:** ${c.body ?? ""}`
2152
+ );
1702
2153
  return {
1703
- status: "not-configured",
1704
- hint: "Install OpenCode (https://opencode.ai)"
2154
+ number: String(issue.number),
2155
+ title: issue.title ?? "",
2156
+ body: issue.body ?? "",
2157
+ labels: (issue.labels ?? []).map((l) => typeof l === "string" ? l : l.name ?? "").filter(Boolean),
2158
+ state: issue.state ?? "open",
2159
+ url: issue.html_url ?? "",
2160
+ comments,
2161
+ acceptanceCriteria: ""
1705
2162
  };
1706
- }
1707
- try {
1708
- await exec3("opencode", ["auth", "list"], { timeout: AUTH_PROBE_TIMEOUT_MS });
1709
- return { status: "authenticated" };
1710
- } catch {
2163
+ },
2164
+ async update(issueId, title, body, opts = {}) {
2165
+ const cwd = opts.cwd || process.cwd();
2166
+ const { owner, repo } = await getOwnerRepo(cwd);
2167
+ const octokit = await getGithubOctokit();
2168
+ await octokit.rest.issues.update({
2169
+ owner,
2170
+ repo,
2171
+ issue_number: Number(issueId),
2172
+ title,
2173
+ body
2174
+ });
2175
+ },
2176
+ async close(issueId, opts = {}) {
2177
+ const cwd = opts.cwd || process.cwd();
2178
+ const { owner, repo } = await getOwnerRepo(cwd);
2179
+ const octokit = await getGithubOctokit();
2180
+ await octokit.rest.issues.update({
2181
+ owner,
2182
+ repo,
2183
+ issue_number: Number(issueId),
2184
+ state: "closed"
2185
+ });
2186
+ },
2187
+ async create(title, body, opts = {}) {
2188
+ const cwd = opts.cwd || process.cwd();
2189
+ const { owner, repo } = await getOwnerRepo(cwd);
2190
+ const octokit = await getGithubOctokit();
2191
+ const { data: issue } = await octokit.rest.issues.create({
2192
+ owner,
2193
+ repo,
2194
+ title,
2195
+ body
2196
+ });
1711
2197
  return {
1712
- status: "not-configured",
1713
- hint: "OpenCode is installed but no credentials configured. Run 'opencode auth login' or set provider API keys"
2198
+ number: String(issue.number),
2199
+ title: issue.title ?? "",
2200
+ body: issue.body ?? "",
2201
+ labels: (issue.labels ?? []).map((l) => typeof l === "string" ? l : l.name ?? "").filter(Boolean),
2202
+ state: issue.state ?? "open",
2203
+ url: issue.html_url ?? "",
2204
+ comments: [],
2205
+ acceptanceCriteria: ""
1714
2206
  };
1715
- }
1716
- }
1717
- var PROVIDER_REGISTRY = {
1718
- copilot: {
1719
- name: "copilot",
1720
- displayName: "GitHub Copilot",
1721
- tier: "free",
1722
- defaultStrongModel: "claude-sonnet-4-5",
1723
- defaultFastModel: "claude-haiku-4",
1724
- costScore: { strong: 1, fast: 1 },
1725
- checkAuth: checkCopilotAuth
1726
2207
  },
1727
- claude: {
1728
- name: "claude",
1729
- displayName: "Claude Code",
1730
- tier: "api-key",
1731
- defaultStrongModel: "claude-sonnet-4",
1732
- defaultFastModel: "claude-haiku-3-5",
1733
- costScore: { strong: 5, fast: 2 },
1734
- checkAuth: checkClaudeAuth
2208
+ async getUsername(opts) {
2209
+ if (opts.username) return opts.username;
2210
+ return deriveShortUsername(opts.cwd, "unknown");
1735
2211
  },
1736
- codex: {
1737
- name: "codex",
1738
- displayName: "OpenAI Codex",
1739
- tier: "api-key",
1740
- defaultStrongModel: "o4-mini",
1741
- defaultFastModel: "codex-mini-latest",
1742
- costScore: { strong: 4, fast: 1 },
1743
- checkAuth: checkCodexAuth
2212
+ getDefaultBranch(opts) {
2213
+ return getDefaultBranch(opts.cwd);
1744
2214
  },
1745
- opencode: {
1746
- name: "opencode",
1747
- displayName: "OpenCode",
1748
- tier: "api-key",
1749
- defaultStrongModel: "anthropic/claude-sonnet-4",
1750
- defaultFastModel: "anthropic/claude-haiku-3-5",
1751
- costScore: { strong: 5, fast: 3 },
1752
- checkAuth: checkOpencodeAuth
2215
+ async getCurrentBranch(opts) {
2216
+ try {
2217
+ const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], opts.cwd)).trim();
2218
+ if (branch && branch !== "HEAD") return branch;
2219
+ } catch {
2220
+ }
2221
+ return this.getDefaultBranch(opts);
2222
+ },
2223
+ buildBranchName(issueNumber, title, username) {
2224
+ return buildBranchName(issueNumber, title, username ?? "unknown");
2225
+ },
2226
+ async createAndSwitchBranch(branchName, opts) {
2227
+ const cwd = opts.cwd;
2228
+ try {
2229
+ await git(["checkout", "-b", branchName], cwd);
2230
+ } catch (err) {
2231
+ const message = log.extractMessage(err);
2232
+ if (message.includes("already exists")) {
2233
+ try {
2234
+ await git(["checkout", branchName], cwd);
2235
+ } catch (checkoutErr) {
2236
+ const checkoutMessage = log.extractMessage(checkoutErr);
2237
+ if (checkoutMessage.includes("already used by worktree")) {
2238
+ await git(["worktree", "prune"], cwd);
2239
+ await git(["checkout", branchName], cwd);
2240
+ } else {
2241
+ throw checkoutErr;
2242
+ }
2243
+ }
2244
+ } else {
2245
+ throw err;
2246
+ }
2247
+ }
2248
+ },
2249
+ async switchBranch(branchName, opts) {
2250
+ await git(["checkout", branchName], opts.cwd);
2251
+ },
2252
+ async pushBranch(branchName, opts) {
2253
+ await git(["push", "--set-upstream", "origin", branchName], opts.cwd);
2254
+ },
2255
+ async commitAllChanges(message, opts) {
2256
+ const cwd = opts.cwd;
2257
+ await git(["add", "-A"], cwd);
2258
+ const status = await git(["diff", "--cached", "--stat"], cwd);
2259
+ if (!status.trim()) {
2260
+ return;
2261
+ }
2262
+ await git(["commit", "-m", message], cwd);
2263
+ },
2264
+ async createPullRequest(branchName, issueNumber, title, body, opts, baseBranch) {
2265
+ const cwd = opts.cwd;
2266
+ const { owner, repo } = await getOwnerRepo(cwd);
2267
+ const octokit = await getGithubOctokit();
2268
+ const prBody = body || `Closes #${issueNumber}`;
2269
+ try {
2270
+ const target = baseBranch ?? await getDefaultBranch(cwd);
2271
+ const { data: pr } = await octokit.rest.pulls.create({
2272
+ owner,
2273
+ repo,
2274
+ title,
2275
+ body: prBody,
2276
+ head: branchName,
2277
+ base: target
2278
+ });
2279
+ return pr.html_url;
2280
+ } catch (err) {
2281
+ const isValidationError = err instanceof RequestError && err.status === 422;
2282
+ if (isValidationError) {
2283
+ const { data: prs } = await octokit.rest.pulls.list({
2284
+ owner,
2285
+ repo,
2286
+ head: `${owner}:${branchName}`,
2287
+ state: "open"
2288
+ });
2289
+ if (prs.length > 0) {
2290
+ return prs[0].html_url;
2291
+ }
2292
+ }
2293
+ throw err;
2294
+ }
1753
2295
  }
1754
2296
  };
1755
2297
 
1756
- // src/providers/detect.ts
1757
- async function checkProviderAuthenticated(name) {
1758
- const meta = PROVIDER_REGISTRY[name];
1759
- const status = await meta.checkAuth();
1760
- return status.status === "authenticated";
2298
+ // src/datasources/azdevops.ts
2299
+ import { execFile as execFile4 } from "child_process";
2300
+ import { promisify as promisify4 } from "util";
2301
+ import { PullRequestStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js";
2302
+ var exec3 = promisify4(execFile4);
2303
+ var doneStateCache = /* @__PURE__ */ new Map();
2304
+ async function git2(args, cwd) {
2305
+ const { stdout } = await exec3("git", args, { cwd, shell: process.platform === "win32" });
2306
+ return stdout;
1761
2307
  }
1762
- async function getAuthenticatedProviders(providers) {
1763
- const results = await Promise.all(
1764
- providers.map(async (name) => ({
1765
- name,
1766
- authenticated: await checkProviderAuthenticated(name)
1767
- }))
1768
- );
1769
- return results.filter((r) => r.authenticated).map((r) => r.name);
2308
+ function redactUrl2(url) {
2309
+ return url.replace(/\/\/[^@/]+@/, "//***@");
1770
2310
  }
1771
-
1772
- // src/providers/index.ts
1773
- var PROVIDERS = {
1774
- opencode: boot,
1775
- copilot: boot2,
1776
- claude: boot3,
1777
- codex: boot4
1778
- };
1779
- async function bootProvider(name, opts) {
1780
- const bootFn = PROVIDERS[name];
1781
- if (!bootFn) {
1782
- throw new Error(
1783
- `Unknown provider "${name}". Available: ${PROVIDER_NAMES.join(", ")}`
1784
- );
2311
+ async function getOrgAndProject(opts = {}) {
2312
+ let orgUrl = opts.org;
2313
+ let project = opts.project;
2314
+ if (!orgUrl || !project) {
2315
+ const cwd = opts.cwd || process.cwd();
2316
+ const remoteUrl = await getGitRemoteUrl(cwd);
2317
+ if (!remoteUrl) {
2318
+ throw new Error(
2319
+ "Could not determine git remote URL. Is this a git repository with an origin remote?"
2320
+ );
2321
+ }
2322
+ const parsed = parseAzDevOpsRemoteUrl(remoteUrl);
2323
+ if (!parsed) {
2324
+ throw new Error(
2325
+ `Could not parse Azure DevOps org/project from remote URL: ${redactUrl2(remoteUrl)}`
2326
+ );
2327
+ }
2328
+ orgUrl = orgUrl || parsed.orgUrl;
2329
+ project = project || parsed.project;
1785
2330
  }
1786
- return bootFn(opts);
2331
+ const connection = await getAzureConnection(orgUrl);
2332
+ return { orgUrl, project, connection };
1787
2333
  }
1788
-
1789
- // src/config-prompts.tsx
1790
- import chalk3 from "chalk";
1791
-
1792
- // src/helpers/ink-prompts.tsx
1793
- import { useState, useRef, useEffect } from "react";
1794
- import { render, Box, Text, useInput, useApp } from "ink";
1795
- import SelectInput from "ink-select-input";
1796
- import TextInput from "ink-text-input";
1797
-
1798
- // src/helpers/format.ts
1799
- import chalk2 from "chalk";
1800
- var PALETTE = {
1801
- brand: "#58A6FF",
1802
- subtitle: "#484F58",
1803
- chrome: "#30363D",
1804
- text: "#C9D1D9",
1805
- muted: "#484F58",
1806
- success: "#56D364",
1807
- error: "#F85149",
1808
- warn: "#D29922",
1809
- accent: "#79C0FF",
1810
- planning: "#D2A8FF"
1811
- };
1812
- function elapsed(ms) {
1813
- const s = Math.floor(ms / 1e3);
1814
- const m = Math.floor(s / 60);
1815
- const sec = s % 60;
1816
- if (m > 0) return `${m}m ${sec}s`;
1817
- return `${sec}s`;
2334
+ function mapWorkItemToIssueDetails(item, id, comments, defaults) {
2335
+ const fields = item.fields ?? {};
2336
+ return {
2337
+ number: String(item.id ?? id),
2338
+ title: fields["System.Title"] ?? defaults?.title ?? "",
2339
+ body: fields["System.Description"] ?? defaults?.body ?? "",
2340
+ labels: (fields["System.Tags"] ?? "").split(";").map((t) => t.trim()).filter(Boolean),
2341
+ state: fields["System.State"] ?? defaults?.state ?? "",
2342
+ url: item._links?.html?.href ?? item.url ?? "",
2343
+ comments,
2344
+ acceptanceCriteria: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] ?? "",
2345
+ iterationPath: fields["System.IterationPath"] || void 0,
2346
+ areaPath: fields["System.AreaPath"] || void 0,
2347
+ assignee: fields["System.AssignedTo"]?.displayName || void 0,
2348
+ priority: fields["Microsoft.VSTS.Common.Priority"] ?? void 0,
2349
+ storyPoints: fields["Microsoft.VSTS.Scheduling.StoryPoints"] ?? fields["Microsoft.VSTS.Scheduling.Effort"] ?? fields["Microsoft.VSTS.Scheduling.Size"] ?? void 0,
2350
+ workItemType: fields["System.WorkItemType"] || defaults?.workItemType || void 0
2351
+ };
1818
2352
  }
1819
- function renderHeaderLines(info) {
1820
- const lines = [];
1821
- lines.push(chalk2.bold.white(" \u26A1 dispatch") + chalk2.dim(` \u2014 AI task orchestration`));
1822
- if (info.provider) {
1823
- lines.push(chalk2.dim(` provider: ${info.provider}`));
1824
- }
1825
- if (info.model) {
1826
- lines.push(chalk2.dim(` model: ${info.model}`));
1827
- }
1828
- if (info.source) {
1829
- lines.push(chalk2.dim(` source: ${info.source}`));
2353
+ async function detectWorkItemType(opts = {}) {
2354
+ try {
2355
+ const { project, connection } = await getOrgAndProject(opts);
2356
+ const witApi = await connection.getWorkItemTrackingApi();
2357
+ const types = await witApi.getWorkItemTypes(project);
2358
+ if (!Array.isArray(types) || types.length === 0) return null;
2359
+ const names = types.map((t) => t.name).filter((n) => !!n);
2360
+ const preferred = ["User Story", "Product Backlog Item", "Requirement", "Issue"];
2361
+ for (const p of preferred) {
2362
+ if (names.includes(p)) return p;
2363
+ }
2364
+ return names[0] ?? null;
2365
+ } catch {
2366
+ return null;
1830
2367
  }
1831
- return lines;
1832
2368
  }
1833
-
1834
- // src/helpers/ink-prompts.tsx
1835
- import { jsx, jsxs } from "react/jsx-runtime";
1836
- function input(opts) {
1837
- return new Promise((resolve3) => {
1838
- function InputPrompt() {
1839
- const { exit } = useApp();
1840
- const [value, setValue] = useState(opts.default ?? "");
1841
- return /* @__PURE__ */ jsxs(Box, { children: [
1842
- /* @__PURE__ */ jsx(Text, { color: PALETTE.accent, bold: true, children: "? " }),
1843
- /* @__PURE__ */ jsxs(Text, { children: [
1844
- opts.message,
1845
- " "
1846
- ] }),
1847
- /* @__PURE__ */ jsx(
1848
- TextInput,
1849
- {
1850
- value,
1851
- onChange: setValue,
1852
- onSubmit: (val) => {
1853
- resolve3(val);
1854
- exit();
1855
- }
1856
- }
1857
- )
1858
- ] });
2369
+ async function detectDoneState(workItemType, opts = {}) {
2370
+ const { orgUrl, project, connection } = await getOrgAndProject(opts);
2371
+ const cacheKey = `${orgUrl}|${project}|${workItemType}`;
2372
+ const cached = doneStateCache.get(cacheKey);
2373
+ if (cached) return cached;
2374
+ try {
2375
+ const witApi = await connection.getWorkItemTrackingApi();
2376
+ const states = await witApi.getWorkItemTypeStates(project, workItemType);
2377
+ if (Array.isArray(states)) {
2378
+ const completed = states.find((s) => s.category === "Completed");
2379
+ if (completed?.name) {
2380
+ doneStateCache.set(cacheKey, completed.name);
2381
+ return completed.name;
2382
+ }
2383
+ const names = states.map((s) => s.name).filter((n) => !!n);
2384
+ const fallbacks = ["Done", "Closed", "Resolved", "Completed"];
2385
+ for (const f of fallbacks) {
2386
+ if (names.includes(f)) {
2387
+ doneStateCache.set(cacheKey, f);
2388
+ return f;
2389
+ }
2390
+ }
1859
2391
  }
1860
- const instance = render(/* @__PURE__ */ jsx(InputPrompt, {}));
1861
- instance.waitUntilExit().catch(() => {
1862
- });
1863
- });
1864
- }
1865
-
1866
- // src/providers/auth-setup.tsx
1867
- import { spawn } from "child_process";
1868
-
1869
- // src/config.ts
1870
- var CONFIG_KEYS = ["enabledProviders", "providerModels", "source", "planTimeout", "specTimeout", "specWarnTimeout", "specKillTimeout", "concurrency", "org", "project", "workItemType", "iteration", "area", "username"];
1871
- function getConfigPath(configDir) {
1872
- const dir = configDir ?? join5(process.cwd(), ".dispatch");
1873
- return join5(dir, "config.json");
2392
+ } catch {
2393
+ }
2394
+ return "Closed";
1874
2395
  }
1875
- async function loadConfig(configDir) {
1876
- const configPath = getConfigPath(configDir);
2396
+ async function fetchComments(workItemId, project, connection) {
1877
2397
  try {
1878
- const raw = await readFile4(configPath, "utf-8");
1879
- const parsed = JSON.parse(raw);
1880
- return migrateConfig(parsed);
2398
+ const witApi = await connection.getWorkItemTrackingApi();
2399
+ const commentList = await witApi.getComments(project, workItemId);
2400
+ if (commentList.comments && Array.isArray(commentList.comments)) {
2401
+ return commentList.comments.map((c) => {
2402
+ const author = c.createdBy?.displayName ?? "unknown";
2403
+ return `**${author}:** ${c.text ?? ""}`;
2404
+ });
2405
+ }
2406
+ return [];
1881
2407
  } catch {
1882
- return {};
2408
+ return [];
1883
2409
  }
1884
2410
  }
1885
- function migrateConfig(raw) {
1886
- const config = {};
1887
- if (!raw.enabledProviders && raw.provider) {
1888
- const providers = /* @__PURE__ */ new Set();
1889
- const legacyProvider = raw.provider;
1890
- if (PROVIDER_NAMES.includes(legacyProvider)) {
1891
- providers.add(legacyProvider);
2411
+ var datasource2 = {
2412
+ name: "azdevops",
2413
+ supportsGit() {
2414
+ return true;
2415
+ },
2416
+ async list(opts = {}) {
2417
+ const { project, connection } = await getOrgAndProject(opts);
2418
+ const witApi = await connection.getWorkItemTrackingApi();
2419
+ const conditions = [
2420
+ "[System.State] <> 'Closed'",
2421
+ "[System.State] <> 'Done'",
2422
+ "[System.State] <> 'Removed'"
2423
+ ];
2424
+ if (opts.iteration) {
2425
+ const iterValue = String(opts.iteration).trim();
2426
+ if (iterValue === "@CurrentIteration") {
2427
+ conditions.push(`[System.IterationPath] UNDER @CurrentIteration`);
2428
+ } else {
2429
+ const escaped = iterValue.replace(/'/g, "''");
2430
+ if (escaped) conditions.push(`[System.IterationPath] UNDER '${escaped}'`);
2431
+ }
1892
2432
  }
1893
- if (raw.fastProvider) {
1894
- const fastProvider = raw.fastProvider;
1895
- if (PROVIDER_NAMES.includes(fastProvider)) {
1896
- providers.add(fastProvider);
2433
+ if (opts.area) {
2434
+ const area = String(opts.area).trim().replace(/'/g, "''");
2435
+ if (area) {
2436
+ conditions.push(`[System.AreaPath] UNDER '${area}'`);
1897
2437
  }
1898
2438
  }
1899
- if (raw.agents && typeof raw.agents === "object") {
1900
- for (const agentCfg of Object.values(raw.agents)) {
1901
- if (agentCfg?.provider && PROVIDER_NAMES.includes(agentCfg.provider)) {
1902
- providers.add(agentCfg.provider);
1903
- }
2439
+ const wiql = `SELECT [System.Id] FROM workitems WHERE ${conditions.join(" AND ")} ORDER BY [System.CreatedDate] DESC`;
2440
+ const queryResult = await witApi.queryByWiql({ query: wiql }, { project });
2441
+ const workItemRefs = queryResult.workItems ?? [];
2442
+ if (workItemRefs.length === 0) return [];
2443
+ const ids = workItemRefs.map((ref) => ref.id).filter((id) => id != null);
2444
+ if (ids.length === 0) return [];
2445
+ try {
2446
+ const items = await witApi.getWorkItems(ids);
2447
+ const itemsArray = Array.isArray(items) ? items : [items];
2448
+ const commentsArray = [];
2449
+ const CONCURRENCY = 5;
2450
+ for (let i = 0; i < itemsArray.length; i += CONCURRENCY) {
2451
+ const batch = itemsArray.slice(i, i + CONCURRENCY);
2452
+ const batchResults = await Promise.all(
2453
+ // item.id is guaranteed non-null here: the ids array was built by filtering
2454
+ // out null ids from workItemRefs, and itemsArray came from getWorkItems(ids).
2455
+ batch.map((item) => fetchComments(item.id, project, connection))
2456
+ );
2457
+ commentsArray.push(...batchResults);
1904
2458
  }
2459
+ return itemsArray.map(
2460
+ (item, i) => mapWorkItemToIssueDetails(item, String(item.id), commentsArray[i])
2461
+ );
2462
+ } catch (err) {
2463
+ log.debug(`Batch getWorkItems failed, falling back to individual fetches: ${log.extractMessage(err)}`);
2464
+ const results = await Promise.all(
2465
+ ids.map((id) => datasource2.fetch(String(id), opts))
2466
+ );
2467
+ return results;
1905
2468
  }
1906
- if (providers.size > 0) {
1907
- config.enabledProviders = [...providers];
2469
+ },
2470
+ async fetch(issueId, opts = {}) {
2471
+ const { project, connection } = await getOrgAndProject(opts);
2472
+ const witApi = await connection.getWorkItemTrackingApi();
2473
+ const item = await witApi.getWorkItem(Number(issueId));
2474
+ const comments = await fetchComments(Number(issueId), project, connection);
2475
+ return mapWorkItemToIssueDetails(item, issueId, comments);
2476
+ },
2477
+ async update(issueId, title, body, opts = {}) {
2478
+ const { connection } = await getOrgAndProject(opts);
2479
+ const witApi = await connection.getWorkItemTrackingApi();
2480
+ const document = [
2481
+ { op: "add", path: "/fields/System.Title", value: title },
2482
+ { op: "add", path: "/fields/System.Description", value: body }
2483
+ ];
2484
+ await witApi.updateWorkItem(null, document, Number(issueId));
2485
+ },
2486
+ async close(issueId, opts = {}) {
2487
+ const { connection } = await getOrgAndProject(opts);
2488
+ const witApi = await connection.getWorkItemTrackingApi();
2489
+ let workItemType = opts.workItemType;
2490
+ if (!workItemType) {
2491
+ const item = await witApi.getWorkItem(Number(issueId));
2492
+ workItemType = item.fields?.["System.WorkItemType"] ?? void 0;
1908
2493
  }
1909
- } else if (Array.isArray(raw.enabledProviders)) {
1910
- config.enabledProviders = raw.enabledProviders.filter(
1911
- (p) => PROVIDER_NAMES.includes(p)
2494
+ const state = workItemType ? await detectDoneState(workItemType, opts) : "Closed";
2495
+ const document = [
2496
+ { op: "add", path: "/fields/System.State", value: state }
2497
+ ];
2498
+ await witApi.updateWorkItem(null, document, Number(issueId));
2499
+ },
2500
+ async create(title, body, opts = {}) {
2501
+ const workItemType = opts.workItemType ?? await detectWorkItemType(opts);
2502
+ if (!workItemType) {
2503
+ throw new Error(
2504
+ "Could not determine work item type. Set workItemType in your config (for example via `dispatch config`)."
2505
+ );
2506
+ }
2507
+ const { project, connection } = await getOrgAndProject(opts);
2508
+ const witApi = await connection.getWorkItemTrackingApi();
2509
+ const document = [
2510
+ { op: "add", path: "/fields/System.Title", value: title },
2511
+ { op: "add", path: "/fields/System.Description", value: body }
2512
+ ];
2513
+ const item = await witApi.createWorkItem(
2514
+ // null as any: SDK customHeaders param — passing null is the documented way to omit it.
2515
+ null,
2516
+ document,
2517
+ project,
2518
+ workItemType
1912
2519
  );
1913
- }
1914
- if (raw.providerModels !== void 0 && typeof raw.providerModels === "object" && raw.providerModels !== null) {
1915
- const validated = {};
1916
- for (const [providerName, modelCfg] of Object.entries(raw.providerModels)) {
1917
- if (!PROVIDER_NAMES.includes(providerName)) continue;
1918
- if (typeof modelCfg !== "object" || modelCfg === null) continue;
1919
- const cfg = modelCfg;
1920
- const entry = {};
1921
- if (typeof cfg.strong === "string") entry.strong = cfg.strong;
1922
- if (typeof cfg.fast === "string") entry.fast = cfg.fast;
1923
- if (entry.strong !== void 0 || entry.fast !== void 0) {
1924
- validated[providerName] = entry;
2520
+ return mapWorkItemToIssueDetails(item, String(item.id), [], {
2521
+ title,
2522
+ body,
2523
+ state: "New",
2524
+ workItemType
2525
+ });
2526
+ },
2527
+ async getDefaultBranch(opts) {
2528
+ const PREFIX = "refs/remotes/origin/";
2529
+ try {
2530
+ const ref = await git2(["symbolic-ref", "refs/remotes/origin/HEAD"], opts.cwd);
2531
+ const trimmed = ref.trim();
2532
+ const branch = trimmed.startsWith(PREFIX) ? trimmed.slice(PREFIX.length) : trimmed;
2533
+ if (!isValidBranchName(branch)) {
2534
+ throw new InvalidBranchNameError(branch, "from symbolic-ref output");
2535
+ }
2536
+ return branch;
2537
+ } catch (err) {
2538
+ if (err instanceof InvalidBranchNameError) {
2539
+ throw err;
2540
+ }
2541
+ try {
2542
+ await git2(["rev-parse", "--verify", "main"], opts.cwd);
2543
+ return "main";
2544
+ } catch {
2545
+ return "master";
1925
2546
  }
1926
2547
  }
1927
- if (Object.keys(validated).length > 0) {
1928
- config.providerModels = validated;
2548
+ },
2549
+ async getCurrentBranch(opts) {
2550
+ try {
2551
+ const branch = (await git2(["rev-parse", "--abbrev-ref", "HEAD"], opts.cwd)).trim();
2552
+ if (branch && branch !== "HEAD") return branch;
2553
+ } catch {
1929
2554
  }
1930
- }
1931
- if (typeof raw.source === "string" && DATASOURCE_NAMES.includes(raw.source)) {
1932
- config.source = raw.source;
1933
- }
1934
- if (typeof raw.planTimeout === "number") config.planTimeout = raw.planTimeout;
1935
- if (typeof raw.specTimeout === "number") config.specTimeout = raw.specTimeout;
1936
- if (typeof raw.specWarnTimeout === "number") config.specWarnTimeout = raw.specWarnTimeout;
1937
- if (typeof raw.specKillTimeout === "number") config.specKillTimeout = raw.specKillTimeout;
1938
- if (typeof raw.concurrency === "number") config.concurrency = raw.concurrency;
1939
- if (typeof raw.org === "string") config.org = raw.org;
1940
- if (typeof raw.project === "string") config.project = raw.project;
1941
- if (typeof raw.workItemType === "string") config.workItemType = raw.workItemType;
1942
- if (typeof raw.iteration === "string") config.iteration = raw.iteration;
1943
- if (typeof raw.area === "string") config.area = raw.area;
1944
- if (typeof raw.username === "string") config.username = raw.username;
1945
- if (typeof raw.nextIssueId === "number") config.nextIssueId = raw.nextIssueId;
1946
- return config;
1947
- }
1948
- async function saveConfig(config, configDir) {
1949
- const configPath = getConfigPath(configDir);
1950
- await mkdir2(dirname3(configPath), { recursive: true });
1951
- await writeFile2(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
2555
+ return this.getDefaultBranch(opts);
2556
+ },
2557
+ async getUsername(opts) {
2558
+ if (opts.username) return opts.username;
2559
+ return deriveShortUsername(opts.cwd, "unknown");
2560
+ },
2561
+ buildBranchName(issueNumber, _title, username) {
2562
+ const branch = `${username}/dispatch/issue-${issueNumber}`;
2563
+ if (!isValidBranchName(branch)) {
2564
+ throw new InvalidBranchNameError(branch);
2565
+ }
2566
+ return branch;
2567
+ },
2568
+ async createAndSwitchBranch(branchName, opts) {
2569
+ if (!isValidBranchName(branchName)) {
2570
+ throw new InvalidBranchNameError(branchName);
2571
+ }
2572
+ try {
2573
+ await git2(["checkout", "-b", branchName], opts.cwd);
2574
+ } catch (err) {
2575
+ const message = log.extractMessage(err);
2576
+ if (message.includes("already exists")) {
2577
+ try {
2578
+ await git2(["checkout", branchName], opts.cwd);
2579
+ } catch (checkoutErr) {
2580
+ const checkoutMessage = log.extractMessage(checkoutErr);
2581
+ if (checkoutMessage.includes("already used by worktree")) {
2582
+ await git2(["worktree", "prune"], opts.cwd);
2583
+ await git2(["checkout", branchName], opts.cwd);
2584
+ } else {
2585
+ throw checkoutErr;
2586
+ }
2587
+ }
2588
+ } else {
2589
+ throw err;
2590
+ }
2591
+ }
2592
+ },
2593
+ async switchBranch(branchName, opts) {
2594
+ await git2(["checkout", branchName], opts.cwd);
2595
+ },
2596
+ async pushBranch(branchName, opts) {
2597
+ await git2(["push", "--set-upstream", "origin", branchName], opts.cwd);
2598
+ },
2599
+ async commitAllChanges(message, opts) {
2600
+ await git2(["add", "-A"], opts.cwd);
2601
+ const status = await git2(["diff", "--cached", "--stat"], opts.cwd);
2602
+ if (!status.trim()) {
2603
+ return;
2604
+ }
2605
+ await git2(["commit", "-m", message], opts.cwd);
2606
+ },
2607
+ async createPullRequest(branchName, issueNumber, title, body, opts, baseBranch) {
2608
+ const cwd = opts.cwd;
2609
+ const { orgUrl, project, connection } = await getOrgAndProject(opts);
2610
+ const gitApi = await connection.getGitApi();
2611
+ const remoteUrl = await getGitRemoteUrl(cwd);
2612
+ if (!remoteUrl) {
2613
+ throw new Error("Could not determine git remote URL.");
2614
+ }
2615
+ const repos = await gitApi.getRepositories(project);
2616
+ const normalizeUrl = (u) => u.replace(/\/\/[^@/]+@/, "//").replace(/\.git$/, "").replace(/\/$/, "").toLowerCase();
2617
+ const normalizedRemote = normalizeUrl(remoteUrl);
2618
+ const repo = repos.find(
2619
+ (r) => r.remoteUrl && normalizeUrl(r.remoteUrl) === normalizedRemote || r.sshUrl && normalizeUrl(r.sshUrl) === normalizedRemote || r.webUrl && normalizeUrl(r.webUrl) === normalizedRemote
2620
+ );
2621
+ if (!repo || !repo.id) {
2622
+ throw new Error(`Could not find Azure DevOps repository matching remote URL: ${redactUrl2(remoteUrl)}`);
2623
+ }
2624
+ const target = baseBranch ?? await this.getDefaultBranch(opts);
2625
+ try {
2626
+ const pr = await gitApi.createPullRequest(
2627
+ {
2628
+ sourceRefName: `refs/heads/${branchName}`,
2629
+ targetRefName: `refs/heads/${target}`,
2630
+ title,
2631
+ description: body || `Resolves AB#${issueNumber}`,
2632
+ workItemRefs: [{ id: issueNumber }]
2633
+ },
2634
+ repo.id,
2635
+ project
2636
+ );
2637
+ const webUrl = repo.webUrl ? `${repo.webUrl}/pullrequest/${pr.pullRequestId}` : pr.url ?? "";
2638
+ return webUrl;
2639
+ } catch (err) {
2640
+ const message = log.extractMessage(err);
2641
+ if (message.includes("already exists")) {
2642
+ const prs = await gitApi.getPullRequests(
2643
+ repo.id,
2644
+ {
2645
+ sourceRefName: `refs/heads/${branchName}`,
2646
+ status: PullRequestStatus.Active
2647
+ },
2648
+ project
2649
+ );
2650
+ if (Array.isArray(prs) && prs.length > 0) {
2651
+ const existingPr = prs[0];
2652
+ const webUrl = repo.webUrl ? `${repo.webUrl}/pullrequest/${existingPr.pullRequestId}` : existingPr.url ?? "";
2653
+ return webUrl;
2654
+ }
2655
+ return "";
2656
+ }
2657
+ throw err;
2658
+ }
2659
+ }
2660
+ };
2661
+
2662
+ // src/datasources/md.ts
2663
+ import { execFile as execFile5 } from "child_process";
2664
+ import { readFile as readFile4, writeFile as writeFile2, readdir, mkdir as mkdir2, rename } from "fs/promises";
2665
+ import { basename, dirname as dirname3, isAbsolute, join as join5, parse as parsePath, resolve } from "path";
2666
+ import { promisify as promisify5 } from "util";
2667
+ import { glob } from "glob";
2668
+
2669
+ // src/helpers/slugify.ts
2670
+ var MAX_SLUG_LENGTH = 60;
2671
+ function slugify(input2, maxLength) {
2672
+ const slug = input2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2673
+ return maxLength != null ? slug.slice(0, maxLength) : slug;
1952
2674
  }
1953
2675
 
1954
2676
  // src/datasources/md.ts
@@ -1969,7 +2691,7 @@ async function git3(args, cwd) {
1969
2691
  var DEFAULT_DIR = ".dispatch/specs";
1970
2692
  function resolveDir(opts) {
1971
2693
  const cwd = opts?.cwd ?? process.cwd();
1972
- return join6(cwd, DEFAULT_DIR);
2694
+ return join5(cwd, DEFAULT_DIR);
1973
2695
  }
1974
2696
  function resolveFilePath(issueId, opts) {
1975
2697
  const filename = issueId.endsWith(".md") ? issueId : `${issueId}.md`;
@@ -1978,7 +2700,7 @@ function resolveFilePath(issueId, opts) {
1978
2700
  const cwd = opts?.cwd ?? process.cwd();
1979
2701
  return resolve(cwd, filename);
1980
2702
  }
1981
- return join6(resolveDir(opts), filename);
2703
+ return join5(resolveDir(opts), filename);
1982
2704
  }
1983
2705
  async function resolveNumericFilePath(issueId, opts) {
1984
2706
  if (/^\d+$/.test(issueId)) {
@@ -1986,7 +2708,7 @@ async function resolveNumericFilePath(issueId, opts) {
1986
2708
  const entries = await readdir(dir);
1987
2709
  const match = entries.find((f) => f.startsWith(`${issueId}-`) && f.endsWith(".md"));
1988
2710
  if (match) {
1989
- return join6(dir, match);
2711
+ return join5(dir, match);
1990
2712
  }
1991
2713
  }
1992
2714
  return resolveFilePath(issueId, opts);
@@ -2015,7 +2737,7 @@ function toIssueDetails(filename, content, dir) {
2015
2737
  body: content,
2016
2738
  labels: [],
2017
2739
  state: "open",
2018
- url: join6(dir, filename),
2740
+ url: join5(dir, filename),
2019
2741
  comments: [],
2020
2742
  acceptanceCriteria: ""
2021
2743
  };
@@ -2032,9 +2754,9 @@ var datasource3 = {
2032
2754
  const mdFiles2 = files.filter((f) => f.endsWith(".md")).sort();
2033
2755
  const results2 = [];
2034
2756
  for (const filePath of mdFiles2) {
2035
- const content = await readFile5(filePath, "utf-8");
2757
+ const content = await readFile4(filePath, "utf-8");
2036
2758
  const filename = basename(filePath);
2037
- const dir2 = dirname4(filePath);
2759
+ const dir2 = dirname3(filePath);
2038
2760
  results2.push(toIssueDetails(filename, content, dir2));
2039
2761
  }
2040
2762
  return results2;
@@ -2049,8 +2771,8 @@ var datasource3 = {
2049
2771
  const mdFiles = entries.filter((f) => f.endsWith(".md")).sort();
2050
2772
  const results = [];
2051
2773
  for (const filename of mdFiles) {
2052
- const filePath = join6(dir, filename);
2053
- const content = await readFile5(filePath, "utf-8");
2774
+ const filePath = join5(dir, filename);
2775
+ const content = await readFile4(filePath, "utf-8");
2054
2776
  results.push(toIssueDetails(filename, content, dir));
2055
2777
  }
2056
2778
  return results;
@@ -2061,38 +2783,38 @@ var datasource3 = {
2061
2783
  const entries = await readdir(dir2);
2062
2784
  const match = entries.find((f) => f.startsWith(`${issueId}-`) && f.endsWith(".md"));
2063
2785
  if (match) {
2064
- const content2 = await readFile5(join6(dir2, match), "utf-8");
2786
+ const content2 = await readFile4(join5(dir2, match), "utf-8");
2065
2787
  return toIssueDetails(match, content2, dir2);
2066
2788
  }
2067
2789
  }
2068
2790
  const filePath = resolveFilePath(issueId, opts);
2069
- const content = await readFile5(filePath, "utf-8");
2791
+ const content = await readFile4(filePath, "utf-8");
2070
2792
  const filename = basename(filePath);
2071
- const dir = dirname4(filePath);
2793
+ const dir = dirname3(filePath);
2072
2794
  return toIssueDetails(filename, content, dir);
2073
2795
  },
2074
2796
  async update(issueId, _title, body, opts) {
2075
2797
  const filePath = await resolveNumericFilePath(issueId, opts);
2076
- await writeFile3(filePath, body, "utf-8");
2798
+ await writeFile2(filePath, body, "utf-8");
2077
2799
  },
2078
2800
  async close(issueId, opts) {
2079
2801
  const filePath = await resolveNumericFilePath(issueId, opts);
2080
2802
  const filename = basename(filePath);
2081
- const archiveDir = join6(dirname4(filePath), "archive");
2082
- await mkdir3(archiveDir, { recursive: true });
2083
- await rename(filePath, join6(archiveDir, filename));
2803
+ const archiveDir = join5(dirname3(filePath), "archive");
2804
+ await mkdir2(archiveDir, { recursive: true });
2805
+ await rename(filePath, join5(archiveDir, filename));
2084
2806
  },
2085
2807
  async create(title, body, opts) {
2086
2808
  return withCreateLock(async () => {
2087
2809
  const cwd = opts?.cwd ?? process.cwd();
2088
- const configDir = join6(cwd, ".dispatch");
2810
+ const configDir = join5(cwd, ".dispatch");
2089
2811
  const config = await loadConfig(configDir);
2090
2812
  const id = config.nextIssueId ?? 1;
2091
2813
  const dir = resolveDir(opts);
2092
- await mkdir3(dir, { recursive: true });
2814
+ await mkdir2(dir, { recursive: true });
2093
2815
  const filename = `${id}-${slugify(title)}.md`;
2094
- const filePath = join6(dir, filename);
2095
- await writeFile3(filePath, body, "utf-8");
2816
+ const filePath = join5(dir, filename);
2817
+ await writeFile2(filePath, body, "utf-8");
2096
2818
  config.nextIssueId = id + 1;
2097
2819
  await saveConfig(config, configDir);
2098
2820
  return {
@@ -2322,7 +3044,111 @@ function parseGitHubRemoteUrl(url) {
2322
3044
  return null;
2323
3045
  }
2324
3046
 
3047
+ // src/config-prompts.tsx
3048
+ init_ink_prompts();
3049
+ import chalk3 from "chalk";
3050
+ init_format();
3051
+
3052
+ // src/providers/auth-setup.tsx
3053
+ init_ink_prompts();
3054
+ import { spawn } from "child_process";
3055
+
3056
+ // src/config.ts
3057
+ var CONFIG_BOUNDS = {
3058
+ planTimeout: { min: 1, max: 120 },
3059
+ specTimeout: { min: 1, max: 120 },
3060
+ specWarnTimeout: { min: 1, max: 120 },
3061
+ specKillTimeout: { min: 1, max: 120 },
3062
+ concurrency: { min: 1, max: 64 },
3063
+ maxRuns: { min: 1, max: 32 }
3064
+ };
3065
+ var CONFIG_KEYS = ["enabledProviders", "providerModels", "source", "planTimeout", "specTimeout", "specWarnTimeout", "specKillTimeout", "concurrency", "maxRuns", "org", "project", "workItemType", "iteration", "area", "username", "orchestratorProvider"];
3066
+ function getConfigPath(configDir) {
3067
+ const dir = configDir ?? join6(process.cwd(), ".dispatch");
3068
+ return join6(dir, "config.json");
3069
+ }
3070
+ async function loadConfig(configDir) {
3071
+ const configPath = getConfigPath(configDir);
3072
+ try {
3073
+ const raw = await readFile5(configPath, "utf-8");
3074
+ const parsed = JSON.parse(raw);
3075
+ return migrateConfig(parsed);
3076
+ } catch {
3077
+ return {};
3078
+ }
3079
+ }
3080
+ function migrateConfig(raw) {
3081
+ const config = {};
3082
+ if (!raw.enabledProviders && raw.provider) {
3083
+ const providers = /* @__PURE__ */ new Set();
3084
+ const legacyProvider = raw.provider;
3085
+ if (PROVIDER_NAMES.includes(legacyProvider)) {
3086
+ providers.add(legacyProvider);
3087
+ }
3088
+ if (raw.fastProvider) {
3089
+ const fastProvider = raw.fastProvider;
3090
+ if (PROVIDER_NAMES.includes(fastProvider)) {
3091
+ providers.add(fastProvider);
3092
+ }
3093
+ }
3094
+ if (raw.agents && typeof raw.agents === "object") {
3095
+ for (const agentCfg of Object.values(raw.agents)) {
3096
+ if (agentCfg?.provider && PROVIDER_NAMES.includes(agentCfg.provider)) {
3097
+ providers.add(agentCfg.provider);
3098
+ }
3099
+ }
3100
+ }
3101
+ if (providers.size > 0) {
3102
+ config.enabledProviders = [...providers];
3103
+ }
3104
+ } else if (Array.isArray(raw.enabledProviders)) {
3105
+ config.enabledProviders = raw.enabledProviders.filter(
3106
+ (p) => PROVIDER_NAMES.includes(p)
3107
+ );
3108
+ }
3109
+ if (raw.providerModels !== void 0 && typeof raw.providerModels === "object" && raw.providerModels !== null) {
3110
+ const validated = {};
3111
+ for (const [providerName, modelCfg] of Object.entries(raw.providerModels)) {
3112
+ if (!PROVIDER_NAMES.includes(providerName)) continue;
3113
+ if (typeof modelCfg !== "object" || modelCfg === null) continue;
3114
+ const cfg = modelCfg;
3115
+ const entry = {};
3116
+ if (typeof cfg.strong === "string") entry.strong = cfg.strong;
3117
+ if (typeof cfg.fast === "string") entry.fast = cfg.fast;
3118
+ if (entry.strong !== void 0 || entry.fast !== void 0) {
3119
+ validated[providerName] = entry;
3120
+ }
3121
+ }
3122
+ if (Object.keys(validated).length > 0) {
3123
+ config.providerModels = validated;
3124
+ }
3125
+ }
3126
+ if (typeof raw.source === "string" && DATASOURCE_NAMES.includes(raw.source)) {
3127
+ config.source = raw.source;
3128
+ }
3129
+ if (typeof raw.planTimeout === "number") config.planTimeout = raw.planTimeout;
3130
+ if (typeof raw.specTimeout === "number") config.specTimeout = raw.specTimeout;
3131
+ if (typeof raw.specWarnTimeout === "number") config.specWarnTimeout = raw.specWarnTimeout;
3132
+ if (typeof raw.specKillTimeout === "number") config.specKillTimeout = raw.specKillTimeout;
3133
+ if (typeof raw.concurrency === "number") config.concurrency = raw.concurrency;
3134
+ if (typeof raw.maxRuns === "number") config.maxRuns = raw.maxRuns;
3135
+ if (typeof raw.org === "string") config.org = raw.org;
3136
+ if (typeof raw.project === "string") config.project = raw.project;
3137
+ if (typeof raw.workItemType === "string") config.workItemType = raw.workItemType;
3138
+ if (typeof raw.iteration === "string") config.iteration = raw.iteration;
3139
+ if (typeof raw.area === "string") config.area = raw.area;
3140
+ if (typeof raw.username === "string") config.username = raw.username;
3141
+ if (typeof raw.nextIssueId === "number") config.nextIssueId = raw.nextIssueId;
3142
+ return config;
3143
+ }
3144
+ async function saveConfig(config, configDir) {
3145
+ const configPath = getConfigPath(configDir);
3146
+ await mkdir3(dirname4(configPath), { recursive: true });
3147
+ await writeFile3(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
3148
+ }
3149
+
2325
3150
  // src/spec-generator.ts
3151
+ import { cpus, freemem } from "os";
2326
3152
  var MB_PER_CONCURRENT_TASK = 500;
2327
3153
  var DEFAULT_SPEC_TIMEOUT_MIN = 10;
2328
3154
  var DEFAULT_SPEC_WARN_MIN = 10;
@@ -2437,6 +3263,7 @@ async function resolveSource(issues, issueSource, cwd) {
2437
3263
  }
2438
3264
 
2439
3265
  // src/helpers/confirm-large-batch.tsx
3266
+ init_ink_prompts();
2440
3267
  var LARGE_BATCH_THRESHOLD = 100;
2441
3268
  async function confirmLargeBatch(count, threshold = LARGE_BATCH_THRESHOLD) {
2442
3269
  if (count <= threshold) return true;
@@ -2512,8 +3339,224 @@ async function ensureGitignoreEntry(repoRoot, entry) {
2512
3339
  }
2513
3340
  }
2514
3341
 
3342
+ // src/orchestrator/runner.ts
3343
+ init_database();
3344
+ init_manager();
3345
+
3346
+ // src/queue/fork-run.ts
3347
+ init_manager();
3348
+ import { fork } from "child_process";
3349
+ import { existsSync } from "fs";
3350
+ import { fileURLToPath } from "url";
3351
+ import { dirname as dirname5, join as join9 } from "path";
3352
+ var __filename = fileURLToPath(import.meta.url);
3353
+ var __dirname = dirname5(__filename);
3354
+ var WORKER_PATH = [
3355
+ join9(__dirname, "mcp", "dispatch-worker.js"),
3356
+ // bundled: __dirname = dist/
3357
+ join9(__dirname, "..", "mcp", "dispatch-worker.js"),
3358
+ // unbundled: __dirname = dist/queue/
3359
+ join9(__dirname, "..", "dispatch-worker.js")
3360
+ // legacy unbundled: __dirname = dist/mcp/tools/
3361
+ ].find((p) => existsSync(p)) ?? join9(__dirname, "mcp", "dispatch-worker.js");
3362
+ var HEARTBEAT_INTERVAL_MS = 3e4;
3363
+ function forkDispatchRun(runId, workerMessage, options) {
3364
+ if (!existsSync(WORKER_PATH)) {
3365
+ throw new Error(
3366
+ `Dispatch worker not found at ${WORKER_PATH}. Run 'npm run build' to compile the project before using MCP tools.`
3367
+ );
3368
+ }
3369
+ const worker = fork(WORKER_PATH, [], { stdio: ["pipe", "pipe", "pipe", "ipc"] });
3370
+ worker.send(workerMessage);
3371
+ const heartbeat = setInterval(() => {
3372
+ emitLog(runId, `Run ${runId} still in progress...`);
3373
+ }, HEARTBEAT_INTERVAL_MS);
3374
+ worker.on("message", (msg) => {
3375
+ const msgType = msg["type"];
3376
+ switch (msgType) {
3377
+ case "progress": {
3378
+ const event = msg["event"];
3379
+ const eventType = event["type"];
3380
+ switch (eventType) {
3381
+ case "task_start":
3382
+ createTask({
3383
+ runId,
3384
+ taskId: event["taskId"],
3385
+ taskText: event["taskText"],
3386
+ file: event["file"] ?? event["taskId"].split(":")[0] ?? "",
3387
+ line: event["line"] ?? parseInt(event["taskId"].split(":")[1] ?? "0", 10)
3388
+ });
3389
+ updateTaskStatus(runId, event["taskId"], "running");
3390
+ emitLog(runId, `Task started: ${event["taskText"]}`);
3391
+ break;
3392
+ case "task_done":
3393
+ updateTaskStatus(runId, event["taskId"], "success");
3394
+ emitLog(runId, `Task done: ${event["taskText"]}`);
3395
+ break;
3396
+ case "task_failed":
3397
+ updateTaskStatus(runId, event["taskId"], "failed", { error: event["error"] });
3398
+ emitLog(runId, `Task failed: ${event["taskText"]} \u2014 ${event["error"]}`, "error");
3399
+ break;
3400
+ case "phase_change":
3401
+ emitLog(runId, event["message"] ?? `Phase: ${event["phase"]}`);
3402
+ break;
3403
+ case "log":
3404
+ emitLog(runId, event["message"]);
3405
+ break;
3406
+ }
3407
+ break;
3408
+ }
3409
+ case "spec_progress": {
3410
+ const event = msg["event"];
3411
+ const eventType = event["type"];
3412
+ switch (eventType) {
3413
+ case "item_start":
3414
+ emitLog(runId, `Generating spec for: ${event["itemTitle"] ?? event["itemId"]}`);
3415
+ break;
3416
+ case "item_done":
3417
+ emitLog(runId, `Spec done: ${event["itemTitle"] ?? event["itemId"]}`);
3418
+ break;
3419
+ case "item_failed":
3420
+ emitLog(runId, `Spec failed: ${event["itemTitle"] ?? event["itemId"]} \u2014 ${event["error"]}`, "error");
3421
+ break;
3422
+ case "log":
3423
+ emitLog(runId, event["message"]);
3424
+ break;
3425
+ }
3426
+ break;
3427
+ }
3428
+ case "done": {
3429
+ const result = msg["result"];
3430
+ if (options?.onDone) {
3431
+ options.onDone(result);
3432
+ } else if ("completed" in result) {
3433
+ updateRunCounters(runId, result["total"], result["completed"], result["failed"]);
3434
+ finishRun(runId, result["failed"] > 0 ? "failed" : "completed");
3435
+ emitLog(runId, `Dispatch complete: ${result["completed"]}/${result["total"]} tasks succeeded`);
3436
+ } else if ("generated" in result) {
3437
+ const total = result["total"];
3438
+ const generated = result["generated"];
3439
+ const failed = result["failed"];
3440
+ finishSpecRun(runId, failed > 0 ? "failed" : "completed", { total, generated, failed });
3441
+ emitLog(runId, `Spec complete: ${generated}/${total} specs generated`);
3442
+ }
3443
+ break;
3444
+ }
3445
+ case "error": {
3446
+ const errorMsg = msg["message"];
3447
+ if (options?.runType === "spec_runs") {
3448
+ finishSpecRun(runId, "failed", { total: 0, generated: 0, failed: 0 }, errorMsg);
3449
+ } else {
3450
+ finishRun(runId, "failed", errorMsg);
3451
+ }
3452
+ emitLog(runId, `Run error: ${errorMsg}`, "error");
3453
+ break;
3454
+ }
3455
+ }
3456
+ });
3457
+ worker.on("exit", (code) => {
3458
+ clearInterval(heartbeat);
3459
+ if (code !== 0 && code !== null) {
3460
+ const exitError = `Worker process exited with code ${code}`;
3461
+ if (options?.runType === "spec_runs") {
3462
+ finishSpecRun(runId, "failed", { total: 0, generated: 0, failed: 0 }, exitError);
3463
+ } else {
3464
+ finishRun(runId, "failed", exitError);
3465
+ }
3466
+ emitLog(runId, `Worker process exited unexpectedly (code ${code})`, "error");
3467
+ }
3468
+ options?.onExit?.(code);
3469
+ });
3470
+ return worker;
3471
+ }
3472
+
3473
+ // src/queue/run-queue.ts
3474
+ init_manager();
3475
+ var RunQueue = class {
3476
+ limit;
3477
+ /** In-memory map of runId → context needed to fork (callbacks). */
3478
+ contexts = /* @__PURE__ */ new Map();
3479
+ constructor(limit) {
3480
+ this.limit = Math.max(1, limit);
3481
+ }
3482
+ /**
3483
+ * Enqueue a run for execution. The run must already exist in the DB with
3484
+ * status "queued" and a serialized workerMessage.
3485
+ *
3486
+ * If a slot is available the run is forked immediately; otherwise it waits
3487
+ * in the DB queue until a running child exits.
3488
+ */
3489
+ enqueue(runId, logCallback, options) {
3490
+ this.contexts.set(runId, { logCallback, options });
3491
+ if (logCallback) {
3492
+ addLogCallback(runId, logCallback);
3493
+ }
3494
+ this.drain();
3495
+ }
3496
+ /**
3497
+ * Drain the queue: fork queued runs while slots are available.
3498
+ * Called on enqueue and whenever a child process exits.
3499
+ */
3500
+ drain() {
3501
+ let active = countActiveRuns();
3502
+ while (active < this.limit) {
3503
+ const next = getNextQueued();
3504
+ if (!next) break;
3505
+ const { runId, workerMessage, table } = next;
3506
+ if (table === "runs") {
3507
+ markRunStarted(runId);
3508
+ } else {
3509
+ markSpecRunStarted(runId);
3510
+ }
3511
+ let parsedMessage;
3512
+ try {
3513
+ parsedMessage = JSON.parse(workerMessage);
3514
+ } catch {
3515
+ emitLog(runId, `Failed to parse queued worker message`, "error");
3516
+ continue;
3517
+ }
3518
+ const ctx = this.contexts.get(runId);
3519
+ if (!ctx) {
3520
+ continue;
3521
+ }
3522
+ this.contexts.delete(runId);
3523
+ emitLog(runId, `Run started (${active + 1}/${this.limit} slots used)`);
3524
+ const userOnExit = ctx.options?.onExit;
3525
+ forkDispatchRun(runId, parsedMessage, {
3526
+ ...ctx.options,
3527
+ runType: table,
3528
+ logCallback: ctx.logCallback,
3529
+ onExit: (code) => {
3530
+ userOnExit?.(code);
3531
+ this.drain();
3532
+ }
3533
+ });
3534
+ active++;
3535
+ }
3536
+ }
3537
+ /** Mark all queued runs as failed (for graceful shutdown). */
3538
+ abort() {
3539
+ failAllQueuedRuns();
3540
+ this.contexts.clear();
3541
+ }
3542
+ /** Current concurrency limit. */
3543
+ get maxRuns() {
3544
+ return this.limit;
3545
+ }
3546
+ };
3547
+ var _queue = null;
3548
+ function initRunQueue(limit) {
3549
+ _queue = new RunQueue(limit);
3550
+ }
3551
+ function getRunQueue() {
3552
+ if (!_queue) {
3553
+ throw new Error("RunQueue not initialized. Call initRunQueue(limit) first.");
3554
+ }
3555
+ return _queue;
3556
+ }
3557
+
2515
3558
  // src/orchestrator/cli-config.ts
2516
- import { join as join8 } from "path";
3559
+ import { join as join10 } from "path";
2517
3560
  import { access } from "fs/promises";
2518
3561
  import { constants } from "fs";
2519
3562
  var CONFIG_TO_CLI = {
@@ -2537,11 +3580,12 @@ function setCliField(target, key, value) {
2537
3580
  }
2538
3581
  async function resolveCliConfig(args) {
2539
3582
  const { explicitFlags } = args;
2540
- const configDir = join8(args.cwd, ".dispatch");
3583
+ const configDir = join10(args.cwd, ".dispatch");
2541
3584
  const config = await loadConfig(configDir);
2542
3585
  const merged = { ...args };
2543
3586
  for (const configKey of CONFIG_KEYS) {
2544
3587
  const cliField = CONFIG_TO_CLI[configKey];
3588
+ if (!cliField) continue;
2545
3589
  const configValue = config[configKey];
2546
3590
  if (configValue !== void 0 && !explicitFlags.has(cliField)) {
2547
3591
  setCliField(merged, cliField, configValue);
@@ -2583,9 +3627,9 @@ async function resolveCliConfig(args) {
2583
3627
  }
2584
3628
 
2585
3629
  // src/orchestrator/spec-pipeline.ts
2586
- import { join as join10, resolve as resolve2 } from "path";
3630
+ import { join as join12, resolve as resolve2 } from "path";
2587
3631
  import { mkdir as mkdir4, readFile as readFile8, rename as rename2, unlink as unlink2 } from "fs/promises";
2588
- import { randomUUID as randomUUID3 } from "crypto";
3632
+ import { randomUUID as randomUUID4 } from "crypto";
2589
3633
  import { glob as glob2 } from "glob";
2590
3634
 
2591
3635
  // src/providers/router.ts
@@ -3023,6 +4067,7 @@ function registerCleanup(fn) {
3023
4067
  import chalk4 from "chalk";
3024
4068
 
3025
4069
  // src/tui.tsx
4070
+ init_format();
3026
4071
  import { useState as useState2, useEffect as useEffect2 } from "react";
3027
4072
  import { render as render2, Box as Box2, Text as Text2, Spacer, useInput as useInput2 } from "ink";
3028
4073
  import Spinner from "ink-spinner";
@@ -3806,6 +4851,9 @@ function createTui(options) {
3806
4851
  return { state, update, stop, waitForRecoveryAction };
3807
4852
  }
3808
4853
 
4854
+ // src/orchestrator/spec-pipeline.ts
4855
+ init_format();
4856
+
3809
4857
  // src/helpers/retry.ts
3810
4858
  var DEFAULT_RETRY_COUNT = 3;
3811
4859
  async function withRetry(fn, maxRetries, options) {
@@ -3874,7 +4922,7 @@ async function runWithConcurrency(options) {
3874
4922
  }
3875
4923
 
3876
4924
  // src/orchestrator/datasource-helpers.ts
3877
- import { basename as basename2, join as join9 } from "path";
4925
+ import { basename as basename2, join as join11 } from "path";
3878
4926
  import { mkdtemp, writeFile as writeFile6 } from "fs/promises";
3879
4927
  import { tmpdir } from "os";
3880
4928
  import { execFile as execFile8 } from "child_process";
@@ -3903,14 +4951,14 @@ async function fetchItemsById(issueIds, datasource4, fetchOpts) {
3903
4951
  return items;
3904
4952
  }
3905
4953
  async function writeItemsToTempDir(items) {
3906
- const tempDir = await mkdtemp(join9(tmpdir(), "dispatch-"));
4954
+ const tempDir = await mkdtemp(join11(tmpdir(), "dispatch-"));
3907
4955
  const files = [];
3908
4956
  const issueDetailsByFile = /* @__PURE__ */ new Map();
3909
4957
  for (const item of items) {
3910
4958
  const slug = slugify(item.title, MAX_SLUG_LENGTH);
3911
4959
  const id = item.number.includes("/") || item.number.includes("\\") ? basename2(item.number, ".md") : item.number;
3912
4960
  const filename = `${id}-${slug}.md`;
3913
- const filepath = join9(tempDir, filename);
4961
+ const filepath = join11(tempDir, filename);
3914
4962
  await writeFile6(filepath, item.body, "utf-8");
3915
4963
  files.push(filepath);
3916
4964
  issueDetailsByFile.set(filepath, item);
@@ -4093,7 +5141,7 @@ function buildInlineTextItem(issues, outputDir) {
4093
5141
  const title = text.length > 80 ? text.slice(0, 80).trimEnd() + "\u2026" : text;
4094
5142
  const slug = slugify(text, MAX_SLUG_LENGTH);
4095
5143
  const filename = `${slug}.md`;
4096
- const filepath = join10(outputDir, filename);
5144
+ const filepath = join12(outputDir, filename);
4097
5145
  const details = {
4098
5146
  number: filepath,
4099
5147
  title,
@@ -4155,7 +5203,7 @@ function previewDryRun(validItems, items, isTrackerMode, isInlineText, outputDir
4155
5203
  let filepath;
4156
5204
  if (isTrackerMode) {
4157
5205
  const slug = slugify(details.title, 60);
4158
- filepath = join10(outputDir, `${id}-${slug}.md`);
5206
+ filepath = join12(outputDir, `${id}-${slug}.md`);
4159
5207
  } else {
4160
5208
  filepath = id;
4161
5209
  }
@@ -4222,7 +5270,7 @@ async function generateSpecsBatch(validItems, items, provider, isTrackerMode, is
4222
5270
  if (isTrackerMode) {
4223
5271
  const slug = slugify(details.title, MAX_SLUG_LENGTH);
4224
5272
  const filename = `${id}-${slug}.md`;
4225
- filepath = join10(outputDir, filename);
5273
+ filepath = join12(outputDir, filename);
4226
5274
  } else if (isInlineText) {
4227
5275
  filepath = id;
4228
5276
  } else {
@@ -4233,9 +5281,9 @@ async function generateSpecsBatch(validItems, items, provider, isTrackerMode, is
4233
5281
  fileLoggerStorage.getStore()?.info(`Starting spec generation for ${isTrackerMode ? `#${id}` : filepath}`);
4234
5282
  if (!quiet) log.info(`Generating spec for ${isTrackerMode ? `#${id}` : filepath}: ${details.title}...`);
4235
5283
  const generateLabel = `dispatch(spec, ${isTrackerMode ? `#${id}` : filepath})`;
4236
- const tmpDir = join10(resolve2(specCwd), ".dispatch", "tmp");
5284
+ const tmpDir = join12(resolve2(specCwd), ".dispatch", "tmp");
4237
5285
  await mkdir4(tmpDir, { recursive: true });
4238
- const tmpPath = join10(tmpDir, `spec-${randomUUID3()}.md`);
5286
+ const tmpPath = join12(tmpDir, `spec-${randomUUID4()}.md`);
4239
5287
  const specInput = {
4240
5288
  issue: isTrackerMode ? details : void 0,
4241
5289
  filePath: isTrackerMode ? void 0 : id,
@@ -4270,7 +5318,7 @@ async function generateSpecsBatch(validItems, items, provider, isTrackerMode, is
4270
5318
  const h1Title = extractTitle(result.data.content, filepath);
4271
5319
  const h1Slug = slugify(h1Title, MAX_SLUG_LENGTH);
4272
5320
  const finalFilename = isTrackerMode ? `${id}-${h1Slug}.md` : `${h1Slug}.md`;
4273
- const finalFilepath = join10(outputDir, finalFilename);
5321
+ const finalFilepath = join12(outputDir, finalFilename);
4274
5322
  if (finalFilepath !== filepath) {
4275
5323
  await rename2(filepath, finalFilepath);
4276
5324
  filepath = finalFilepath;
@@ -4418,7 +5466,7 @@ async function runSpecPipeline(opts) {
4418
5466
  providerModels,
4419
5467
  serverUrl,
4420
5468
  cwd: specCwd,
4421
- outputDir = join10(specCwd, ".dispatch", "specs"),
5469
+ outputDir = join12(specCwd, ".dispatch", "specs"),
4422
5470
  org,
4423
5471
  project,
4424
5472
  workItemType,
@@ -4955,11 +6003,11 @@ function parseCommitResponse(response) {
4955
6003
  }
4956
6004
 
4957
6005
  // src/helpers/worktree.ts
4958
- import { join as join11, basename as basename3 } from "path";
6006
+ import { join as join13, basename as basename3 } from "path";
4959
6007
  import { execFile as execFile9 } from "child_process";
4960
6008
  import { promisify as promisify9 } from "util";
4961
- import { randomUUID as randomUUID4 } from "crypto";
4962
- import { existsSync } from "fs";
6009
+ import { randomUUID as randomUUID5 } from "crypto";
6010
+ import { existsSync as existsSync2 } from "fs";
4963
6011
  import { rm } from "fs/promises";
4964
6012
  var exec8 = promisify9(execFile9);
4965
6013
  var WORKTREE_DIR = ".dispatch/worktrees";
@@ -4975,8 +6023,8 @@ function worktreeName(issueFilename) {
4975
6023
  }
4976
6024
  async function createWorktree(repoRoot, issueFilename, branchName, startPoint) {
4977
6025
  const name = worktreeName(issueFilename);
4978
- const worktreePath = join11(repoRoot, WORKTREE_DIR, name);
4979
- if (existsSync(worktreePath)) {
6026
+ const worktreePath = join13(repoRoot, WORKTREE_DIR, name);
6027
+ if (existsSync2(worktreePath)) {
4980
6028
  try {
4981
6029
  const listOutput = await git4(["worktree", "list", "--porcelain"], repoRoot);
4982
6030
  const isRegistered = listOutput.includes(`worktree ${worktreePath}
@@ -5057,7 +6105,7 @@ async function createWorktree(repoRoot, issueFilename, branchName, startPoint) {
5057
6105
  }
5058
6106
  async function removeWorktree(repoRoot, issueFilename) {
5059
6107
  const name = worktreeName(issueFilename);
5060
- const worktreePath = join11(repoRoot, WORKTREE_DIR, name);
6108
+ const worktreePath = join13(repoRoot, WORKTREE_DIR, name);
5061
6109
  try {
5062
6110
  await git4(["worktree", "remove", worktreePath], repoRoot);
5063
6111
  } catch {
@@ -5075,7 +6123,7 @@ async function removeWorktree(repoRoot, issueFilename) {
5075
6123
  }
5076
6124
  }
5077
6125
  function generateFeatureBranchName() {
5078
- const uuid = randomUUID4();
6126
+ const uuid = randomUUID5();
5079
6127
  const octet = uuid.split("-")[0];
5080
6128
  return `dispatch/feature-${octet}`;
5081
6129
  }
@@ -5180,11 +6228,12 @@ var ProviderPool = class {
5180
6228
  };
5181
6229
 
5182
6230
  // src/orchestrator/dispatch-pipeline.ts
6231
+ init_format();
5183
6232
  import chalk5 from "chalk";
5184
6233
 
5185
6234
  // src/helpers/run-state.ts
5186
6235
  import { readFile as readFile10, mkdir as mkdir5 } from "fs/promises";
5187
- import { join as join12, basename as basename4 } from "path";
6236
+ import { join as join14, basename as basename4 } from "path";
5188
6237
  import { z } from "zod";
5189
6238
  var RunStateTaskStatusSchema = z.enum(["pending", "running", "success", "failed"]);
5190
6239
  var RunStateTaskSchema = z.object({
@@ -5766,6 +6815,7 @@ ${err.stack}` : ""}`);
5766
6815
  }
5767
6816
  if (stopAfterIssue) break;
5768
6817
  }
6818
+ let commitSkillResult;
5769
6819
  if (!preserveContext) {
5770
6820
  if (!noBranch && branchName && defaultBranch && details && datasource4.supportsGit()) {
5771
6821
  try {
@@ -5779,7 +6829,6 @@ ${err.stack}` : ""}`);
5779
6829
  }
5780
6830
  }
5781
6831
  fileLogger?.phase("Commit generation");
5782
- let commitSkillResult;
5783
6832
  if (!noBranch && branchName && defaultBranch && details && datasource4.supportsGit()) {
5784
6833
  try {
5785
6834
  const branchDiff = await getBranchDiff(defaultBranch, issueCwd);
@@ -5810,99 +6859,99 @@ ${err.stack}` : ""}`);
5810
6859
  log.warn(`Commit agent error for issue #${details.number}: ${log.formatErrorChain(err)}`);
5811
6860
  }
5812
6861
  }
5813
- fileLogger?.phase("PR lifecycle");
5814
- if (!noBranch && branchName && defaultBranch && details) {
5815
- if (feature && featureBranchName) {
5816
- if (worktreePath) {
5817
- try {
5818
- await removeWorktree(cwd, file);
5819
- } catch (err) {
5820
- log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
5821
- }
5822
- }
6862
+ }
6863
+ fileLogger?.phase("PR lifecycle");
6864
+ if (!noBranch && branchName && defaultBranch && details) {
6865
+ if (feature && featureBranchName && !preserveContext) {
6866
+ if (worktreePath) {
5823
6867
  try {
5824
- await datasource4.switchBranch(featureBranchName, lifecycleOpts);
5825
- await exec9("git", ["merge", branchName, "--no-ff", "-m", `merge: issue #${details.number}`], { cwd, shell: process.platform === "win32" });
5826
- log.debug(`Merged ${branchName} into ${featureBranchName}`);
6868
+ await removeWorktree(cwd, file);
5827
6869
  } catch (err) {
5828
- const mergeError = `Could not merge ${branchName} into feature branch: ${log.formatErrorChain(err)}`;
5829
- log.warn(mergeError);
5830
- try {
5831
- await exec9("git", ["merge", "--abort"], { cwd, shell: process.platform === "win32" });
5832
- } catch {
5833
- }
5834
- for (const task of fileTasks) {
5835
- const tuiTask = tui.state.tasks.find((t) => t.task === task);
5836
- if (tuiTask) {
5837
- tuiTask.status = "failed";
5838
- tuiTask.error = mergeError;
5839
- }
5840
- upsertResult(results, { task, success: false, error: mergeError });
5841
- }
5842
- return { halted: false };
6870
+ log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
5843
6871
  }
6872
+ }
6873
+ try {
6874
+ await datasource4.switchBranch(featureBranchName, lifecycleOpts);
6875
+ await exec9("git", ["merge", branchName, "--no-ff", "-m", `merge: issue #${details.number}`], { cwd, shell: process.platform === "win32" });
6876
+ log.debug(`Merged ${branchName} into ${featureBranchName}`);
6877
+ } catch (err) {
6878
+ const mergeError = `Could not merge ${branchName} into feature branch: ${log.formatErrorChain(err)}`;
6879
+ log.warn(mergeError);
5844
6880
  try {
5845
- await exec9("git", ["branch", "-d", branchName], { cwd, shell: process.platform === "win32" });
5846
- log.debug(`Deleted local branch ${branchName}`);
5847
- } catch (err) {
5848
- log.warn(`Could not delete local branch ${branchName}: ${log.formatErrorChain(err)}`);
6881
+ await exec9("git", ["merge", "--abort"], { cwd, shell: process.platform === "win32" });
6882
+ } catch {
6883
+ }
6884
+ for (const task of fileTasks) {
6885
+ const tuiTask = tui.state.tasks.find((t) => t.task === task);
6886
+ if (tuiTask) {
6887
+ tuiTask.status = "failed";
6888
+ tuiTask.error = mergeError;
6889
+ }
6890
+ upsertResult(results, { task, success: false, error: mergeError });
5849
6891
  }
6892
+ return { halted: false };
6893
+ }
6894
+ try {
6895
+ await exec9("git", ["branch", "-d", branchName], { cwd, shell: process.platform === "win32" });
6896
+ log.debug(`Deleted local branch ${branchName}`);
6897
+ } catch (err) {
6898
+ log.warn(`Could not delete local branch ${branchName}: ${log.formatErrorChain(err)}`);
6899
+ }
6900
+ try {
6901
+ await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
6902
+ } catch (err) {
6903
+ log.warn(`Could not switch back to ${featureDefaultBranch}: ${log.formatErrorChain(err)}`);
6904
+ }
6905
+ } else if (!feature) {
6906
+ if (datasource4.supportsGit()) {
5850
6907
  try {
5851
- await datasource4.switchBranch(featureDefaultBranch, lifecycleOpts);
6908
+ await datasource4.pushBranch(branchName, issueLifecycleOpts);
6909
+ log.debug(`Pushed branch ${branchName}`);
6910
+ fileLogger?.info(`Pushed branch ${branchName}`);
5852
6911
  } catch (err) {
5853
- log.warn(`Could not switch back to ${featureDefaultBranch}: ${log.formatErrorChain(err)}`);
6912
+ log.warn(`Could not push branch ${branchName}: ${log.formatErrorChain(err)}`);
5854
6913
  }
5855
- } else {
5856
- if (datasource4.supportsGit()) {
5857
- try {
5858
- await datasource4.pushBranch(branchName, issueLifecycleOpts);
5859
- log.debug(`Pushed branch ${branchName}`);
5860
- fileLogger?.info(`Pushed branch ${branchName}`);
5861
- } catch (err) {
5862
- log.warn(`Could not push branch ${branchName}: ${log.formatErrorChain(err)}`);
6914
+ }
6915
+ if (datasource4.supportsGit()) {
6916
+ try {
6917
+ const prTitle = commitSkillResult?.success && commitSkillResult.data.prTitle || await buildPrTitle(details.title, defaultBranch, issueLifecycleOpts.cwd);
6918
+ const prBody = commitSkillResult?.success && commitSkillResult.data.prDescription || await buildPrBody(
6919
+ details,
6920
+ fileTasks,
6921
+ issueResults,
6922
+ defaultBranch,
6923
+ datasource4.name,
6924
+ issueLifecycleOpts.cwd
6925
+ );
6926
+ const prUrl = await datasource4.createPullRequest(
6927
+ branchName,
6928
+ details.number,
6929
+ prTitle,
6930
+ prBody,
6931
+ issueLifecycleOpts,
6932
+ startingBranch
6933
+ );
6934
+ if (prUrl) {
6935
+ log.success(`Created PR for issue #${details.number}: ${prUrl}`);
6936
+ fileLogger?.info(`Created PR: ${prUrl}`);
5863
6937
  }
6938
+ } catch (err) {
6939
+ log.warn(`Could not create PR for issue #${details.number}: ${log.formatErrorChain(err)}`);
6940
+ fileLogger?.warn(`PR creation failed: ${log.extractMessage(err)}`);
5864
6941
  }
5865
- if (datasource4.supportsGit()) {
5866
- try {
5867
- const prTitle = commitSkillResult?.success && commitSkillResult.data.prTitle || await buildPrTitle(details.title, defaultBranch, issueLifecycleOpts.cwd);
5868
- const prBody = commitSkillResult?.success && commitSkillResult.data.prDescription || await buildPrBody(
5869
- details,
5870
- fileTasks,
5871
- issueResults,
5872
- defaultBranch,
5873
- datasource4.name,
5874
- issueLifecycleOpts.cwd
5875
- );
5876
- const prUrl = await datasource4.createPullRequest(
5877
- branchName,
5878
- details.number,
5879
- prTitle,
5880
- prBody,
5881
- issueLifecycleOpts,
5882
- startingBranch
5883
- );
5884
- if (prUrl) {
5885
- log.success(`Created PR for issue #${details.number}: ${prUrl}`);
5886
- fileLogger?.info(`Created PR: ${prUrl}`);
5887
- }
5888
- } catch (err) {
5889
- log.warn(`Could not create PR for issue #${details.number}: ${log.formatErrorChain(err)}`);
5890
- fileLogger?.warn(`PR creation failed: ${log.extractMessage(err)}`);
5891
- }
6942
+ }
6943
+ if (useWorktrees && worktreePath && !preserveContext) {
6944
+ try {
6945
+ await removeWorktree(cwd, file);
6946
+ } catch (err) {
6947
+ log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
5892
6948
  }
5893
- if (useWorktrees && worktreePath) {
5894
- try {
5895
- await removeWorktree(cwd, file);
5896
- } catch (err) {
5897
- log.warn(`Could not remove worktree for issue #${details.number}: ${log.formatErrorChain(err)}`);
5898
- }
5899
- } else if (!useWorktrees && datasource4.supportsGit()) {
5900
- try {
5901
- await datasource4.switchBranch(defaultBranch, lifecycleOpts);
5902
- log.debug(`Switched back to ${defaultBranch}`);
5903
- } catch (err) {
5904
- log.warn(`Could not switch back to ${defaultBranch}: ${log.formatErrorChain(err)}`);
5905
- }
6949
+ } else if (!useWorktrees && datasource4.supportsGit()) {
6950
+ try {
6951
+ await datasource4.switchBranch(defaultBranch, lifecycleOpts);
6952
+ log.debug(`Switched back to ${defaultBranch}`);
6953
+ } catch (err) {
6954
+ log.warn(`Could not switch back to ${defaultBranch}: ${log.formatErrorChain(err)}`);
5906
6955
  }
5907
6956
  }
5908
6957
  }
@@ -6095,109 +7144,294 @@ async function boot5(opts) {
6095
7144
  log.error("--feature and --no-branch are mutually exclusive");
6096
7145
  process.exit(1);
6097
7146
  }
6098
- if (m.spec) {
6099
- return this.generateSpecs({
6100
- issues: m.spec,
6101
- issueSource: m.issueSource,
7147
+ if (m.dryRun) {
7148
+ if (m.spec || m.respec) {
7149
+ let issues;
7150
+ if (m.spec) {
7151
+ issues = m.spec;
7152
+ } else {
7153
+ const respecArgs = m.respec;
7154
+ const isEmpty = Array.isArray(respecArgs) && respecArgs.length === 0;
7155
+ if (isEmpty) {
7156
+ const source = await resolveSource(respecArgs, m.issueSource, m.cwd);
7157
+ if (!source) process.exit(1);
7158
+ const datasource4 = getDatasource(source);
7159
+ const existing = await datasource4.list({ cwd: m.cwd, org: m.org, project: m.project, workItemType: m.workItemType, iteration: m.iteration, area: m.area });
7160
+ if (existing.length === 0) {
7161
+ log.error("No existing specs found to regenerate");
7162
+ process.exit(1);
7163
+ }
7164
+ const identifiers = existing.map((item) => item.number);
7165
+ const allNumeric = identifiers.every((id) => /^\d+$/.test(id));
7166
+ issues = allNumeric ? identifiers.join(",") : identifiers;
7167
+ const confirmed = await confirmLargeBatch(existing.length);
7168
+ if (!confirmed) process.exit(0);
7169
+ } else {
7170
+ issues = respecArgs;
7171
+ }
7172
+ }
7173
+ return this.generateSpecs({
7174
+ issues,
7175
+ issueSource: m.issueSource,
7176
+ provider: m.provider,
7177
+ enabledProviders: m.enabledProviders,
7178
+ providerModels: m.providerModels,
7179
+ serverUrl: m.serverUrl,
7180
+ cwd: m.cwd,
7181
+ outputDir: m.outputDir,
7182
+ org: m.org,
7183
+ project: m.project,
7184
+ workItemType: m.workItemType,
7185
+ iteration: m.iteration,
7186
+ area: m.area,
7187
+ concurrency: m.concurrency,
7188
+ dryRun: true,
7189
+ retries: m.retries,
7190
+ specTimeout: m.specTimeout ?? DEFAULT_SPEC_TIMEOUT_MIN,
7191
+ specWarnTimeout: m.specWarnTimeout,
7192
+ specKillTimeout: m.specKillTimeout
7193
+ });
7194
+ }
7195
+ return this.orchestrate({
7196
+ issueIds: m.issueIds,
7197
+ concurrency: m.concurrency ?? defaultConcurrency(),
7198
+ dryRun: true,
7199
+ noPlan: m.noPlan,
7200
+ noBranch: m.noBranch,
7201
+ noWorktree: m.noWorktree,
6102
7202
  provider: m.provider,
6103
7203
  enabledProviders: m.enabledProviders,
6104
7204
  providerModels: m.providerModels,
6105
7205
  serverUrl: m.serverUrl,
6106
- cwd: m.cwd,
6107
- outputDir: m.outputDir,
7206
+ source: m.issueSource,
6108
7207
  org: m.org,
6109
7208
  project: m.project,
6110
7209
  workItemType: m.workItemType,
6111
7210
  iteration: m.iteration,
6112
7211
  area: m.area,
6113
- concurrency: m.concurrency,
6114
- dryRun: m.dryRun,
7212
+ planTimeout: m.planTimeout,
7213
+ planRetries: m.planRetries,
6115
7214
  retries: m.retries,
6116
- specTimeout: m.specTimeout ?? DEFAULT_SPEC_TIMEOUT_MIN,
6117
- specWarnTimeout: m.specWarnTimeout,
6118
- specKillTimeout: m.specKillTimeout
7215
+ force: m.force,
7216
+ feature: m.feature,
7217
+ username: m.username
6119
7218
  });
6120
7219
  }
6121
- if (m.respec) {
6122
- const respecArgs = m.respec;
6123
- const isEmpty = Array.isArray(respecArgs) && respecArgs.length === 0;
7220
+ openDatabase(cwd);
7221
+ const concurrency = m.concurrency ?? defaultConcurrency();
7222
+ const maxRuns = Math.min(Math.max(4, defaultConcurrency() * 2), CONFIG_BOUNDS.maxRuns.max);
7223
+ if (m.resume !== void 0) {
7224
+ return runResumeFlow(m.resume, cwd, maxRuns);
7225
+ }
7226
+ const sessionId = randomUUID6();
7227
+ markOrphanedRunsFailed({ exceptSessionId: sessionId });
7228
+ initRunQueue(maxRuns);
7229
+ if (m.spec || m.respec) {
6124
7230
  let issues;
6125
- if (isEmpty) {
6126
- const source = await resolveSource(respecArgs, m.issueSource, m.cwd);
6127
- if (!source) {
6128
- process.exit(1);
6129
- }
6130
- const datasource4 = getDatasource(source);
6131
- const existing = await datasource4.list({ cwd: m.cwd, org: m.org, project: m.project, workItemType: m.workItemType, iteration: m.iteration, area: m.area });
6132
- if (existing.length === 0) {
6133
- log.error("No existing specs found to regenerate");
6134
- process.exit(1);
6135
- }
6136
- const identifiers = existing.map((item) => item.number);
6137
- const allNumeric = identifiers.every((id) => /^\d+$/.test(id));
6138
- issues = allNumeric ? identifiers.join(",") : identifiers;
6139
- const confirmed = await confirmLargeBatch(existing.length);
6140
- if (!confirmed) {
6141
- process.exit(0);
6142
- }
7231
+ if (m.spec) {
7232
+ issues = m.spec;
6143
7233
  } else {
6144
- issues = respecArgs;
7234
+ const respecArgs = m.respec;
7235
+ const isEmpty = Array.isArray(respecArgs) && respecArgs.length === 0;
7236
+ if (isEmpty) {
7237
+ const source = await resolveSource(respecArgs, m.issueSource, m.cwd);
7238
+ if (!source) process.exit(1);
7239
+ const datasource4 = getDatasource(source);
7240
+ const existing = await datasource4.list({ cwd: m.cwd, org: m.org, project: m.project, workItemType: m.workItemType, iteration: m.iteration, area: m.area });
7241
+ if (existing.length === 0) {
7242
+ log.error("No existing specs found to regenerate");
7243
+ process.exit(1);
7244
+ }
7245
+ const identifiers = existing.map((item) => item.number);
7246
+ const allNumeric = identifiers.every((id) => /^\d+$/.test(id));
7247
+ issues = allNumeric ? identifiers.join(",") : identifiers;
7248
+ const confirmed = await confirmLargeBatch(existing.length);
7249
+ if (!confirmed) process.exit(0);
7250
+ } else {
7251
+ issues = respecArgs;
7252
+ }
6145
7253
  }
6146
- return this.generateSpecs({
7254
+ const workerMessage2 = {
7255
+ type: "spec",
7256
+ cwd,
7257
+ opts: {
7258
+ issues,
7259
+ enabledProviders: m.enabledProviders,
7260
+ providerModels: m.providerModels,
7261
+ issueSource: m.issueSource,
7262
+ org: m.org,
7263
+ project: m.project,
7264
+ workItemType: m.workItemType,
7265
+ iteration: m.iteration,
7266
+ area: m.area,
7267
+ concurrency,
7268
+ specTimeout: m.specTimeout ?? DEFAULT_SPEC_TIMEOUT_MIN,
7269
+ specWarnTimeout: m.specWarnTimeout,
7270
+ specKillTimeout: m.specKillTimeout,
7271
+ dryRun: false,
7272
+ cwd
7273
+ }
7274
+ };
7275
+ const runId2 = createSpecRun({
7276
+ cwd,
6147
7277
  issues,
6148
- issueSource: m.issueSource,
7278
+ sessionId,
7279
+ status: "queued",
7280
+ workerMessage: JSON.stringify(workerMessage2)
7281
+ });
7282
+ return awaitQueuedRuns([runId2], sessionId, cwd, "spec");
7283
+ }
7284
+ const workerMessage = {
7285
+ type: "dispatch",
7286
+ cwd,
7287
+ opts: {
7288
+ issueIds: m.issueIds,
7289
+ dryRun: false,
6149
7290
  provider: m.provider,
6150
7291
  enabledProviders: m.enabledProviders,
6151
7292
  providerModels: m.providerModels,
6152
- serverUrl: m.serverUrl,
6153
- cwd: m.cwd,
6154
- outputDir: m.outputDir,
7293
+ source: m.issueSource,
6155
7294
  org: m.org,
6156
7295
  project: m.project,
6157
7296
  workItemType: m.workItemType,
6158
7297
  iteration: m.iteration,
6159
7298
  area: m.area,
6160
- concurrency: m.concurrency,
6161
- dryRun: m.dryRun,
7299
+ username: m.username,
7300
+ planTimeout: m.planTimeout,
7301
+ concurrency,
7302
+ noPlan: m.noPlan,
7303
+ noBranch: m.noBranch,
7304
+ noWorktree: m.noWorktree,
6162
7305
  retries: m.retries,
6163
- specTimeout: m.specTimeout ?? DEFAULT_SPEC_TIMEOUT_MIN,
6164
- specWarnTimeout: m.specWarnTimeout,
6165
- specKillTimeout: m.specKillTimeout
6166
- });
6167
- }
6168
- return this.orchestrate({
7306
+ feature: m.feature,
7307
+ planRetries: m.planRetries,
7308
+ force: m.force
7309
+ }
7310
+ };
7311
+ const runId = createRun({
7312
+ cwd,
6169
7313
  issueIds: m.issueIds,
6170
- concurrency: m.concurrency ?? defaultConcurrency(),
6171
- dryRun: m.dryRun,
6172
- noPlan: m.noPlan,
6173
- noBranch: m.noBranch,
6174
- noWorktree: m.noWorktree,
6175
- provider: m.provider,
6176
- enabledProviders: m.enabledProviders,
6177
- providerModels: m.providerModels,
6178
- serverUrl: m.serverUrl,
6179
- source: m.issueSource,
6180
- org: m.org,
6181
- project: m.project,
6182
- workItemType: m.workItemType,
6183
- iteration: m.iteration,
6184
- area: m.area,
6185
- planTimeout: m.planTimeout,
6186
- planRetries: m.planRetries,
6187
- retries: m.retries,
6188
- force: m.force,
6189
- feature: m.feature,
6190
- username: m.username
7314
+ sessionId,
7315
+ status: "queued",
7316
+ workerMessage: JSON.stringify(workerMessage)
6191
7317
  });
7318
+ return awaitQueuedRuns([runId], sessionId, cwd, "dispatch");
6192
7319
  }
6193
7320
  };
6194
7321
  return runner;
6195
7322
  }
7323
+ function cliLogCallback(message, level) {
7324
+ if (level === "error") log.error(message);
7325
+ else if (level === "warn") log.warn(message);
7326
+ else log.info(message);
7327
+ }
7328
+ async function awaitQueuedRuns(runIds, sessionId, cwd, mode) {
7329
+ const queue = getRunQueue();
7330
+ for (const runId of runIds) {
7331
+ queue.enqueue(runId, cliLogCallback);
7332
+ }
7333
+ const getStatus = mode === "spec" ? (runId) => getSpecRun(runId)?.status ?? null : (runId) => getRun(runId)?.status ?? null;
7334
+ await Promise.all(runIds.map(
7335
+ (runId) => waitForRunCompletion(runId, 6e5, () => getStatus(runId))
7336
+ ));
7337
+ if (mode === "spec") {
7338
+ let total2 = 0, generated = 0, failed2 = 0;
7339
+ for (const runId of runIds) {
7340
+ const { getSpecRun: getSpecRun2 } = await Promise.resolve().then(() => (init_manager(), manager_exports));
7341
+ const run = getSpecRun2(runId);
7342
+ if (run) {
7343
+ total2 += run.total;
7344
+ generated += run.generated;
7345
+ failed2 += run.failed;
7346
+ }
7347
+ }
7348
+ return { total: total2, generated, failed: failed2 };
7349
+ }
7350
+ let total = 0, completed = 0, failed = 0;
7351
+ const results = [];
7352
+ for (const runId of runIds) {
7353
+ const run = getRun(runId);
7354
+ if (run) {
7355
+ total += run.total;
7356
+ completed += run.completed;
7357
+ failed += run.failed;
7358
+ }
7359
+ const tasks = getTasksForRun(runId);
7360
+ for (const task of tasks) {
7361
+ results.push({
7362
+ task: { file: task.file, line: task.line, text: task.taskText },
7363
+ success: task.status === "success",
7364
+ error: task.error ?? void 0
7365
+ });
7366
+ }
7367
+ }
7368
+ return { total, completed, failed, skipped: 0, results };
7369
+ }
7370
+ async function runResumeFlow(resumeArg, cwd, maxRuns) {
7371
+ const sessions = listResumableSessions(cwd);
7372
+ if (sessions.length === 0) {
7373
+ log.info("No incomplete sessions found to resume.");
7374
+ process.exit(0);
7375
+ }
7376
+ let sessionId;
7377
+ if (typeof resumeArg === "string") {
7378
+ const match = sessions.find((s) => s.sessionId === resumeArg);
7379
+ if (!match) {
7380
+ log.error(`Session ${resumeArg} not found or has no incomplete runs.`);
7381
+ log.info("Available sessions:");
7382
+ for (const s of sessions) {
7383
+ const date = new Date(s.startedAt).toLocaleString();
7384
+ log.info(` ${s.sessionId} (${date}, ${s.incompleteRuns} incomplete)`);
7385
+ }
7386
+ process.exit(1);
7387
+ }
7388
+ sessionId = resumeArg;
7389
+ } else if (sessions.length === 1) {
7390
+ sessionId = sessions[0].sessionId;
7391
+ log.info(`Resuming session ${sessionId} (${sessions[0].incompleteRuns} incomplete run(s))`);
7392
+ } else {
7393
+ const { select: select2 } = await Promise.resolve().then(() => (init_ink_prompts(), ink_prompts_exports));
7394
+ sessionId = await select2({
7395
+ message: "Multiple incomplete sessions found. Which one would you like to resume?",
7396
+ choices: sessions.map((s) => {
7397
+ const date = new Date(s.startedAt).toLocaleString();
7398
+ let issueLabel = "";
7399
+ try {
7400
+ const ids = JSON.parse(s.issueIds);
7401
+ issueLabel = ids.length > 3 ? `issues ${ids.slice(0, 3).join(", ")}...` : `issues ${ids.join(", ")}`;
7402
+ } catch {
7403
+ issueLabel = "unknown issues";
7404
+ }
7405
+ return {
7406
+ name: `${s.sessionId.slice(0, 8)} ${date} ${issueLabel} (${s.incompleteRuns} incomplete)`,
7407
+ value: s.sessionId
7408
+ };
7409
+ })
7410
+ });
7411
+ }
7412
+ markOrphanedRunsFailed({ exceptSessionId: sessionId });
7413
+ initRunQueue(maxRuns);
7414
+ const runIds = requeueSessionRuns(sessionId);
7415
+ if (runIds.length === 0) {
7416
+ log.info("No runs to resume in this session.");
7417
+ process.exit(0);
7418
+ }
7419
+ log.info(`Resuming ${runIds.length} run(s) from session ${sessionId.slice(0, 8)}...`);
7420
+ return awaitQueuedRuns(runIds, sessionId, cwd, "dispatch");
7421
+ }
6196
7422
 
6197
7423
  // src/mcp/dispatch-worker.ts
6198
7424
  process.on("message", (msg) => {
6199
7425
  void handleMessage(msg);
6200
7426
  });
7427
+ function sendAndFlush(msg) {
7428
+ return new Promise((resolve3, reject) => {
7429
+ process.send(msg, (err) => {
7430
+ if (err) reject(err);
7431
+ else resolve3();
7432
+ });
7433
+ });
7434
+ }
6201
7435
  async function handleMessage(msg) {
6202
7436
  try {
6203
7437
  if (msg.type === "dispatch") {
@@ -6208,7 +7442,7 @@ async function handleMessage(msg) {
6208
7442
  process.send({ type: "progress", event });
6209
7443
  }
6210
7444
  });
6211
- process.send({ type: "done", result });
7445
+ await sendAndFlush({ type: "done", result });
6212
7446
  } else if (msg.type === "spec") {
6213
7447
  const result = await runSpecPipeline({
6214
7448
  ...msg.opts,
@@ -6216,10 +7450,11 @@ async function handleMessage(msg) {
6216
7450
  process.send({ type: "spec_progress", event });
6217
7451
  }
6218
7452
  });
6219
- process.send({ type: "done", result });
7453
+ await sendAndFlush({ type: "done", result });
6220
7454
  }
6221
7455
  } catch (err) {
6222
- process.send({ type: "error", message: err instanceof Error ? err.message : String(err) });
7456
+ await sendAndFlush({ type: "error", message: err instanceof Error ? err.message : String(err) }).catch(() => {
7457
+ });
6223
7458
  }
6224
7459
  process.exit(0);
6225
7460
  }