opencode-magi 0.0.0-dev-20260519011027

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/dist/commands.js +18 -0
  4. package/dist/config/load.js +62 -0
  5. package/dist/config/output.js +16 -0
  6. package/dist/config/resolve.js +113 -0
  7. package/dist/config/validate.js +580 -0
  8. package/dist/config/worktree.js +13 -0
  9. package/dist/github/commands.js +398 -0
  10. package/dist/github/retry.js +44 -0
  11. package/dist/index.js +540 -0
  12. package/dist/orchestrator/abort.js +9 -0
  13. package/dist/orchestrator/ci.js +568 -0
  14. package/dist/orchestrator/findings.js +66 -0
  15. package/dist/orchestrator/majority.js +48 -0
  16. package/dist/orchestrator/merge.js +836 -0
  17. package/dist/orchestrator/model.js +202 -0
  18. package/dist/orchestrator/pool.js +15 -0
  19. package/dist/orchestrator/report.js +168 -0
  20. package/dist/orchestrator/review.js +791 -0
  21. package/dist/orchestrator/run-manager.js +1670 -0
  22. package/dist/orchestrator/safety.js +44 -0
  23. package/dist/permissions/common.json +24 -0
  24. package/dist/permissions/editor.json +7 -0
  25. package/dist/prompts/compose.js +298 -0
  26. package/dist/prompts/contracts.js +189 -0
  27. package/dist/prompts/output.js +260 -0
  28. package/dist/prompts/templates/ci-classification-after-edit.md +16 -0
  29. package/dist/prompts/templates/ci-classification.md +9 -0
  30. package/dist/prompts/templates/close-reconsideration.md +6 -0
  31. package/dist/prompts/templates/edit.md +9 -0
  32. package/dist/prompts/templates/finding-validation.md +7 -0
  33. package/dist/prompts/templates/rereview-close-reconsideration.md +6 -0
  34. package/dist/prompts/templates/rereview.md +16 -0
  35. package/dist/prompts/templates/review.md +7 -0
  36. package/dist/types.js +1 -0
  37. package/package.json +67 -0
  38. package/schema.json +206 -0
@@ -0,0 +1,1670 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rm, rmdir, writeFile, } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { outputBaseDirs, prRunOutputDir } from "../config/output";
5
+ import { worktreeBaseDirs } from "../config/worktree";
6
+ import { removeBranch, removeWorktree, } from "../github/commands";
7
+ import { withGitHubApiRetry } from "../github/retry";
8
+ import { runMerge, } from "./merge";
9
+ import { runReview } from "./review";
10
+ const EVENT_LAST_UPDATE_THROTTLE_MS = 5_000;
11
+ const DEFAULT_CLEAR_OPTIONS = {
12
+ branch: true,
13
+ output: true,
14
+ session: true,
15
+ worktree: true,
16
+ };
17
+ function createRunId() {
18
+ return `run-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
19
+ }
20
+ function now() {
21
+ return new Date().toISOString();
22
+ }
23
+ function isActiveStatus(status) {
24
+ return (status === "blocked" ||
25
+ status === "preparing" ||
26
+ status === "running" ||
27
+ status === "posting");
28
+ }
29
+ function isWithinDirectory(directory, path) {
30
+ const relation = relative(directory, path);
31
+ return (relation === "" || (!relation.startsWith("..") && !isAbsolute(relation)));
32
+ }
33
+ async function pruneEmptyDirectories(input) {
34
+ const boundary = resolve(input.boundary);
35
+ const start = resolve(input.start);
36
+ if (!isWithinDirectory(boundary, start) || start === boundary)
37
+ return;
38
+ async function pruneChildren(dir) {
39
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
40
+ for (const entry of entries) {
41
+ if (!entry.isDirectory())
42
+ continue;
43
+ const path = join(dir, entry.name);
44
+ await pruneChildren(path);
45
+ await rmdir(path).catch(() => undefined);
46
+ }
47
+ }
48
+ if (input.recursive)
49
+ await pruneChildren(start);
50
+ let current = start;
51
+ while (current !== boundary && isWithinDirectory(boundary, current)) {
52
+ try {
53
+ await rmdir(current);
54
+ }
55
+ catch (error) {
56
+ const code = error.code;
57
+ if (code !== "ENOENT")
58
+ break;
59
+ }
60
+ current = dirname(current);
61
+ }
62
+ }
63
+ function reviewerArtifactBase(progressType, reviewer) {
64
+ return `${reviewer}.${progressType}`;
65
+ }
66
+ function prUrl(repository, pr) {
67
+ const host = repository.github.host || "github.com";
68
+ return `https://${host}/${repository.github.owner}/${repository.github.repo}/pull/${pr}`;
69
+ }
70
+ function prMarkdownLink(state) {
71
+ if (state.pr == null)
72
+ return state.runId;
73
+ return state.prUrl ? `[#${state.pr}](${state.prUrl})` : `#${state.pr}`;
74
+ }
75
+ function runLabel(state) {
76
+ return state.pr == null ? state.runId : prMarkdownLink(state);
77
+ }
78
+ function reviewerCompletionText(input) {
79
+ const reviewer = `**Reviewer ${input.reviewer}**`;
80
+ if (input.verdict === "MERGE") {
81
+ return `${reviewer} approved ${input.pr}.`;
82
+ }
83
+ if (input.verdict === "CHANGES_REQUESTED") {
84
+ return `${reviewer} requested changes on ${input.pr}.`;
85
+ }
86
+ if (input.verdict === "CLOSE") {
87
+ return `${reviewer} requested closing ${input.pr}.`;
88
+ }
89
+ return `${reviewer} finished reviewing ${input.pr}.`;
90
+ }
91
+ function reviewDecisionText(input) {
92
+ if (input.verdict === "MERGE")
93
+ return `Reviewers approved ${input.pr}.`;
94
+ if (input.verdict === "CHANGES_REQUESTED") {
95
+ return `Reviewers requested changes on ${input.pr}.`;
96
+ }
97
+ if (input.verdict === "CLOSE") {
98
+ return `Reviewers requested closing ${input.pr}.`;
99
+ }
100
+ return `Reviewers finished reviewing ${input.pr}.`;
101
+ }
102
+ function ciReportText(input) {
103
+ const failed = input.report.failed.length;
104
+ const rerun = input.report.rerun.length;
105
+ const recovered = input.report.scopeOutsideRecovered.length;
106
+ const unresolved = input.report.scopeOutsideUnresolved.length;
107
+ const scopeInside = input.report.scopeInside.length;
108
+ return `CI report for ${input.pr}: ${failed} failed, ${scopeInside} scope-in, ${rerun} rerun, ${recovered} recovered, ${unresolved} unresolved.`;
109
+ }
110
+ function closeReconsiderationText(input) {
111
+ if (input.to === "MERGE") {
112
+ return `**Reviewer ${input.reviewer}** changed their close request to approval for ${input.pr}.`;
113
+ }
114
+ if (input.to === "CHANGES_REQUESTED") {
115
+ return `**Reviewer ${input.reviewer}** changed their close request to changes requested for ${input.pr}.`;
116
+ }
117
+ return `**Reviewer ${input.reviewer}** reconsidered closing ${input.pr}.`;
118
+ }
119
+ function findingsValidationText(input) {
120
+ return `Validated review findings by majority for ${input.pr}: ${input.kept} kept, ${input.discarded} discarded.`;
121
+ }
122
+ function reviewerFailureText(input) {
123
+ const repairs = repairAttemptsText(input.repairAttempts);
124
+ return `**Reviewer ${input.reviewer}** failed reviewing ${input.pr}${repairs}: ${input.error}`;
125
+ }
126
+ function editorFailureText(input) {
127
+ const repairs = repairAttemptsText(input.repairAttempts);
128
+ return `**Editor** failed editing ${input.pr}${repairs}: ${input.error}`;
129
+ }
130
+ function repairAttemptsText(attempts) {
131
+ if (!attempts)
132
+ return "";
133
+ return ` after ${attempts} JSON regeneration attempt${attempts === 1 ? "" : "s"}`;
134
+ }
135
+ function mergePhaseText(input) {
136
+ if (input.phase === "fetching PR metadata") {
137
+ return `Fetching PR metadata for ${input.pr}.`;
138
+ }
139
+ if (input.phase === "fetching existing reviews") {
140
+ return `Fetching existing reviews for ${input.pr}.`;
141
+ }
142
+ if (input.phase === "waiting for checks") {
143
+ return `Waiting for checks for ${input.pr}.`;
144
+ }
145
+ if (input.phase === "posting reviews") {
146
+ return `Posting review results to GitHub for ${input.pr}.`;
147
+ }
148
+ if (input.phase === "waiting for CI checks") {
149
+ return `Waiting for CI checks for ${input.pr}.`;
150
+ }
151
+ if (input.phase === "CI checks passed") {
152
+ return `CI checks passed for ${input.pr}.`;
153
+ }
154
+ if (input.phase === "investigating failed CI checks") {
155
+ return `Investigating failed CI checks for ${input.pr}.`;
156
+ }
157
+ if (input.phase === "fetching failed CI logs") {
158
+ return `Fetching failed CI logs for ${input.pr}.`;
159
+ }
160
+ if (input.phase === "classifying CI failures") {
161
+ return `Classifying CI failures for ${input.pr}.`;
162
+ }
163
+ if (input.phase === "CI failures classified as scope-in") {
164
+ return `CI failures were classified as scope-in for ${input.pr}.`;
165
+ }
166
+ if (input.phase === "scope-out CI failures remain unresolved") {
167
+ return `Scope-out CI failures remain unresolved for ${input.pr}.`;
168
+ }
169
+ if (input.phase === "rerunning scope-out CI jobs") {
170
+ return `Rerunning scope-out CI jobs for ${input.pr}.`;
171
+ }
172
+ if (input.phase === "waiting for rerun CI checks") {
173
+ return `Waiting for rerun CI checks for ${input.pr}.`;
174
+ }
175
+ if (input.phase === "rerun CI checks passed") {
176
+ return `Rerun CI checks passed for ${input.pr}.`;
177
+ }
178
+ if (input.phase === "merging PR")
179
+ return `Merging ${input.pr}.`;
180
+ if (input.phase === "closing PR")
181
+ return `Closing ${input.pr}.`;
182
+ if (input.phase === "creating worktree") {
183
+ return `Creating worktree for ${input.pr}.`;
184
+ }
185
+ if (input.phase === "validating review findings") {
186
+ return `Validating review findings for ${input.pr}.`;
187
+ }
188
+ if (input.phase === "reconsidering close verdicts") {
189
+ return `Reconsidering close verdicts for ${input.pr}.`;
190
+ }
191
+ if (input.phase.startsWith("editing cycle")) {
192
+ return `**Editor** started editing ${input.pr}.`;
193
+ }
194
+ if (input.phase.startsWith("waiting for checks after edit")) {
195
+ return `Waiting for checks after editing ${input.pr}.`;
196
+ }
197
+ if (input.phase.startsWith("rereview cycle")) {
198
+ return `Started re-reviewing ${input.pr}.`;
199
+ }
200
+ return `Magi phase for ${input.pr}: ${input.phase}.`;
201
+ }
202
+ function threadLimitText(input) {
203
+ const links = input.threads
204
+ .map((thread) => `[${thread.label}](${thread.url})`)
205
+ .join(", ");
206
+ if (input.threads.length === 1) {
207
+ return `Review thread ${links} reached the resolution attempt limit for ${input.pr}.`;
208
+ }
209
+ return `Review threads ${links} reached the resolution attempt limit for ${input.pr}.`;
210
+ }
211
+ function extractSessionId(properties) {
212
+ if (!properties)
213
+ return undefined;
214
+ const direct = properties.sessionID ?? properties.sessionId;
215
+ if (typeof direct === "string")
216
+ return direct;
217
+ const info = properties.info;
218
+ if (info && typeof info === "object") {
219
+ const value = info
220
+ .sessionID ??
221
+ info
222
+ .sessionId ??
223
+ info.id;
224
+ if (typeof value === "string")
225
+ return value;
226
+ }
227
+ const part = properties.part;
228
+ if (part && typeof part === "object") {
229
+ const value = part.sessionID ??
230
+ part.sessionId;
231
+ if (typeof value === "string")
232
+ return value;
233
+ }
234
+ return undefined;
235
+ }
236
+ function extractToolPart(properties) {
237
+ if (!properties)
238
+ return undefined;
239
+ const part = properties.part && typeof properties.part === "object"
240
+ ? properties.part
241
+ : properties;
242
+ const type = typeof part.type === "string" ? part.type : undefined;
243
+ const tool = typeof part.tool === "string" ? part.tool : undefined;
244
+ const state = part.state && typeof part.state === "object"
245
+ ? part.state
246
+ : undefined;
247
+ if (!tool && type !== "tool")
248
+ return undefined;
249
+ return {
250
+ callId: typeof part.callID === "string" ? part.callID : undefined,
251
+ id: typeof part.id === "string" ? part.id : undefined,
252
+ input: state?.input && typeof state.input === "object"
253
+ ? state.input
254
+ : undefined,
255
+ status: typeof state?.status === "string" ? state.status : undefined,
256
+ tool,
257
+ };
258
+ }
259
+ function extractQuestions(input) {
260
+ return Array.isArray(input?.questions) ? input.questions : undefined;
261
+ }
262
+ function formatQuestionRequest(question) {
263
+ if (!question?.questions?.length)
264
+ return undefined;
265
+ return question.questions
266
+ .map((item, index) => {
267
+ if (!item || typeof item !== "object")
268
+ return `${index + 1}. ${String(item)}`;
269
+ const record = item;
270
+ const header = typeof record.header === "string" ? record.header : undefined;
271
+ const text = typeof record.question === "string" ? record.question : undefined;
272
+ const options = Array.isArray(record.options)
273
+ ? record.options
274
+ .map((option) => {
275
+ if (!option || typeof option !== "object")
276
+ return undefined;
277
+ const label = option.label;
278
+ return typeof label === "string" ? label : undefined;
279
+ })
280
+ .filter((value) => Boolean(value))
281
+ : [];
282
+ const suffix = options.length ? ` Options: ${options.join(", ")}.` : "";
283
+ return `${index + 1}. ${header ? `${header}: ` : ""}${text ?? JSON.stringify(item)}${suffix}`;
284
+ })
285
+ .join("\n");
286
+ }
287
+ function questionWaitText(input) {
288
+ const details = formatQuestionRequest(input.question);
289
+ const request = input.question?.id ? ` Request: ${input.question.id}.` : "";
290
+ return [
291
+ `Magi ${input.agent} is waiting for a question answer on ${input.pr}.${request}`,
292
+ details
293
+ ? `Question:\n${details}`
294
+ : "Question details were not included in the event.",
295
+ ].join("\n");
296
+ }
297
+ function extractPermissionRequest(properties) {
298
+ if (!properties)
299
+ return undefined;
300
+ const sessionId = extractSessionId(properties);
301
+ const id = typeof properties.id === "string"
302
+ ? properties.id
303
+ : typeof properties.requestID === "string"
304
+ ? properties.requestID
305
+ : typeof properties.permissionID === "string"
306
+ ? properties.permissionID
307
+ : undefined;
308
+ const permission = typeof properties.permission === "string"
309
+ ? properties.permission
310
+ : typeof properties.type === "string"
311
+ ? properties.type
312
+ : undefined;
313
+ const patterns = Array.isArray(properties.patterns)
314
+ ? properties.patterns.filter((item) => typeof item === "string")
315
+ : undefined;
316
+ const tool = typeof properties.tool === "string"
317
+ ? properties.tool
318
+ : properties.tool && typeof properties.tool === "object"
319
+ ? typeof properties.tool.name === "string"
320
+ ? properties.tool.name
321
+ : undefined
322
+ : undefined;
323
+ if (!sessionId && !id && !permission && !tool)
324
+ return undefined;
325
+ return { id, patterns, permission, sessionId, tool };
326
+ }
327
+ function extractQuestionRequest(properties) {
328
+ if (!properties)
329
+ return undefined;
330
+ const sessionId = extractSessionId(properties);
331
+ const id = typeof properties.id === "string"
332
+ ? properties.id
333
+ : typeof properties.requestID === "string"
334
+ ? properties.requestID
335
+ : typeof properties.questionID === "string"
336
+ ? properties.questionID
337
+ : undefined;
338
+ const questions = Array.isArray(properties.questions)
339
+ ? properties.questions
340
+ : undefined;
341
+ const tool = typeof properties.tool === "string"
342
+ ? properties.tool
343
+ : properties.tool && typeof properties.tool === "object"
344
+ ? typeof properties.tool.name === "string"
345
+ ? properties.tool.name
346
+ : undefined
347
+ : undefined;
348
+ if (!sessionId && !id && !questions && !tool)
349
+ return undefined;
350
+ return { id, questions, sessionId, tool };
351
+ }
352
+ export class MagiRunManager {
353
+ input;
354
+ active = new Map();
355
+ countedToolParts = new Map();
356
+ controllers = new Map();
357
+ eventLastUpdates = new Map();
358
+ notifiedPermissions = new Map();
359
+ runPaths = new Map();
360
+ outputDirs = new Set();
361
+ sessionToRun = new Map();
362
+ constructor(input) {
363
+ this.input = input;
364
+ }
365
+ async startReview(input) {
366
+ const runId = createRunId();
367
+ const outputDir = prRunOutputDir({
368
+ config: input.config,
369
+ directory: this.input.directory,
370
+ pr: input.pr,
371
+ runId,
372
+ });
373
+ const createdAt = now();
374
+ const state = {
375
+ command: "review",
376
+ createdAt,
377
+ dryRun: input.dryRun,
378
+ outputDir,
379
+ parentSessionId: input.parentSessionId,
380
+ phase: "queued",
381
+ pr: input.pr,
382
+ prUrl: prUrl(input.repository, input.pr),
383
+ repository: input.repository.alias,
384
+ reviewers: Object.fromEntries(input.repository.agents.reviewers.map((reviewer) => [
385
+ reviewer.key,
386
+ {
387
+ account: reviewer.account,
388
+ repairAttempts: 0,
389
+ status: "pending",
390
+ toolCalls: 0,
391
+ },
392
+ ])),
393
+ runId,
394
+ status: "preparing",
395
+ updatedAt: createdAt,
396
+ };
397
+ this.active.set(runId, state);
398
+ this.runPaths.set(runId, join(outputDir, "state.json"));
399
+ for (const dir of outputBaseDirs(this.input.directory, input.config))
400
+ this.outputDirs.add(dir);
401
+ await this.persist(state);
402
+ await this.notify(state, `Started Magi review for ${prMarkdownLink(state)}.`);
403
+ const controller = new AbortController();
404
+ this.controllers.set(runId, controller);
405
+ void this.executeReview({
406
+ ...input,
407
+ runId,
408
+ signal: controller.signal,
409
+ }).catch(async (error) => {
410
+ await this.failRun(runId, error);
411
+ });
412
+ return state;
413
+ }
414
+ async startMerge(input) {
415
+ const runId = createRunId();
416
+ const outputDir = prRunOutputDir({
417
+ config: input.config,
418
+ directory: this.input.directory,
419
+ pr: input.pr,
420
+ runId,
421
+ });
422
+ const createdAt = now();
423
+ const editor = input.repository.agents.editor;
424
+ const state = {
425
+ command: "merge",
426
+ createdAt,
427
+ dryRun: input.dryRun,
428
+ editor: editor
429
+ ? {
430
+ account: editor.account,
431
+ repairAttempts: 0,
432
+ status: "pending",
433
+ toolCalls: 0,
434
+ }
435
+ : undefined,
436
+ outputDir,
437
+ parentSessionId: input.parentSessionId,
438
+ phase: "queued",
439
+ pr: input.pr,
440
+ prUrl: prUrl(input.repository, input.pr),
441
+ repository: input.repository.alias,
442
+ reviewers: Object.fromEntries(input.repository.agents.reviewers.map((reviewer) => [
443
+ reviewer.key,
444
+ {
445
+ account: reviewer.account,
446
+ repairAttempts: 0,
447
+ status: "pending",
448
+ toolCalls: 0,
449
+ },
450
+ ])),
451
+ runId,
452
+ status: "preparing",
453
+ updatedAt: createdAt,
454
+ };
455
+ this.active.set(runId, state);
456
+ this.runPaths.set(runId, join(outputDir, "state.json"));
457
+ for (const dir of outputBaseDirs(this.input.directory, input.config))
458
+ this.outputDirs.add(dir);
459
+ await this.persist(state);
460
+ await this.notify(state, `Started Magi merge for ${prMarkdownLink(state)}.`);
461
+ const controller = new AbortController();
462
+ this.controllers.set(runId, controller);
463
+ void this.executeMerge({
464
+ ...input,
465
+ runId,
466
+ signal: controller.signal,
467
+ }).catch(async (error) => {
468
+ await this.failRun(runId, error);
469
+ });
470
+ return state;
471
+ }
472
+ async status(input = {}) {
473
+ const timeoutMs = Math.min(input.timeoutMs ?? 60_000, 600_000);
474
+ const startedAt = Date.now();
475
+ while (input.block) {
476
+ const states = await this.filteredStates(input);
477
+ if (states.length &&
478
+ states.every((state) => !isActiveStatus(state.status)))
479
+ return states;
480
+ if (Date.now() - startedAt >= timeoutMs)
481
+ return states;
482
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
483
+ }
484
+ return this.filteredStates(input);
485
+ }
486
+ hasSession(sessionId) {
487
+ return this.sessionToRun.has(sessionId);
488
+ }
489
+ async output(input) {
490
+ const state = await this.selectState(input);
491
+ if (!state)
492
+ return `Magi run not found: ${this.selectorText(input)}`;
493
+ if (input.reviewer) {
494
+ const reviewer = this.agentState(state, input.reviewer);
495
+ if (!reviewer)
496
+ return `Agent not found in ${this.selectorText(input)}: ${input.reviewer}`;
497
+ const sections = [
498
+ `# ${input.reviewer}`,
499
+ `status: ${reviewer.status}`,
500
+ reviewer.sessionId ? `session: ${reviewer.sessionId}` : undefined,
501
+ reviewer.verdict ? `verdict: ${reviewer.verdict}` : undefined,
502
+ reviewer.error ? `error: ${reviewer.error}` : undefined,
503
+ ].filter(Boolean);
504
+ if (reviewer.parsedPath) {
505
+ sections.push("\n## Parsed");
506
+ sections.push(await readFile(reviewer.parsedPath, "utf8").catch(() => "(missing parsed artifact)"));
507
+ }
508
+ if (reviewer.rawPath) {
509
+ sections.push("\n## Raw");
510
+ sections.push(await readFile(reviewer.rawPath, "utf8").catch(() => "(missing raw artifact)"));
511
+ }
512
+ return sections.join("\n");
513
+ }
514
+ const sections = [this.formatStates([state], { verbose: true })];
515
+ if (state.reportPath) {
516
+ sections.push("\n## Report");
517
+ sections.push(await readFile(state.reportPath, "utf8").catch(() => "(missing report artifact)"));
518
+ }
519
+ const output = sections.join("\n");
520
+ return output;
521
+ }
522
+ async cancel(input) {
523
+ const selector = typeof input === "string" ? { runId: input } : input;
524
+ const state = await this.selectState(selector);
525
+ if (!state)
526
+ return undefined;
527
+ const runId = state.runId;
528
+ this.controllers.get(runId)?.abort();
529
+ state.status = "cancelled";
530
+ state.phase = "cancelled";
531
+ state.completedAt = now();
532
+ if (state.editor?.status === "pending" ||
533
+ state.editor?.status === "running" ||
534
+ state.editor?.status === "repairing") {
535
+ state.editor.status = "cancelled";
536
+ }
537
+ if (state.editor?.sessionId) {
538
+ await this.input.client.session
539
+ .abort?.({ path: { id: state.editor.sessionId } })
540
+ .catch(() => undefined);
541
+ }
542
+ for (const reviewer of Object.values(state.reviewers)) {
543
+ if (reviewer.status === "pending" ||
544
+ reviewer.status === "running" ||
545
+ reviewer.status === "repairing" ||
546
+ reviewer.status === "blocked") {
547
+ reviewer.status = "cancelled";
548
+ }
549
+ if (reviewer.sessionId) {
550
+ await this.input.client.session
551
+ .abort?.({ path: { id: reviewer.sessionId } })
552
+ .catch(() => undefined);
553
+ }
554
+ }
555
+ for (const classifier of Object.values(state.ciClassifiers ?? {})) {
556
+ if (classifier.status === "pending" ||
557
+ classifier.status === "running" ||
558
+ classifier.status === "repairing" ||
559
+ classifier.status === "blocked") {
560
+ classifier.status = "cancelled";
561
+ }
562
+ if (classifier.sessionId) {
563
+ await this.input.client.session
564
+ .abort?.({ path: { id: classifier.sessionId } })
565
+ .catch(() => undefined);
566
+ }
567
+ }
568
+ if (state.worktreePath) {
569
+ await removeWorktree(this.input.exec, state.worktreePath).catch(() => undefined);
570
+ }
571
+ await this.persist(state);
572
+ await this.notify(state, `Cancelled ${state.command} for ${runLabel(state)}.`, { reply: true });
573
+ this.active.delete(runId);
574
+ this.controllers.delete(runId);
575
+ return state;
576
+ }
577
+ async clear(input) {
578
+ const configured = input.options ?? {};
579
+ const options = {
580
+ branch: configured.branch ?? DEFAULT_CLEAR_OPTIONS.branch,
581
+ output: configured.output ?? DEFAULT_CLEAR_OPTIONS.output,
582
+ session: configured.session ?? DEFAULT_CLEAR_OPTIONS.session,
583
+ worktree: configured.worktree ?? DEFAULT_CLEAR_OPTIONS.worktree,
584
+ };
585
+ const states = await this.filteredStates(input);
586
+ const cleanupDirs = new Set(this.absoluteWorktreeDirs(input));
587
+ const cleanupTrees = new Set(this.emptyOutputCleanupRoots(input));
588
+ const summary = {
589
+ branchDeleted: 0,
590
+ branchFailed: 0,
591
+ branchSkipped: 0,
592
+ outputDeleted: 0,
593
+ outputFailed: 0,
594
+ runsCleared: 0,
595
+ runsSkippedActive: 0,
596
+ sessionDeleted: 0,
597
+ sessionFailed: 0,
598
+ worktreeDeleted: 0,
599
+ worktreeFailed: 0,
600
+ };
601
+ const lines = [];
602
+ if (!states.length) {
603
+ await this.pruneEmptyMagiDirectories({
604
+ dirs: cleanupDirs,
605
+ trees: cleanupTrees,
606
+ });
607
+ return `No Magi runs found: ${this.selectorText(input)}`;
608
+ }
609
+ for (const state of states) {
610
+ if (isActiveStatus(state.status)) {
611
+ summary.runsSkippedActive += 1;
612
+ lines.push(`Skipped active run ${state.runId} for ${runLabel(state)}: ${state.status}`);
613
+ continue;
614
+ }
615
+ if (options.session) {
616
+ if (!this.input.client.session.delete) {
617
+ summary.sessionFailed += this.collectSessionIds(state).length;
618
+ lines.push("OpenCode client does not support session deletion.");
619
+ }
620
+ else {
621
+ for (const sessionId of this.collectSessionIds(state)) {
622
+ try {
623
+ await this.input.client.session.delete({
624
+ path: { id: sessionId },
625
+ });
626
+ summary.sessionDeleted += 1;
627
+ }
628
+ catch (error) {
629
+ summary.sessionFailed += 1;
630
+ lines.push(`Failed to delete session ${sessionId}: ${error.message}`);
631
+ }
632
+ }
633
+ }
634
+ }
635
+ if (options.worktree && state.worktreePath) {
636
+ let removed = false;
637
+ let unregisterError;
638
+ try {
639
+ await removeWorktree(this.input.exec, state.worktreePath);
640
+ removed = true;
641
+ }
642
+ catch (error) {
643
+ unregisterError = error;
644
+ }
645
+ try {
646
+ await rm(state.worktreePath, { force: true, recursive: true });
647
+ removed = true;
648
+ }
649
+ catch (error) {
650
+ summary.worktreeFailed += 1;
651
+ if (unregisterError) {
652
+ lines.push(`Failed to unregister worktree ${state.worktreePath}: ${unregisterError.message}`);
653
+ }
654
+ lines.push(`Failed to delete worktree directory ${state.worktreePath}: ${error.message}`);
655
+ }
656
+ if (removed)
657
+ summary.worktreeDeleted += 1;
658
+ cleanupDirs.add(state.worktreePath);
659
+ }
660
+ if (options.branch) {
661
+ if (state.worktreeBranch) {
662
+ try {
663
+ await removeBranch(this.input.exec, state.worktreeBranch);
664
+ summary.branchDeleted += 1;
665
+ }
666
+ catch (error) {
667
+ summary.branchFailed += 1;
668
+ lines.push(`Failed to delete branch ${state.worktreeBranch}: ${error.message}`);
669
+ }
670
+ }
671
+ else {
672
+ summary.branchSkipped += 1;
673
+ }
674
+ }
675
+ if (options.output) {
676
+ try {
677
+ await rm(state.outputDir, { force: true, recursive: true });
678
+ summary.outputDeleted += 1;
679
+ cleanupDirs.add(state.outputDir);
680
+ }
681
+ catch (error) {
682
+ summary.outputFailed += 1;
683
+ lines.push(`Failed to delete output ${state.outputDir}: ${error.message}`);
684
+ }
685
+ }
686
+ this.active.delete(state.runId);
687
+ this.controllers.delete(state.runId);
688
+ this.runPaths.delete(state.runId);
689
+ summary.runsCleared += 1;
690
+ lines.push(`Cleared run ${state.runId} for ${runLabel(state)}.`);
691
+ }
692
+ await this.pruneEmptyMagiDirectories({
693
+ dirs: cleanupDirs,
694
+ trees: cleanupTrees,
695
+ });
696
+ return this.formatClearSummary(summary, lines);
697
+ }
698
+ async replyPermission(input) {
699
+ const state = await this.selectState(input);
700
+ if (!state)
701
+ return `Magi run not found: ${this.selectorText(input)}`;
702
+ const selected = this.selectPendingAgent(state, "permission", input.agent, input.requestId);
703
+ if (typeof selected === "string")
704
+ return selected;
705
+ const requestId = input.requestId ?? selected.state.pendingPermission?.id;
706
+ if (!requestId)
707
+ return `Permission request id not found for ${selected.key}.`;
708
+ if (!this.input.client.permission?.reply) {
709
+ return "OpenCode client does not support permission replies.";
710
+ }
711
+ await this.input.client.permission.reply({
712
+ requestID: requestId,
713
+ reply: input.reply,
714
+ });
715
+ selected.state.pendingPermission = undefined;
716
+ if (!selected.state.pendingQuestion &&
717
+ selected.state.status === "blocked") {
718
+ selected.state.status = "running";
719
+ }
720
+ if (state.status === "blocked" && !this.hasBlockedAgents(state)) {
721
+ state.status = "running";
722
+ }
723
+ await this.persist(state);
724
+ return `Replied to permission request ${requestId} for ${selected.key}: ${input.reply}.`;
725
+ }
726
+ async replyQuestion(input) {
727
+ const state = await this.selectState(input);
728
+ if (!state)
729
+ return `Magi run not found: ${this.selectorText(input)}`;
730
+ const selected = this.selectPendingAgent(state, "question", input.agent, input.requestId);
731
+ if (typeof selected === "string")
732
+ return selected;
733
+ const requestId = input.requestId ?? selected.state.pendingQuestion?.id;
734
+ if (!requestId)
735
+ return `Question request id not found for ${selected.key}.`;
736
+ if (!this.input.client.question?.reply) {
737
+ return "OpenCode client does not support question replies.";
738
+ }
739
+ await this.input.client.question.reply({
740
+ answers: input.answers,
741
+ requestID: requestId,
742
+ });
743
+ selected.state.pendingQuestion = undefined;
744
+ if (!selected.state.pendingPermission &&
745
+ selected.state.status === "blocked") {
746
+ selected.state.status = "running";
747
+ }
748
+ if (state.status === "blocked" && !this.hasBlockedAgents(state)) {
749
+ state.status = "running";
750
+ }
751
+ await this.persist(state);
752
+ return `Replied to question request ${requestId} for ${selected.key}.`;
753
+ }
754
+ async rejectQuestion(input) {
755
+ const state = await this.selectState(input);
756
+ if (!state)
757
+ return `Magi run not found: ${this.selectorText(input)}`;
758
+ const selected = this.selectPendingAgent(state, "question", input.agent, input.requestId);
759
+ if (typeof selected === "string")
760
+ return selected;
761
+ const requestId = input.requestId ?? selected.state.pendingQuestion?.id;
762
+ if (!requestId)
763
+ return `Question request id not found for ${selected.key}.`;
764
+ if (!this.input.client.question?.reject) {
765
+ return "OpenCode client does not support question rejection.";
766
+ }
767
+ await this.input.client.question.reject({ requestID: requestId });
768
+ selected.state.pendingQuestion = undefined;
769
+ if (!selected.state.pendingPermission &&
770
+ selected.state.status === "blocked") {
771
+ selected.state.status = "running";
772
+ }
773
+ if (state.status === "blocked" && !this.hasBlockedAgents(state)) {
774
+ state.status = "running";
775
+ }
776
+ await this.persist(state);
777
+ return `Rejected question request ${requestId} for ${selected.key}.`;
778
+ }
779
+ async handleEvent(input) {
780
+ const sessionId = extractSessionId(input.event.properties);
781
+ if (!sessionId)
782
+ return;
783
+ const mapping = this.sessionToRun.get(sessionId);
784
+ if (!mapping)
785
+ return;
786
+ const state = this.active.get(mapping.runId) ??
787
+ (await this.readStateByRunId(mapping.runId));
788
+ if (!state)
789
+ return;
790
+ const agent = this.agentState(state, mapping.agent);
791
+ if (!agent)
792
+ return;
793
+ let dirty = false;
794
+ const receivedAt = now();
795
+ const receivedAtMs = Date.now();
796
+ const markUpdated = (force = false) => {
797
+ const last = this.eventLastUpdates.get(sessionId) ?? 0;
798
+ if (!force && receivedAtMs - last < EVENT_LAST_UPDATE_THROTTLE_MS)
799
+ return;
800
+ agent.lastUpdate = receivedAt;
801
+ this.eventLastUpdates.set(sessionId, receivedAtMs);
802
+ dirty = true;
803
+ };
804
+ const toolPart = extractToolPart(input.event.properties);
805
+ if (toolPart) {
806
+ const counted = this.countedToolParts.get(sessionId) ?? new Set();
807
+ if (!toolPart.id || !counted.has(toolPart.id)) {
808
+ agent.toolCalls += 1;
809
+ if (toolPart.id)
810
+ counted.add(toolPart.id);
811
+ this.countedToolParts.set(sessionId, counted);
812
+ markUpdated(true);
813
+ dirty = true;
814
+ }
815
+ }
816
+ if (input.event.type === "message.part.updated" &&
817
+ toolPart?.tool === "question" &&
818
+ (toolPart.status === "pending" || toolPart.status === "running")) {
819
+ const existing = agent.pendingQuestion;
820
+ const question = {
821
+ id: toolPart.id ?? toolPart.callId,
822
+ questions: extractQuestions(toolPart.input),
823
+ sessionId,
824
+ tool: toolPart.tool,
825
+ };
826
+ agent.pendingQuestion = question;
827
+ agent.status = "blocked";
828
+ state.status = "blocked";
829
+ agent.error = "Question is waiting for an answer.";
830
+ markUpdated(true);
831
+ dirty = true;
832
+ if (!existing) {
833
+ await this.notify(state, questionWaitText({
834
+ agent: mapping.agent,
835
+ pr: prMarkdownLink(state),
836
+ question,
837
+ }), { reply: true });
838
+ }
839
+ }
840
+ if (input.event.type === "permission.asked" ||
841
+ input.event.type === "permission.updated") {
842
+ const permission = extractPermissionRequest(input.event.properties);
843
+ const notified = this.notifiedPermissions.get(sessionId) ?? new Set();
844
+ const permissionId = permission?.id ?? `${mapping.agent}:${Date.now()}`;
845
+ if (!notified.has(permissionId)) {
846
+ notified.add(permissionId);
847
+ this.notifiedPermissions.set(sessionId, notified);
848
+ agent.pendingPermission = permission;
849
+ agent.status = "blocked";
850
+ state.status = "blocked";
851
+ agent.error = `Permission ${permission?.permission ?? "request"} is waiting for approval.`;
852
+ markUpdated(true);
853
+ dirty = true;
854
+ await this.notify(state, `Magi ${mapping.agent} is waiting for permission on ${prMarkdownLink(state)}: ${agent.error}`, { reply: true });
855
+ }
856
+ }
857
+ if (input.event.type === "question.asked") {
858
+ const question = extractQuestionRequest(input.event.properties);
859
+ const notified = this.notifiedPermissions.get(sessionId) ?? new Set();
860
+ const questionId = question?.id ?? `${mapping.agent}:${Date.now()}`;
861
+ if (agent.pendingQuestion) {
862
+ agent.pendingQuestion = question;
863
+ markUpdated(true);
864
+ dirty = true;
865
+ }
866
+ else if (!notified.has(questionId)) {
867
+ notified.add(questionId);
868
+ this.notifiedPermissions.set(sessionId, notified);
869
+ agent.pendingQuestion = question;
870
+ agent.status = "blocked";
871
+ state.status = "blocked";
872
+ agent.error = "Question is waiting for an answer.";
873
+ markUpdated(true);
874
+ dirty = true;
875
+ await this.notify(state, questionWaitText({
876
+ agent: mapping.agent,
877
+ pr: prMarkdownLink(state),
878
+ question,
879
+ }), { reply: true });
880
+ }
881
+ }
882
+ if ((input.event.type === "permission.replied" ||
883
+ input.event.type === "permission.rejected") &&
884
+ agent.pendingPermission) {
885
+ agent.pendingPermission = undefined;
886
+ if (!agent.pendingQuestion && agent.status === "blocked") {
887
+ agent.status = "running";
888
+ }
889
+ if (state.status === "blocked" && !this.hasBlockedAgents(state)) {
890
+ state.status = "running";
891
+ }
892
+ markUpdated(true);
893
+ dirty = true;
894
+ }
895
+ if ((input.event.type === "question.replied" ||
896
+ input.event.type === "question.rejected") &&
897
+ agent.pendingQuestion) {
898
+ agent.pendingQuestion = undefined;
899
+ if (!agent.pendingPermission && agent.status === "blocked") {
900
+ agent.status = "running";
901
+ }
902
+ if (state.status === "blocked" && !this.hasBlockedAgents(state)) {
903
+ state.status = "running";
904
+ }
905
+ markUpdated(true);
906
+ dirty = true;
907
+ }
908
+ if (input.event.type === "message.part.updated" &&
909
+ toolPart?.tool === "question" &&
910
+ (toolPart.status === "completed" || toolPart.status === "error") &&
911
+ agent.pendingQuestion?.tool === "question") {
912
+ agent.pendingQuestion = undefined;
913
+ if (!agent.pendingPermission && agent.status === "blocked") {
914
+ agent.status = "running";
915
+ }
916
+ if (state.status === "blocked" && !this.hasBlockedAgents(state)) {
917
+ state.status = "running";
918
+ }
919
+ markUpdated(true);
920
+ dirty = true;
921
+ }
922
+ if (input.event.type === "permission.replied" &&
923
+ agent.status === "blocked") {
924
+ agent.status = "running";
925
+ markUpdated(true);
926
+ dirty = true;
927
+ }
928
+ if (input.event.type === "session.error") {
929
+ agent.status = "failed";
930
+ agent.error = JSON.stringify(input.event.properties?.error ?? "session error");
931
+ markUpdated(true);
932
+ dirty = true;
933
+ }
934
+ if (!dirty)
935
+ markUpdated();
936
+ if (dirty)
937
+ await this.persist(state);
938
+ }
939
+ formatStates(states, options = {}) {
940
+ if (!states.length)
941
+ return "No Magi runs found.";
942
+ return states
943
+ .map((state) => {
944
+ const editorLine = state.editor
945
+ ? this.formatAgentLine("editor", state.editor, options)
946
+ : undefined;
947
+ const reviewerLines = Object.entries(state.reviewers).map(([key, reviewer]) => {
948
+ return this.formatAgentLine(key, reviewer, options);
949
+ });
950
+ const classifierLines = Object.entries(state.ciClassifiers ?? {}).map(([key, classifier]) => this.formatAgentLine(`ci:${key}`, classifier, options));
951
+ const lines = [
952
+ options.verbose ? `Run: ${state.runId}` : undefined,
953
+ state.pr == null ? undefined : `PR: #${state.pr}`,
954
+ `Command: ${state.command}`,
955
+ state.dryRun ? "Dry run: true" : undefined,
956
+ `Status: ${state.status}`,
957
+ `Phase: ${state.phase}`,
958
+ state.verdict ? `Verdict: ${state.verdict}` : undefined,
959
+ state.error ? `Error: ${state.error}` : undefined,
960
+ ...(state.ciReports ?? []).flatMap((report) => [
961
+ ...report.scopeOutsideRecovered.map((item) => `CI scope outside recovered: ${item.check.name}${report.attempts ? ` (recovered after ${report.attempts} retry attempt${report.attempts === 1 ? "" : "s"})` : ""} - ${item.reason}`),
962
+ ...report.scopeOutsideUnresolved.map((item) => `CI scope outside unresolved: ${item.check.name}${report.attempts ? ` (${report.attempts} retry attempt${report.attempts === 1 ? "" : "s"})` : ""} - ${item.reason}`),
963
+ ...report.scopeInside.map((item) => `CI scope inside: ${item.check.name} - ${item.reason}`),
964
+ ]),
965
+ ...(state.warnings ?? []).map((warning) => `Warning: ${warning}`),
966
+ options.verbose && state.threadAttempts
967
+ ? `Thread attempts: ${Object.keys(state.threadAttempts).length} tracked, ${Object.values(state.threadAttempts).filter((attempt) => attempt.exhaustedAtCycle != null).length} exhausted`
968
+ : undefined,
969
+ options.verbose ? `Artifacts: ${state.outputDir}` : undefined,
970
+ options.verbose && state.reportPath
971
+ ? `Report: ${state.reportPath}`
972
+ : undefined,
973
+ editorLine,
974
+ ...classifierLines,
975
+ ...reviewerLines,
976
+ ];
977
+ return lines.filter(Boolean).join("\n");
978
+ })
979
+ .join("\n\n");
980
+ }
981
+ async formatStatesWithReports(states, options = {}) {
982
+ const sections = [this.formatStates(states, options)];
983
+ if (!options.verbose)
984
+ return sections[0];
985
+ for (const state of states) {
986
+ if (state.status !== "completed" || !state.reportPath)
987
+ continue;
988
+ const report = await readFile(state.reportPath, "utf8").catch(() => "(missing report artifact)");
989
+ sections.push([
990
+ `Report for ${runLabel(state)} (${state.runId}):`,
991
+ "",
992
+ report.trimEnd(),
993
+ ].join("\n"));
994
+ }
995
+ return sections.join("\n\n");
996
+ }
997
+ collectSessionIds(state) {
998
+ const ids = [
999
+ state.editor?.sessionId,
1000
+ ...Object.values(state.reviewers).map((reviewer) => reviewer.sessionId),
1001
+ ...Object.values(state.ciClassifiers ?? {}).map((classifier) => classifier.sessionId),
1002
+ ...Object.values(state.sessionIds ?? {}),
1003
+ ...(state.ciReports ?? []).flatMap((report) => (report.classifierRuns ?? []).map((run) => run.sessionId)),
1004
+ ].filter((id) => Boolean(id));
1005
+ return [...new Set(ids)];
1006
+ }
1007
+ formatClearSummary(summary, lines) {
1008
+ return [
1009
+ `Cleared Magi runs: ${summary.runsCleared}`,
1010
+ `Skipped active runs: ${summary.runsSkippedActive}`,
1011
+ `Sessions deleted: ${summary.sessionDeleted}${summary.sessionFailed ? ` (${summary.sessionFailed} failed)` : ""}`,
1012
+ `Worktrees deleted: ${summary.worktreeDeleted}${summary.worktreeFailed ? ` (${summary.worktreeFailed} failed)` : ""}`,
1013
+ `Branches deleted: ${summary.branchDeleted}${summary.branchFailed ? ` (${summary.branchFailed} failed)` : ""}${summary.branchSkipped ? `, ${summary.branchSkipped} skipped` : ""}`,
1014
+ `Outputs deleted: ${summary.outputDeleted}${summary.outputFailed ? ` (${summary.outputFailed} failed)` : ""}`,
1015
+ ...lines.map((line) => `- ${line}`),
1016
+ ].join("\n");
1017
+ }
1018
+ formatAgentLine(key, agent, options) {
1019
+ const details = [
1020
+ agent.verdict,
1021
+ options.verbose && agent.sessionId
1022
+ ? `session=${agent.sessionId}`
1023
+ : undefined,
1024
+ options.verbose && agent.toolCalls
1025
+ ? `tools=${agent.toolCalls}`
1026
+ : undefined,
1027
+ options.verbose && agent.repairAttempts
1028
+ ? `repairs=${agent.repairAttempts}`
1029
+ : undefined,
1030
+ options.verbose && agent.pendingPermission
1031
+ ? `pendingPermission=${agent.pendingPermission.id ?? agent.pendingPermission.permission ?? "unknown"}`
1032
+ : undefined,
1033
+ options.verbose && agent.pendingQuestion
1034
+ ? `pendingQuestion=${agent.pendingQuestion.id ?? "unknown"}`
1035
+ : undefined,
1036
+ ].filter(Boolean);
1037
+ return `- ${key}: ${agent.status}${details.length ? ` (${details.join(", ")})` : ""}`;
1038
+ }
1039
+ agentState(state, key) {
1040
+ if (key.startsWith("ci:"))
1041
+ return state.ciClassifiers?.[key.slice(3)];
1042
+ return key === "editor" ? state.editor : state.reviewers[key];
1043
+ }
1044
+ agentEntries(state) {
1045
+ return [
1046
+ ...(state.editor
1047
+ ? [["editor", state.editor]]
1048
+ : []),
1049
+ ...Object.entries(state.ciClassifiers ?? {}).map(([key, value]) => [`ci:${key}`, value]),
1050
+ ...Object.entries(state.reviewers),
1051
+ ];
1052
+ }
1053
+ selectPendingAgent(state, kind, key, requestId) {
1054
+ const entries = key
1055
+ ? this.agentState(state, key)
1056
+ ? [[key, this.agentState(state, key)]]
1057
+ : []
1058
+ : this.agentEntries(state);
1059
+ const matches = entries.filter(([, agent]) => {
1060
+ const pending = kind === "permission" ? agent.pendingPermission : agent.pendingQuestion;
1061
+ if (!pending)
1062
+ return false;
1063
+ return !requestId || pending.id === requestId;
1064
+ });
1065
+ if (!matches.length) {
1066
+ return key
1067
+ ? `No pending ${kind} request found for ${key}.`
1068
+ : `No pending ${kind} request found for ${prMarkdownLink(state)}.`;
1069
+ }
1070
+ if (matches.length > 1) {
1071
+ return `Multiple pending ${kind} requests found for ${prMarkdownLink(state)}. Specify agent or requestId.`;
1072
+ }
1073
+ return { key: matches[0][0], state: matches[0][1] };
1074
+ }
1075
+ hasBlockedAgents(state) {
1076
+ return this.agentEntries(state).some(([, agent]) => agent.status === "blocked");
1077
+ }
1078
+ async executeReview(input) {
1079
+ const result = await runReview({
1080
+ client: this.input.client,
1081
+ config: input.config,
1082
+ directory: this.input.directory,
1083
+ dryRun: input.dryRun,
1084
+ exec: withGitHubApiRetry(this.input.exec, input.config.github?.apiRetryAttempts ?? 3),
1085
+ onProgress: (progress) => this.applyReviewProgress(input.runId, progress),
1086
+ pr: input.pr,
1087
+ repository: input.repository,
1088
+ runId: input.runId,
1089
+ signal: input.signal,
1090
+ });
1091
+ const state = this.active.get(input.runId);
1092
+ if (!state)
1093
+ return;
1094
+ if (state.status === "cancelled")
1095
+ return;
1096
+ state.status = "completed";
1097
+ state.phase = "completed";
1098
+ state.completedAt = now();
1099
+ state.verdict = result.verdict;
1100
+ state.majority = result.verdict;
1101
+ state.posted = result.posted;
1102
+ state.reportPath = join(state.outputDir, "report.md");
1103
+ state.sessionIds = result.sessionIds;
1104
+ for (const [key, output] of Object.entries(result.outputs)) {
1105
+ const reviewer = state.reviewers[key];
1106
+ if (!reviewer)
1107
+ continue;
1108
+ reviewer.status = "completed";
1109
+ reviewer.verdict = output.verdict;
1110
+ const artifact = "resolve" in output
1111
+ ? reviewerArtifactBase("rereview", key)
1112
+ : reviewerArtifactBase("review", key);
1113
+ reviewer.rawPath = join(state.outputDir, `${artifact}.raw.txt`);
1114
+ reviewer.parsedPath = join(state.outputDir, `${artifact}.json`);
1115
+ }
1116
+ for (const [key, posted] of Object.entries(result.posted)) {
1117
+ const reviewer = state.reviewers[key];
1118
+ if (!reviewer || !posted.startsWith("skipped:"))
1119
+ continue;
1120
+ reviewer.status = "skipped";
1121
+ }
1122
+ await this.persist(state);
1123
+ if (result.worktreePath) {
1124
+ await removeWorktree(this.input.exec, result.worktreePath).catch(() => undefined);
1125
+ }
1126
+ if (state.worktreeBranch) {
1127
+ await removeBranch(this.input.exec, state.worktreeBranch).catch(() => undefined);
1128
+ }
1129
+ await this.notify(state, [`Finished reviewing ${prMarkdownLink(state)}.`, "", result.report].join("\n"), { reply: true });
1130
+ this.active.delete(input.runId);
1131
+ this.controllers.delete(input.runId);
1132
+ }
1133
+ async executeMerge(input) {
1134
+ const result = await runMerge({
1135
+ client: this.input.client,
1136
+ config: input.config,
1137
+ directory: this.input.directory,
1138
+ dryRun: input.dryRun,
1139
+ exec: withGitHubApiRetry(this.input.exec, input.config.github?.apiRetryAttempts ?? 3),
1140
+ onProgress: (progress) => this.applyMergeProgress(input.runId, progress),
1141
+ pr: input.pr,
1142
+ repository: input.repository,
1143
+ runId: input.runId,
1144
+ signal: input.signal,
1145
+ });
1146
+ const state = this.active.get(input.runId);
1147
+ if (!state)
1148
+ return;
1149
+ if (state.status === "cancelled")
1150
+ return;
1151
+ state.status = "completed";
1152
+ state.phase = result.status;
1153
+ state.completedAt = now();
1154
+ state.verdict = result.status;
1155
+ state.reportPath = join(state.outputDir, "report.md");
1156
+ if (state.editor?.status === "pending")
1157
+ state.editor.status = "skipped";
1158
+ await this.persist(state);
1159
+ await this.notify(state, [
1160
+ `Finished merge workflow for ${prMarkdownLink(state)}.`,
1161
+ "",
1162
+ result.report,
1163
+ ].join("\n"), { reply: true });
1164
+ this.active.delete(input.runId);
1165
+ this.controllers.delete(input.runId);
1166
+ }
1167
+ async applyReviewProgress(runId, progress) {
1168
+ const state = this.active.get(runId);
1169
+ if (!state)
1170
+ return;
1171
+ state.updatedAt = now();
1172
+ if (progress.type === "phase") {
1173
+ state.phase = progress.phase;
1174
+ state.status =
1175
+ progress.phase === "posting reviews" ? "posting" : "running";
1176
+ }
1177
+ if (progress.type === "ci_report") {
1178
+ state.ciReports = [...(state.ciReports ?? []), progress.report];
1179
+ }
1180
+ if (progress.type === "ci_classifier_started") {
1181
+ state.ciClassifiers ??= {};
1182
+ state.ciClassifiers[progress.reviewer] = {
1183
+ account: progress.reviewer,
1184
+ promptPath: progress.promptPath,
1185
+ repairAttempts: 0,
1186
+ status: "running",
1187
+ toolCalls: 0,
1188
+ };
1189
+ }
1190
+ if (progress.type === "ci_classifier_session") {
1191
+ state.ciClassifiers ??= {};
1192
+ const classifier = state.ciClassifiers[progress.reviewer] ??
1193
+ (state.ciClassifiers[progress.reviewer] = {
1194
+ account: progress.reviewer,
1195
+ repairAttempts: 0,
1196
+ status: "running",
1197
+ toolCalls: 0,
1198
+ });
1199
+ classifier.sessionId = progress.sessionId;
1200
+ classifier.status = "running";
1201
+ classifier.lastUpdate = now();
1202
+ this.sessionToRun.set(progress.sessionId, {
1203
+ agent: `ci:${progress.reviewer}`,
1204
+ runId,
1205
+ });
1206
+ }
1207
+ if (progress.type === "ci_classifier_repair") {
1208
+ const classifier = state.ciClassifiers?.[progress.reviewer];
1209
+ if (classifier) {
1210
+ classifier.repairAttempts += 1;
1211
+ classifier.status = "repairing";
1212
+ classifier.lastUpdate = now();
1213
+ }
1214
+ }
1215
+ if (progress.type === "ci_classifier_completed") {
1216
+ const classifier = state.ciClassifiers?.[progress.reviewer];
1217
+ if (classifier) {
1218
+ classifier.classification = progress.classification;
1219
+ classifier.rawPath = progress.rawPath;
1220
+ classifier.reason = progress.reason;
1221
+ classifier.sessionId = progress.sessionId;
1222
+ classifier.status = "completed";
1223
+ classifier.lastUpdate = now();
1224
+ }
1225
+ }
1226
+ if (progress.type === "ci_classifier_failed") {
1227
+ const classifier = state.ciClassifiers?.[progress.reviewer];
1228
+ if (classifier) {
1229
+ classifier.error = progress.error;
1230
+ classifier.status = "failed";
1231
+ classifier.lastUpdate = now();
1232
+ }
1233
+ }
1234
+ if (progress.type === "worktree_created") {
1235
+ state.worktreePath = progress.worktreePath;
1236
+ state.worktreeBranch = progress.branch;
1237
+ }
1238
+ if (progress.type === "reviewer_started") {
1239
+ const reviewer = state.reviewers[progress.reviewer];
1240
+ if (!reviewer)
1241
+ return;
1242
+ reviewer.status = "running";
1243
+ }
1244
+ if (progress.type === "reviewer_skipped") {
1245
+ const reviewer = state.reviewers[progress.reviewer];
1246
+ if (!reviewer)
1247
+ return;
1248
+ reviewer.status = "skipped";
1249
+ reviewer.verdict = progress.verdict;
1250
+ }
1251
+ if (progress.type === "reviewer_session") {
1252
+ const reviewer = state.reviewers[progress.reviewer];
1253
+ if (!reviewer)
1254
+ return;
1255
+ if (progress.options)
1256
+ this.input.setSessionOptions?.(progress.sessionId, progress.options);
1257
+ reviewer.sessionId = progress.sessionId;
1258
+ reviewer.status = "running";
1259
+ reviewer.lastUpdate = now();
1260
+ this.sessionToRun.set(progress.sessionId, {
1261
+ agent: progress.reviewer,
1262
+ runId,
1263
+ });
1264
+ }
1265
+ if (progress.type === "reviewer_repair") {
1266
+ const reviewer = state.reviewers[progress.reviewer];
1267
+ if (!reviewer)
1268
+ return;
1269
+ reviewer.status = "repairing";
1270
+ reviewer.repairAttempts += 1;
1271
+ reviewer.lastUpdate = now();
1272
+ }
1273
+ if (progress.type === "reviewer_response") {
1274
+ const reviewer = state.reviewers[progress.reviewer];
1275
+ if (!reviewer)
1276
+ return;
1277
+ reviewer.sessionId = progress.sessionId;
1278
+ reviewer.lastUpdate = now();
1279
+ }
1280
+ if (progress.type === "reviewer_failed") {
1281
+ const reviewer = state.reviewers[progress.reviewer];
1282
+ if (!reviewer)
1283
+ return;
1284
+ reviewer.status = "failed";
1285
+ reviewer.error = progress.error;
1286
+ reviewer.lastUpdate = now();
1287
+ }
1288
+ if (progress.type === "reviewer_completed") {
1289
+ const reviewer = state.reviewers[progress.reviewer];
1290
+ if (!reviewer)
1291
+ return;
1292
+ reviewer.sessionId = progress.sessionId;
1293
+ reviewer.status = "completed";
1294
+ reviewer.verdict = progress.verdict;
1295
+ reviewer.lastUpdate = now();
1296
+ }
1297
+ if (progress.type === "completed") {
1298
+ state.verdict = progress.verdict;
1299
+ }
1300
+ await this.persist(state);
1301
+ if (progress.type === "reviewer_reconsidered") {
1302
+ await this.notify(state, closeReconsiderationText({
1303
+ pr: prMarkdownLink(state),
1304
+ reviewer: progress.reviewer,
1305
+ to: progress.to,
1306
+ }));
1307
+ }
1308
+ if (progress.type === "findings_validated") {
1309
+ await this.notify(state, findingsValidationText({
1310
+ discarded: progress.discarded,
1311
+ kept: progress.kept,
1312
+ pr: prMarkdownLink(state),
1313
+ }));
1314
+ for (const reviewer of progress.reviewersChangedToMerge) {
1315
+ await this.notify(state, `**Reviewer ${reviewer}** had no remaining findings after validation and approved ${prMarkdownLink(state)}.`);
1316
+ }
1317
+ }
1318
+ if (progress.type === "ci_report") {
1319
+ await this.notify(state, ciReportText({ pr: prMarkdownLink(state), report: progress.report }));
1320
+ }
1321
+ if (progress.type === "ci_classifier_started") {
1322
+ await this.notify(state, `**CI classifier ${progress.reviewer}** started for ${prMarkdownLink(state)}.`);
1323
+ }
1324
+ if (progress.type === "ci_classifier_repair") {
1325
+ await this.notify(state, `**CI classifier ${progress.reviewer}** started JSON regeneration for ${prMarkdownLink(state)}.`);
1326
+ }
1327
+ if (progress.type === "ci_classifier_completed") {
1328
+ await this.notify(state, `**CI classifier ${progress.reviewer}** completed for ${prMarkdownLink(state)}: ${progress.classification} - ${progress.reason}`);
1329
+ }
1330
+ if (progress.type === "ci_classifier_failed") {
1331
+ await this.notify(state, `**CI classifier ${progress.reviewer}** failed for ${prMarkdownLink(state)}: ${progress.error}`);
1332
+ }
1333
+ if (progress.type === "worktree_created") {
1334
+ await this.notify(state, `Worktree is ready for ${prMarkdownLink(state)}.`);
1335
+ }
1336
+ if (progress.type === "reviewer_started") {
1337
+ await this.notify(state, `**Reviewer ${progress.reviewer}** started reviewing ${prMarkdownLink(state)}.`);
1338
+ }
1339
+ if (progress.type === "reviewer_skipped") {
1340
+ await this.notify(state, `**Reviewer ${progress.reviewer}** skipped ${prMarkdownLink(state)} with existing verdict ${progress.verdict}.`);
1341
+ }
1342
+ if (progress.type === "reviewer_repair") {
1343
+ await this.notify(state, `**Reviewer ${progress.reviewer}** started JSON regeneration for ${prMarkdownLink(state)}.`);
1344
+ }
1345
+ if (progress.type === "reviewer_failed") {
1346
+ await this.notify(state, reviewerFailureText({
1347
+ error: progress.error,
1348
+ pr: prMarkdownLink(state),
1349
+ repairAttempts: state.reviewers[progress.reviewer]?.repairAttempts ?? 0,
1350
+ reviewer: progress.reviewer,
1351
+ }));
1352
+ }
1353
+ if (progress.type === "phase" && state.command === "review") {
1354
+ const text = mergePhaseText({
1355
+ phase: progress.phase,
1356
+ pr: prMarkdownLink(state),
1357
+ });
1358
+ if (text)
1359
+ await this.notify(state, text);
1360
+ }
1361
+ if (progress.type === "completed" && state.command === "merge") {
1362
+ await this.notify(state, reviewDecisionText({
1363
+ pr: prMarkdownLink(state),
1364
+ verdict: progress.verdict,
1365
+ }));
1366
+ }
1367
+ if (progress.type === "completed" && state.command === "review") {
1368
+ await this.notify(state, reviewDecisionText({
1369
+ pr: prMarkdownLink(state),
1370
+ verdict: progress.verdict,
1371
+ }));
1372
+ }
1373
+ if (progress.type === "reviewer_completed") {
1374
+ await this.notify(state, reviewerCompletionText({
1375
+ pr: prMarkdownLink(state),
1376
+ reviewer: progress.reviewer,
1377
+ verdict: progress.verdict,
1378
+ }));
1379
+ }
1380
+ }
1381
+ async applyMergeProgress(runId, progress) {
1382
+ if (progress.type === "phase" ||
1383
+ progress.type === "worktree_created" ||
1384
+ progress.type === "reviewer_started" ||
1385
+ progress.type === "reviewer_skipped" ||
1386
+ progress.type === "reviewer_session" ||
1387
+ progress.type === "reviewer_repair" ||
1388
+ progress.type === "reviewer_response" ||
1389
+ progress.type === "reviewer_failed" ||
1390
+ progress.type === "reviewer_completed" ||
1391
+ progress.type === "reviewer_reconsidered" ||
1392
+ progress.type === "findings_validated" ||
1393
+ progress.type === "ci_report" ||
1394
+ progress.type === "completed") {
1395
+ if (progress.type === "phase") {
1396
+ const state = this.active.get(runId);
1397
+ const text = state
1398
+ ? mergePhaseText({ phase: progress.phase, pr: prMarkdownLink(state) })
1399
+ : undefined;
1400
+ if (state && text)
1401
+ await this.notify(state, text);
1402
+ }
1403
+ await this.applyReviewProgress(runId, progress);
1404
+ return;
1405
+ }
1406
+ const state = this.active.get(runId);
1407
+ if (!state)
1408
+ return;
1409
+ if (progress.type === "warning") {
1410
+ state.warnings = [...(state.warnings ?? []), progress.message];
1411
+ await this.persist(state);
1412
+ await this.notify(state, `Warning for ${prMarkdownLink(state)}: ${progress.message}`);
1413
+ return;
1414
+ }
1415
+ if (progress.type === "thread_limit_reached") {
1416
+ await this.notify(state, threadLimitText({
1417
+ pr: prMarkdownLink(state),
1418
+ threads: progress.threads,
1419
+ }));
1420
+ return;
1421
+ }
1422
+ if (progress.type === "thread_attempts") {
1423
+ state.threadAttempts = progress.attempts;
1424
+ await this.persist(state);
1425
+ await this.notify(state, `Tracked ${Object.keys(progress.attempts).length} review thread resolution attempts for ${prMarkdownLink(state)}.`);
1426
+ return;
1427
+ }
1428
+ const editor = state.editor;
1429
+ if (!editor)
1430
+ return;
1431
+ state.updatedAt = now();
1432
+ if (progress.type === "editor_started") {
1433
+ editor.status = "running";
1434
+ }
1435
+ if (progress.type === "editor_session") {
1436
+ if (progress.options)
1437
+ this.input.setSessionOptions?.(progress.sessionId, progress.options);
1438
+ editor.sessionId = progress.sessionId;
1439
+ editor.status = "running";
1440
+ editor.lastUpdate = now();
1441
+ this.sessionToRun.set(progress.sessionId, { agent: "editor", runId });
1442
+ }
1443
+ if (progress.type === "editor_repair") {
1444
+ editor.status = "repairing";
1445
+ editor.repairAttempts += 1;
1446
+ editor.lastUpdate = now();
1447
+ }
1448
+ if (progress.type === "editor_response") {
1449
+ editor.sessionId = progress.sessionId;
1450
+ editor.lastUpdate = now();
1451
+ }
1452
+ if (progress.type === "editor_failed") {
1453
+ editor.status = "failed";
1454
+ editor.error = progress.error;
1455
+ editor.lastUpdate = now();
1456
+ }
1457
+ if (progress.type === "editor_completed") {
1458
+ editor.status = "completed";
1459
+ editor.rawPath = join(state.outputDir, `editor.cycle-${progress.cycle}.raw.txt`);
1460
+ editor.parsedPath = join(state.outputDir, `editor.cycle-${progress.cycle}.json`);
1461
+ editor.lastUpdate = now();
1462
+ }
1463
+ if (progress.type === "merge_completed") {
1464
+ state.verdict = progress.status;
1465
+ }
1466
+ await this.persist(state);
1467
+ if (progress.type === "editor_started") {
1468
+ await this.notify(state, `**Editor** started edit cycle ${progress.cycle} for ${prMarkdownLink(state)}.`);
1469
+ }
1470
+ if (progress.type === "editor_repair") {
1471
+ await this.notify(state, `**Editor** started JSON regeneration for ${prMarkdownLink(state)} cycle ${progress.cycle}.`);
1472
+ }
1473
+ if (progress.type === "editor_completed") {
1474
+ await this.notify(state, `**Editor** finished editing ${prMarkdownLink(state)}.`);
1475
+ }
1476
+ if (progress.type === "editor_failed") {
1477
+ await this.notify(state, editorFailureText({
1478
+ error: progress.error,
1479
+ pr: prMarkdownLink(state),
1480
+ repairAttempts: state.editor?.repairAttempts ?? 0,
1481
+ }));
1482
+ }
1483
+ if (progress.type === "merge_completed") {
1484
+ await this.notify(state, `Merge workflow reached ${progress.status} for ${prMarkdownLink(state)}.`);
1485
+ }
1486
+ }
1487
+ async failRun(runId, error) {
1488
+ const state = this.active.get(runId);
1489
+ if (!state)
1490
+ return;
1491
+ if (state.status === "cancelled")
1492
+ return;
1493
+ state.status = "failed";
1494
+ state.phase = "failed";
1495
+ state.completedAt = now();
1496
+ state.error = error instanceof Error ? error.message : String(error);
1497
+ if (state.editor?.status === "pending" ||
1498
+ state.editor?.status === "running" ||
1499
+ state.editor?.status === "repairing" ||
1500
+ state.editor?.status === "blocked") {
1501
+ state.editor.status = "failed";
1502
+ state.editor.error = state.error;
1503
+ }
1504
+ if (state.editor?.sessionId) {
1505
+ await this.input.client.session
1506
+ .abort?.({ path: { id: state.editor.sessionId } })
1507
+ .catch(() => undefined);
1508
+ }
1509
+ for (const reviewer of Object.values(state.reviewers)) {
1510
+ if (reviewer.status === "pending" ||
1511
+ reviewer.status === "running" ||
1512
+ reviewer.status === "repairing" ||
1513
+ reviewer.status === "blocked") {
1514
+ reviewer.status = "failed";
1515
+ reviewer.error = state.error;
1516
+ }
1517
+ if (reviewer.sessionId) {
1518
+ await this.input.client.session
1519
+ .abort?.({ path: { id: reviewer.sessionId } })
1520
+ .catch(() => undefined);
1521
+ }
1522
+ }
1523
+ for (const classifier of Object.values(state.ciClassifiers ?? {})) {
1524
+ if (classifier.status === "pending" ||
1525
+ classifier.status === "running" ||
1526
+ classifier.status === "repairing" ||
1527
+ classifier.status === "blocked") {
1528
+ classifier.status = "failed";
1529
+ classifier.error = state.error;
1530
+ }
1531
+ if (classifier.sessionId) {
1532
+ await this.input.client.session
1533
+ .abort?.({ path: { id: classifier.sessionId } })
1534
+ .catch(() => undefined);
1535
+ }
1536
+ }
1537
+ await this.persist(state);
1538
+ await this.notify(state, `Magi ${state.command} failed for ${runLabel(state)}: ${state.error}`, { reply: true });
1539
+ this.active.delete(runId);
1540
+ this.controllers.delete(runId);
1541
+ }
1542
+ async filteredStates(input) {
1543
+ const states = input.runId
1544
+ ? (await this.readStateByRunId(input.runId))
1545
+ ? [(await this.readStateByRunId(input.runId))]
1546
+ : []
1547
+ : await this.listStates(input.outputDir);
1548
+ return states
1549
+ .filter((state) => input.command == null || state.command === input.command)
1550
+ .filter((state) => input.pr == null || state.pr === input.pr)
1551
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1552
+ }
1553
+ async selectState(input) {
1554
+ if (input.runId)
1555
+ return this.readStateByRunId(input.runId);
1556
+ return (await this.filteredStates(input))[0];
1557
+ }
1558
+ selectorText(input) {
1559
+ if (input.runId)
1560
+ return input.runId;
1561
+ if (input.pr != null)
1562
+ return `PR #${input.pr}`;
1563
+ return "all runs";
1564
+ }
1565
+ absoluteOutputDir(dir) {
1566
+ return isAbsolute(dir) ? dir : join(this.input.directory, dir);
1567
+ }
1568
+ absoluteWorktreeDirs(input) {
1569
+ const worktreeDirs = Array.isArray(input.worktreeDir)
1570
+ ? input.worktreeDir
1571
+ : input.worktreeDir
1572
+ ? [input.worktreeDir]
1573
+ : worktreeBaseDirs(this.input.directory);
1574
+ return worktreeDirs.map((dir) => isAbsolute(dir) ? dir : join(this.input.directory, dir));
1575
+ }
1576
+ emptyOutputCleanupRoots(input) {
1577
+ const outputDirs = Array.isArray(input.outputDir)
1578
+ ? input.outputDir
1579
+ : input.outputDir
1580
+ ? [input.outputDir]
1581
+ : [join(this.input.directory, ".magi", "runs")];
1582
+ return outputDirs.map((dir) => this.absoluteOutputDir(dir));
1583
+ }
1584
+ async pruneEmptyMagiDirectories(input) {
1585
+ for (const dir of input.trees) {
1586
+ await pruneEmptyDirectories({
1587
+ boundary: this.input.directory,
1588
+ recursive: true,
1589
+ start: dir,
1590
+ });
1591
+ }
1592
+ for (const dir of input.dirs) {
1593
+ await pruneEmptyDirectories({
1594
+ boundary: this.input.directory,
1595
+ start: dir,
1596
+ });
1597
+ }
1598
+ }
1599
+ async listStates(outputDir) {
1600
+ for (const dir of Array.isArray(outputDir)
1601
+ ? outputDir
1602
+ : outputDir
1603
+ ? [outputDir]
1604
+ : []) {
1605
+ this.outputDirs.add(this.absoluteOutputDir(dir));
1606
+ }
1607
+ const baseDirs = [...this.outputDirs].map((dir) => this.absoluteOutputDir(dir));
1608
+ if (!baseDirs.length)
1609
+ baseDirs.push(join(this.input.directory, ".magi", "runs"));
1610
+ const states = [];
1611
+ async function walk(dir) {
1612
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
1613
+ for (const entry of entries) {
1614
+ const path = join(dir, entry.name);
1615
+ if (entry.isDirectory()) {
1616
+ await walk(path);
1617
+ continue;
1618
+ }
1619
+ if (entry.name !== "state.json")
1620
+ continue;
1621
+ const state = await readFile(path, "utf8")
1622
+ .then((text) => JSON.parse(text))
1623
+ .catch(() => undefined);
1624
+ if (state)
1625
+ states.push(state);
1626
+ }
1627
+ }
1628
+ for (const baseDir of baseDirs)
1629
+ await walk(baseDir);
1630
+ for (const state of this.active.values()) {
1631
+ if (!states.some((item) => item.runId === state.runId))
1632
+ states.push(state);
1633
+ }
1634
+ return states;
1635
+ }
1636
+ async readStateByRunId(runId) {
1637
+ const active = this.active.get(runId);
1638
+ if (active)
1639
+ return active;
1640
+ const knownPath = this.runPaths.get(runId);
1641
+ if (knownPath) {
1642
+ const state = await readFile(knownPath, "utf8")
1643
+ .then((text) => JSON.parse(text))
1644
+ .catch(() => undefined);
1645
+ if (state)
1646
+ return state;
1647
+ }
1648
+ return (await this.listStates()).find((state) => state.runId === runId);
1649
+ }
1650
+ async persist(state) {
1651
+ state.updatedAt = now();
1652
+ await mkdir(state.outputDir, { recursive: true });
1653
+ const path = join(state.outputDir, "state.json");
1654
+ this.runPaths.set(state.runId, path);
1655
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
1656
+ }
1657
+ async notify(state, text, options = {}) {
1658
+ if (!state.parentSessionId || !this.input.client.session.promptAsync)
1659
+ return;
1660
+ void options;
1661
+ await this.input.client.session
1662
+ .promptAsync({
1663
+ body: {
1664
+ parts: [{ type: "text", text, synthetic: true }],
1665
+ },
1666
+ path: { id: state.parentSessionId },
1667
+ })
1668
+ .catch(() => undefined);
1669
+ }
1670
+ }