opencode-magi 0.0.0-dev-20260520175008 → 0.0.0-dev-20260520182750

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.
@@ -164,6 +164,13 @@ function editorFailureText(input) {
164
164
  const repairs = repairAttemptsText(input.repairAttempts);
165
165
  return `**Editor** failed editing ${input.pr}${repairs}: ${input.error}`;
166
166
  }
167
+ function triageCreatorFailureText(input) {
168
+ const repairs = repairAttemptsText(input.repairAttempts);
169
+ return `**Triage creator** failed creating an implementation PR for ${input.issue}${repairs}: ${input.error}`;
170
+ }
171
+ function triageDecisionNotification(input) {
172
+ return `Triage decided ${input.issue}: ${input.result}. Planned action: ${input.action}.`;
173
+ }
167
174
  function repairAttemptsText(attempts) {
168
175
  if (!attempts)
169
176
  return "";
@@ -536,6 +543,14 @@ export class MagiRunManager {
536
543
  ])),
537
544
  runId,
538
545
  status: "preparing",
546
+ triageCreator: input.repository.agents.triageCreator
547
+ ? {
548
+ account: input.repository.agents.triageCreator.account,
549
+ repairAttempts: 0,
550
+ status: "pending",
551
+ toolCalls: 0,
552
+ }
553
+ : undefined,
539
554
  updatedAt: createdAt,
540
555
  };
541
556
  this.active.set(runId, state);
@@ -626,6 +641,17 @@ export class MagiRunManager {
626
641
  .abort?.({ path: { id: state.editor.sessionId } })
627
642
  .catch(() => undefined);
628
643
  }
644
+ if (state.triageCreator?.status === "pending" ||
645
+ state.triageCreator?.status === "running" ||
646
+ state.triageCreator?.status === "repairing" ||
647
+ state.triageCreator?.status === "blocked") {
648
+ state.triageCreator.status = "cancelled";
649
+ }
650
+ if (state.triageCreator?.sessionId) {
651
+ await this.input.client.session
652
+ .abort?.({ path: { id: state.triageCreator.sessionId } })
653
+ .catch(() => undefined);
654
+ }
629
655
  for (const reviewer of Object.values(state.reviewers)) {
630
656
  if (reviewer.status === "pending" ||
631
657
  reviewer.status === "running" ||
@@ -919,7 +945,7 @@ export class MagiRunManager {
919
945
  if (!existing) {
920
946
  await this.notify(state, questionWaitText({
921
947
  agent: mapping.agent,
922
- pr: prMarkdownLink(state),
948
+ pr: runLabel(state),
923
949
  question,
924
950
  }), { reply: true });
925
951
  }
@@ -938,7 +964,7 @@ export class MagiRunManager {
938
964
  agent.error = `Permission ${permission?.permission ?? "request"} is waiting for approval.`;
939
965
  markUpdated(true);
940
966
  dirty = true;
941
- await this.notify(state, `Magi ${mapping.agent} is waiting for permission on ${prMarkdownLink(state)}: ${agent.error}`, { reply: true });
967
+ await this.notify(state, `Magi ${mapping.agent} is waiting for permission on ${runLabel(state)}: ${agent.error}`, { reply: true });
942
968
  }
943
969
  }
944
970
  if (input.event.type === "question.asked") {
@@ -961,7 +987,7 @@ export class MagiRunManager {
961
987
  dirty = true;
962
988
  await this.notify(state, questionWaitText({
963
989
  agent: mapping.agent,
964
- pr: prMarkdownLink(state),
990
+ pr: runLabel(state),
965
991
  question,
966
992
  }), { reply: true });
967
993
  }
@@ -1031,6 +1057,9 @@ export class MagiRunManager {
1031
1057
  const editorLine = state.editor
1032
1058
  ? this.formatAgentLine("editor", state.editor, options)
1033
1059
  : undefined;
1060
+ const triageCreatorLine = state.triageCreator
1061
+ ? this.formatAgentLine("triageCreator", state.triageCreator, options)
1062
+ : undefined;
1034
1063
  const reviewerLines = Object.entries(state.reviewers).map(([key, reviewer]) => {
1035
1064
  return this.formatAgentLine(key, reviewer, options);
1036
1065
  });
@@ -1038,6 +1067,7 @@ export class MagiRunManager {
1038
1067
  const lines = [
1039
1068
  options.verbose ? `Run: ${state.runId}` : undefined,
1040
1069
  state.pr == null ? undefined : `PR: #${state.pr}`,
1070
+ state.issue == null ? undefined : `Issue: #${state.issue}`,
1041
1071
  `Command: ${state.command}`,
1042
1072
  state.dryRun ? "Dry run: true" : undefined,
1043
1073
  `Status: ${state.status}`,
@@ -1058,6 +1088,7 @@ export class MagiRunManager {
1058
1088
  ? `Report: ${state.reportPath}`
1059
1089
  : undefined,
1060
1090
  editorLine,
1091
+ triageCreatorLine,
1061
1092
  ...classifierLines,
1062
1093
  ...reviewerLines,
1063
1094
  ];
@@ -1084,6 +1115,7 @@ export class MagiRunManager {
1084
1115
  collectSessionIds(state) {
1085
1116
  const ids = [
1086
1117
  state.editor?.sessionId,
1118
+ state.triageCreator?.sessionId,
1087
1119
  ...Object.values(state.reviewers).map((reviewer) => reviewer.sessionId),
1088
1120
  ...Object.values(state.ciClassifiers ?? {}).map((classifier) => classifier.sessionId),
1089
1121
  ...Object.values(state.sessionIds ?? {}),
@@ -1126,13 +1158,22 @@ export class MagiRunManager {
1126
1158
  agentState(state, key) {
1127
1159
  if (key.startsWith("ci:"))
1128
1160
  return state.ciClassifiers?.[key.slice(3)];
1129
- return key === "editor" ? state.editor : state.reviewers[key];
1161
+ if (key === "editor")
1162
+ return state.editor;
1163
+ if (key === "triageCreator")
1164
+ return state.triageCreator;
1165
+ return state.reviewers[key];
1130
1166
  }
1131
1167
  agentEntries(state) {
1132
1168
  return [
1133
1169
  ...(state.editor
1134
1170
  ? [["editor", state.editor]]
1135
1171
  : []),
1172
+ ...(state.triageCreator
1173
+ ? [
1174
+ ["triageCreator", state.triageCreator],
1175
+ ]
1176
+ : []),
1136
1177
  ...Object.entries(state.ciClassifiers ?? {}).map(([key, value]) => [`ci:${key}`, value]),
1137
1178
  ...Object.entries(state.reviewers),
1138
1179
  ];
@@ -1152,10 +1193,10 @@ export class MagiRunManager {
1152
1193
  if (!matches.length) {
1153
1194
  return key
1154
1195
  ? `No pending ${kind} request found for ${key}.`
1155
- : `No pending ${kind} request found for ${prMarkdownLink(state)}.`;
1196
+ : `No pending ${kind} request found for ${runLabel(state)}.`;
1156
1197
  }
1157
1198
  if (matches.length > 1) {
1158
- return `Multiple pending ${kind} requests found for ${prMarkdownLink(state)}. Specify agent or requestId.`;
1199
+ return `Multiple pending ${kind} requests found for ${runLabel(state)}. Specify agent or requestId.`;
1159
1200
  }
1160
1201
  return { key: matches[0][0], state: matches[0][1] };
1161
1202
  }
@@ -1266,6 +1307,7 @@ export class MagiRunManager {
1266
1307
  dryRun: input.dryRun,
1267
1308
  exec: withGitHubApiRetry(this.input.exec, input.config.github?.apiRetryAttempts ?? 3),
1268
1309
  issue: input.issue,
1310
+ onProgress: (progress) => this.applyTriageProgress(input.runId, progress),
1269
1311
  repository: input.repository,
1270
1312
  runId: input.runId,
1271
1313
  signal: input.signal,
@@ -1284,6 +1326,9 @@ export class MagiRunManager {
1284
1326
  if (agent.status === "pending")
1285
1327
  agent.status = "completed";
1286
1328
  }
1329
+ if (completed.triageCreator?.status === "pending") {
1330
+ completed.triageCreator.status = "skipped";
1331
+ }
1287
1332
  await this.persist(completed);
1288
1333
  await this.notify(completed, [
1289
1334
  `Finished triage for ${issueMarkdownLink(completed)}.`,
@@ -1293,6 +1338,189 @@ export class MagiRunManager {
1293
1338
  this.active.delete(input.runId);
1294
1339
  this.controllers.delete(input.runId);
1295
1340
  }
1341
+ async applyTriageProgress(runId, progress) {
1342
+ const state = this.active.get(runId);
1343
+ if (!state)
1344
+ return;
1345
+ const issue = issueMarkdownLink(state);
1346
+ const creatorState = () => state.triageCreator ??
1347
+ (state.triageCreator = {
1348
+ account: "triageCreator",
1349
+ repairAttempts: 0,
1350
+ status: "pending",
1351
+ toolCalls: 0,
1352
+ });
1353
+ state.updatedAt = now();
1354
+ if (progress.type === "phase") {
1355
+ state.phase = progress.phase;
1356
+ state.status = "running";
1357
+ }
1358
+ if (progress.type === "decision") {
1359
+ state.phase = `decision: ${progress.result.disposition}`;
1360
+ state.verdict = JSON.stringify(progress.result);
1361
+ }
1362
+ if (progress.type === "comment_posting") {
1363
+ state.phase = "posting triage comment";
1364
+ state.status = "posting";
1365
+ }
1366
+ if (progress.type === "comment_posted") {
1367
+ state.status = "running";
1368
+ }
1369
+ if (progress.type === "pr_creation_started") {
1370
+ state.phase = "creating implementation PR";
1371
+ state.status = "running";
1372
+ }
1373
+ if (progress.type === "worktree_created") {
1374
+ state.worktreePath = progress.worktreePath;
1375
+ state.worktreeBranch = progress.branch;
1376
+ }
1377
+ if (progress.type === "triage_agent_started") {
1378
+ const reviewer = state.reviewers[progress.reviewer];
1379
+ if (reviewer)
1380
+ reviewer.status = "running";
1381
+ }
1382
+ if (progress.type === "triage_agent_session") {
1383
+ const reviewer = state.reviewers[progress.reviewer];
1384
+ if (reviewer) {
1385
+ if (progress.options)
1386
+ this.input.setSessionOptions?.(progress.sessionId, progress.options);
1387
+ reviewer.sessionId = progress.sessionId;
1388
+ reviewer.status = "running";
1389
+ reviewer.lastUpdate = now();
1390
+ this.sessionToRun.set(progress.sessionId, {
1391
+ agent: progress.reviewer,
1392
+ runId,
1393
+ });
1394
+ }
1395
+ }
1396
+ if (progress.type === "triage_agent_repair") {
1397
+ const reviewer = state.reviewers[progress.reviewer];
1398
+ if (reviewer) {
1399
+ reviewer.status = "repairing";
1400
+ reviewer.repairAttempts += 1;
1401
+ reviewer.lastUpdate = now();
1402
+ }
1403
+ }
1404
+ if (progress.type === "triage_agent_response") {
1405
+ const reviewer = state.reviewers[progress.reviewer];
1406
+ if (reviewer) {
1407
+ reviewer.sessionId = progress.sessionId;
1408
+ reviewer.lastUpdate = now();
1409
+ }
1410
+ }
1411
+ if (progress.type === "triage_agent_completed") {
1412
+ const reviewer = state.reviewers[progress.reviewer];
1413
+ if (reviewer) {
1414
+ reviewer.sessionId = progress.sessionId;
1415
+ reviewer.status = "completed";
1416
+ reviewer.verdict = progress.vote;
1417
+ reviewer.rawPath = join(state.outputDir, `${progress.reviewer}.${progress.phase}.raw.txt`);
1418
+ reviewer.parsedPath = join(state.outputDir, `${progress.reviewer}.${progress.phase}.json`);
1419
+ reviewer.lastUpdate = now();
1420
+ }
1421
+ }
1422
+ if (progress.type === "triage_agent_failed") {
1423
+ const reviewer = state.reviewers[progress.reviewer];
1424
+ if (reviewer) {
1425
+ reviewer.status = "failed";
1426
+ reviewer.error = redactSecrets(progress.error);
1427
+ reviewer.lastUpdate = now();
1428
+ }
1429
+ }
1430
+ if (progress.type === "triage_creator_started") {
1431
+ creatorState().status = "running";
1432
+ }
1433
+ if (progress.type === "triage_creator_session") {
1434
+ const creator = creatorState();
1435
+ if (progress.options)
1436
+ this.input.setSessionOptions?.(progress.sessionId, progress.options);
1437
+ creator.sessionId = progress.sessionId;
1438
+ creator.status = "running";
1439
+ creator.lastUpdate = now();
1440
+ this.sessionToRun.set(progress.sessionId, {
1441
+ agent: "triageCreator",
1442
+ runId,
1443
+ });
1444
+ }
1445
+ if (progress.type === "triage_creator_repair") {
1446
+ const creator = creatorState();
1447
+ creator.status = "repairing";
1448
+ creator.repairAttempts += 1;
1449
+ creator.lastUpdate = now();
1450
+ }
1451
+ if (progress.type === "triage_creator_response") {
1452
+ const creator = creatorState();
1453
+ creator.sessionId = progress.sessionId;
1454
+ creator.lastUpdate = now();
1455
+ }
1456
+ if (progress.type === "triage_creator_completed") {
1457
+ const creator = creatorState();
1458
+ creator.sessionId = progress.sessionId;
1459
+ creator.status = "completed";
1460
+ creator.parsedPath = join(state.outputDir, "create-pr.json");
1461
+ creator.lastUpdate = now();
1462
+ }
1463
+ if (progress.type === "triage_creator_failed") {
1464
+ const creator = creatorState();
1465
+ creator.status = "failed";
1466
+ creator.error = redactSecrets(progress.error);
1467
+ creator.lastUpdate = now();
1468
+ }
1469
+ await this.persist(state);
1470
+ if (progress.type === "phase") {
1471
+ await this.notify(state, `Triage phase for ${issue}: ${progress.phase}.`);
1472
+ }
1473
+ if (progress.type === "decision") {
1474
+ await this.notify(state, triageDecisionNotification({
1475
+ action: progress.action,
1476
+ issue,
1477
+ result: JSON.stringify(progress.result),
1478
+ }));
1479
+ }
1480
+ if (progress.type === "triage_agent_started") {
1481
+ await this.notify(state, `**Triage agent ${progress.reviewer}** started ${progress.phase} for ${issue}.`);
1482
+ }
1483
+ if (progress.type === "triage_agent_repair") {
1484
+ await this.notify(state, `**Triage agent ${progress.reviewer}** started JSON regeneration for ${issue}.`);
1485
+ }
1486
+ if (progress.type === "triage_agent_completed") {
1487
+ await this.notify(state, `**Triage agent ${progress.reviewer}** completed ${progress.phase} for ${issue}: ${progress.vote}.`);
1488
+ }
1489
+ if (progress.type === "triage_agent_failed") {
1490
+ await this.notify(state, `**Triage agent ${progress.reviewer}** failed ${progress.phase} for ${issue}: ${redactSecrets(progress.error)}`);
1491
+ }
1492
+ if (progress.type === "comment_posting") {
1493
+ await this.notify(state, `Posting triage comment for ${issue}.`);
1494
+ }
1495
+ if (progress.type === "comment_posted") {
1496
+ await this.notify(state, `Posted triage comment for ${issue}: ${progress.url}`);
1497
+ }
1498
+ if (progress.type === "pr_creation_started") {
1499
+ await this.notify(state, `Started implementation PR creation for ${issue}.`);
1500
+ }
1501
+ if (progress.type === "worktree_created") {
1502
+ await this.notify(state, `Worktree is ready for ${issue}.`);
1503
+ }
1504
+ if (progress.type === "triage_creator_started") {
1505
+ await this.notify(state, `**Triage creator** started creating an implementation PR for ${issue}.`);
1506
+ }
1507
+ if (progress.type === "triage_creator_repair") {
1508
+ await this.notify(state, `**Triage creator** started JSON regeneration for ${issue}.`);
1509
+ }
1510
+ if (progress.type === "triage_creator_completed") {
1511
+ await this.notify(state, `**Triage creator** completed implementation changes for ${issue}.`);
1512
+ }
1513
+ if (progress.type === "triage_creator_failed") {
1514
+ await this.notify(state, triageCreatorFailureText({
1515
+ error: redactSecrets(progress.error),
1516
+ issue,
1517
+ repairAttempts: state.triageCreator?.repairAttempts ?? 0,
1518
+ }));
1519
+ }
1520
+ if (progress.type === "pr_created") {
1521
+ await this.notify(state, `Created implementation PR for ${issue}: ${progress.url}`);
1522
+ }
1523
+ }
1296
1524
  async applyReviewProgress(runId, progress) {
1297
1525
  const state = this.active.get(runId);
1298
1526
  if (!state)
@@ -6,7 +6,7 @@ import { assignIssue, closeIssue, closePullRequest, configureGitIdentity, create
6
6
  import { composeTriageAcceptancePrompt, composeTriageActionPrompt, composeTriageCategoryPrompt, composeTriageCommentClassificationPrompt, composeTriageCommentPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageQuestionPrompt, composeTriageReconsiderPrompt, } from "../prompts/compose";
7
7
  import { parseTriageActionOutput, parseTriageBinaryOutput, parseTriageCategoryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, } from "../prompts/output";
8
8
  import { aggregateStringMajority, majorityThreshold } from "./majority";
9
- import { runModelText, runModelWithRepair } from "./model";
9
+ import { runModelText, runModelWithRepair, } from "./model";
10
10
  const MARKER_PREFIX = "opencode-magi:triage";
11
11
  const BINARY_VOTES = ["ASK", "NO", "YES"];
12
12
  const DUPLICATE_VOTES = ["DUPLICATE", "NOT_DUPLICATE"];
@@ -98,6 +98,35 @@ function issueContext(input) {
98
98
  async function writeJson(path, value) {
99
99
  await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
100
100
  }
101
+ async function emitProgress(input, progress) {
102
+ await input.onProgress?.(progress);
103
+ }
104
+ async function emitTriageModelProgress(input) {
105
+ if (input.progress.type === "session_created") {
106
+ await emitProgress(input.run, {
107
+ options: input.progress.options,
108
+ phase: input.phase,
109
+ reviewer: input.reviewer,
110
+ sessionId: input.progress.sessionId,
111
+ type: "triage_agent_session",
112
+ });
113
+ }
114
+ if (input.progress.type === "repair") {
115
+ await emitProgress(input.run, {
116
+ phase: input.phase,
117
+ reviewer: input.reviewer,
118
+ type: "triage_agent_repair",
119
+ });
120
+ }
121
+ if (input.progress.type === "response") {
122
+ await emitProgress(input.run, {
123
+ phase: input.phase,
124
+ reviewer: input.reviewer,
125
+ sessionId: input.progress.sessionId,
126
+ type: "triage_agent_response",
127
+ });
128
+ }
129
+ }
101
130
  async function runVote(input) {
102
131
  const prompt = await input.prompt({
103
132
  context: input.context,
@@ -106,17 +135,47 @@ async function runVote(input) {
106
135
  repository: input.repository,
107
136
  reviewer: input.agent,
108
137
  });
109
- const result = await runModelWithRepair({
110
- client: input.client,
111
- model: input.agent.model,
112
- options: input.agent.options,
113
- parse: input.parse,
114
- permission: input.agent.permission,
115
- prompt,
116
- repairAttempts: 3,
117
- schemaName: input.schemaName,
118
- signal: input.signal,
119
- title: `Magi triage ${input.schemaName} #${input.issue} (${input.agent.key})`,
138
+ await emitProgress(input.run, {
139
+ phase: input.phase,
140
+ reviewer: input.agent.key,
141
+ type: "triage_agent_started",
142
+ });
143
+ let result;
144
+ try {
145
+ result = await runModelWithRepair({
146
+ client: input.client,
147
+ model: input.agent.model,
148
+ onProgress: (progress) => emitTriageModelProgress({
149
+ phase: input.phase,
150
+ progress,
151
+ reviewer: input.agent.key,
152
+ run: input.run,
153
+ }),
154
+ options: input.agent.options,
155
+ parse: input.parse,
156
+ permission: input.agent.permission,
157
+ prompt,
158
+ repairAttempts: 3,
159
+ schemaName: input.schemaName,
160
+ signal: input.signal,
161
+ title: `Magi triage ${input.schemaName} #${input.issue} (${input.agent.key})`,
162
+ });
163
+ }
164
+ catch (error) {
165
+ await emitProgress(input.run, {
166
+ error: error instanceof Error ? error.message : String(error),
167
+ phase: input.phase,
168
+ reviewer: input.agent.key,
169
+ type: "triage_agent_failed",
170
+ });
171
+ throw error;
172
+ }
173
+ await emitProgress(input.run, {
174
+ phase: input.phase,
175
+ reviewer: input.agent.key,
176
+ sessionId: result.sessionId,
177
+ type: "triage_agent_completed",
178
+ vote: result.value.vote,
120
179
  });
121
180
  return {
122
181
  ...result.value,
@@ -155,6 +214,7 @@ async function runDuplicateVote(input) {
155
214
  const agents = input.input.repository.agents.triage;
156
215
  if (!agents?.length)
157
216
  throw new Error("triage.agents is required");
217
+ await emitProgress(input.input, { phase: "duplicate", type: "phase" });
158
218
  const outputs = await Promise.all(agents.map((agent) => runVote({
159
219
  agent,
160
220
  client: input.input.client,
@@ -162,8 +222,10 @@ async function runDuplicateVote(input) {
162
222
  directory: input.input.directory,
163
223
  issue: input.input.issue,
164
224
  parse: parseTriageDuplicateOutput,
225
+ phase: "duplicate",
165
226
  prompt: composeTriageDuplicatePrompt,
166
227
  repository: input.input.repository,
228
+ run: input.input,
167
229
  schemaName: "triage duplicate",
168
230
  signal: input.input.signal,
169
231
  })));
@@ -194,6 +256,7 @@ async function runPhaseVote(input) {
194
256
  const agents = input.input.repository.agents.triage;
195
257
  if (!agents?.length)
196
258
  throw new Error("triage.agents is required");
259
+ await emitProgress(input.input, { phase: input.phase, type: "phase" });
197
260
  const outputs = await Promise.all(agents.map((agent) => runVote({
198
261
  agent,
199
262
  client: input.input.client,
@@ -201,8 +264,10 @@ async function runPhaseVote(input) {
201
264
  directory: input.input.directory,
202
265
  issue: input.input.issue,
203
266
  parse: input.parse,
267
+ phase: input.phase,
204
268
  prompt: input.prompt,
205
269
  repository: input.input.repository,
270
+ run: input.input,
206
271
  schemaName: input.schemaName,
207
272
  signal: input.input.signal,
208
273
  })));
@@ -544,6 +609,11 @@ async function finishWithResult(input) {
544
609
  if (!triage)
545
610
  throw new Error("triage configuration is required");
546
611
  const plan = input.plan ?? actionPlan({ result: input.result, triage });
612
+ await emitProgress(input.input, {
613
+ action: plan.action,
614
+ result: input.result,
615
+ type: "decision",
616
+ });
547
617
  await runActionPrompt({
548
618
  context: input.context,
549
619
  input: input.input,
@@ -565,7 +635,8 @@ async function finishWithResult(input) {
565
635
  : undefined;
566
636
  if (!input.input.dryRun) {
567
637
  if (comment) {
568
- await postMarkedIssueComment({
638
+ await emitProgress(input.input, { type: "comment_posting" });
639
+ const posted = await postMarkedIssueComment({
569
640
  account: triage.account ?? "",
570
641
  body: comment,
571
642
  exec: input.input.exec,
@@ -573,6 +644,10 @@ async function finishWithResult(input) {
573
644
  outputDir: input.outputDir,
574
645
  repository: input.input.repository,
575
646
  });
647
+ await emitProgress(input.input, {
648
+ type: "comment_posted",
649
+ url: posted.url,
650
+ });
576
651
  }
577
652
  if (plan.clearLabels) {
578
653
  const clearLabels = existingClearLabels(input.issue, triage.automation.clear);
@@ -597,8 +672,10 @@ async function finishWithResult(input) {
597
672
  issue: input.issue,
598
673
  outputDir: input.outputDir,
599
674
  });
600
- if (prUrl)
675
+ if (prUrl) {
601
676
  await writeJson(join(input.outputDir, "pr.json"), { url: prUrl });
677
+ await emitProgress(input.input, { type: "pr_created", url: prUrl });
678
+ }
602
679
  }
603
680
  if (input.previousMarker && prUrl) {
604
681
  await persistProcessedMarker({
@@ -659,48 +736,89 @@ async function createImplementationPr(input) {
659
736
  const triage = input.input.repository.triage;
660
737
  if (!triage?.account)
661
738
  throw new Error("triage.account is required");
662
- await assignIssue(input.input.exec, input.input.repository, input.issue.number, triage.account);
663
- const branch = `magi/issue-${input.issue.number}-${Date.now().toString(36)}`;
664
- const worktreePath = join(worktreeBaseDir(input.input.directory, input.input.config, "issue"), `issue-${input.issue.number}`);
665
- await mkdir(dirname(worktreePath), { recursive: true });
666
- await input.input.exec(`git worktree add -b ${shellQuote(branch)} ${shellQuote(worktreePath)}`);
739
+ await emitProgress(input.input, { type: "pr_creation_started" });
740
+ await emitProgress(input.input, { type: "triage_creator_started" });
667
741
  try {
668
- await configureGitIdentity(input.input.exec, worktreePath, creator.author);
669
- const prompt = await composeTriageCreatePrPrompt({
670
- context: input.context,
671
- directory: input.input.directory,
672
- issue: input.issue.number,
673
- repository: input.input.repository,
742
+ await assignIssue(input.input.exec, input.input.repository, input.issue.number, triage.account);
743
+ const branch = `magi/issue-${input.issue.number}-${Date.now().toString(36)}`;
744
+ const worktreePath = join(worktreeBaseDir(input.input.directory, input.input.config, "issue"), `issue-${input.issue.number}`);
745
+ await mkdir(dirname(worktreePath), { recursive: true });
746
+ await input.input.exec(`git worktree add -b ${shellQuote(branch)} ${shellQuote(worktreePath)}`);
747
+ await emitProgress(input.input, {
748
+ branch,
749
+ type: "worktree_created",
674
750
  worktreePath,
675
751
  });
676
- const result = await runModelWithRepair({
677
- client: input.input.client,
678
- model: creator.model,
679
- options: creator.options,
680
- parse: parseTriageCreatePrOutput,
681
- permission: creator.permission,
682
- prompt,
683
- repairAttempts: 3,
684
- schemaName: "edit",
685
- signal: input.input.signal,
686
- title: `Magi triage create PR #${input.issue.number}`,
687
- });
688
- await writeJson(join(input.outputDir, "create-pr.json"), result.value);
689
- if (result.value.mode !== "EDITED")
690
- return undefined;
691
- await pushHead(input.input.exec, input.input.repository, worktreePath, creator.account, {
692
- owner: input.input.repository.github.owner,
693
- ref: branch,
694
- repo: input.input.repository.github.repo,
695
- });
696
- return createPullRequest(input.input.exec, input.input.repository, creator.account, {
697
- body: `Closes #${input.issue.number}`,
698
- head: branch,
699
- title: `fix: address issue #${input.issue.number}`,
700
- });
752
+ try {
753
+ await configureGitIdentity(input.input.exec, worktreePath, creator.author);
754
+ const prompt = await composeTriageCreatePrPrompt({
755
+ context: input.context,
756
+ directory: input.input.directory,
757
+ issue: input.issue.number,
758
+ repository: input.input.repository,
759
+ worktreePath,
760
+ });
761
+ const result = await runModelWithRepair({
762
+ client: input.input.client,
763
+ model: creator.model,
764
+ onProgress: async (progress) => {
765
+ if (progress.type === "session_created") {
766
+ await emitProgress(input.input, {
767
+ options: progress.options,
768
+ sessionId: progress.sessionId,
769
+ type: "triage_creator_session",
770
+ });
771
+ }
772
+ if (progress.type === "repair") {
773
+ await emitProgress(input.input, { type: "triage_creator_repair" });
774
+ }
775
+ if (progress.type === "response") {
776
+ await emitProgress(input.input, {
777
+ sessionId: progress.sessionId,
778
+ type: "triage_creator_response",
779
+ });
780
+ }
781
+ },
782
+ options: creator.options,
783
+ parse: parseTriageCreatePrOutput,
784
+ permission: creator.permission,
785
+ prompt,
786
+ repairAttempts: 3,
787
+ schemaName: "triage create PR",
788
+ signal: input.input.signal,
789
+ title: `Magi triage create PR #${input.issue.number}`,
790
+ });
791
+ await emitProgress(input.input, {
792
+ sessionId: result.sessionId,
793
+ type: "triage_creator_completed",
794
+ });
795
+ await writeJson(join(input.outputDir, "create-pr.json"), result.value);
796
+ if (result.value.mode !== "EDITED")
797
+ return undefined;
798
+ const pullRequest = result.value.pullRequest;
799
+ if (!pullRequest)
800
+ throw new Error("EDITED requires pullRequest");
801
+ await pushHead(input.input.exec, input.input.repository, worktreePath, creator.account, {
802
+ owner: input.input.repository.github.owner,
803
+ ref: branch,
804
+ repo: input.input.repository.github.repo,
805
+ });
806
+ return createPullRequest(input.input.exec, input.input.repository, creator.account, {
807
+ body: pullRequest.body,
808
+ head: branch,
809
+ title: pullRequest.title,
810
+ });
811
+ }
812
+ finally {
813
+ await removeWorktree(input.input.exec, worktreePath).catch(() => undefined);
814
+ }
701
815
  }
702
- finally {
703
- await removeWorktree(input.input.exec, worktreePath).catch(() => undefined);
816
+ catch (error) {
817
+ await emitProgress(input.input, {
818
+ error: error instanceof Error ? error.message : String(error),
819
+ type: "triage_creator_failed",
820
+ });
821
+ throw error;
704
822
  }
705
823
  }
706
824
  export async function runTriage(input) {
@@ -718,7 +836,12 @@ export async function runTriage(input) {
718
836
  runId,
719
837
  });
720
838
  await mkdir(outputDir, { recursive: true });
839
+ await emitProgress(input, { phase: "fetching issue", type: "phase" });
721
840
  const issue = await fetchIssue(input.exec, input.repository, input.issue);
841
+ await emitProgress(input, {
842
+ phase: "scanning issue relationships",
843
+ type: "phase",
844
+ });
722
845
  const relationship = await relationshipScan(input, issue);
723
846
  const block = safetyBlocked(input, issue, Boolean(relationship.previousMarker));
724
847
  await writeJson(join(outputDir, "issue.json"), issue);
@@ -738,6 +861,7 @@ export async function runTriage(input) {
738
861
  }
739
862
  let context = issueContext({ issue, relationship });
740
863
  await writeFile(join(outputDir, "context.md"), `${context}\n`);
864
+ await emitProgress(input, { phase: "triaging", type: "phase" });
741
865
  let processed = relationship.previousMarker?.processed ?? [];
742
866
  let result;
743
867
  if (relationship.previousMarker) {
@@ -848,6 +972,11 @@ export async function runTriage(input) {
848
972
  createPr: false,
849
973
  postComment: true,
850
974
  };
975
+ await emitProgress(input, {
976
+ action: plan.action,
977
+ result: relatedPrDecision,
978
+ type: "decision",
979
+ });
851
980
  await runActionPrompt({
852
981
  context,
853
982
  input,
@@ -865,7 +994,8 @@ export async function runTriage(input) {
865
994
  result: relatedPrDecision,
866
995
  });
867
996
  if (!input.dryRun) {
868
- await postMarkedIssueComment({
997
+ await emitProgress(input, { type: "comment_posting" });
998
+ const posted = await postMarkedIssueComment({
869
999
  account: triage.account,
870
1000
  body,
871
1001
  exec: input.exec,
@@ -873,6 +1003,7 @@ export async function runTriage(input) {
873
1003
  outputDir,
874
1004
  repository: input.repository,
875
1005
  });
1006
+ await emitProgress(input, { type: "comment_posted", url: posted.url });
876
1007
  const clearLabels = existingClearLabels(issue, triage.automation.clear);
877
1008
  if (clearLabels.length) {
878
1009
  await removeIssueLabels(input.exec, input.repository, issue.number, clearLabels, triage.account);
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join } from "node:path";
4
- import { ciClassificationAfterEditOutputContract, ciClassificationOutputContract, closeReconsiderationOutputContract, editOutputContract, findingValidationOutputContract, rereviewCloseReconsiderationOutputContract, rereviewOutputContract, reviewOutputContract, triageCommentClassificationOutputContract, triageActionOutputContract, triageDuplicateOutputContract, triageVoteOutputContract, } from "./contracts";
4
+ import { ciClassificationAfterEditOutputContract, ciClassificationOutputContract, closeReconsiderationOutputContract, editOutputContract, findingValidationOutputContract, rereviewCloseReconsiderationOutputContract, rereviewOutputContract, reviewOutputContract, triageActionOutputContract, triageCommentClassificationOutputContract, triageCreatePrOutputContract, triageDuplicateOutputContract, triageVoteOutputContract, } from "./contracts";
5
5
  async function readOptionalPrompt(directory, path, values = {}) {
6
6
  if (!path)
7
7
  return "";
@@ -387,7 +387,7 @@ export async function composeTriageCreatePrPrompt(input) {
387
387
  task,
388
388
  languageBlock(input.repository.language),
389
389
  personaBlock(persona),
390
- editOutputContract,
390
+ triageCreatePrOutputContract,
391
391
  ]
392
392
  .filter(Boolean)
393
393
  .join("\n\n");
@@ -148,6 +148,32 @@ Rules:
148
148
  - responses must include a reply for each thread you addressed.
149
149
  - REPLIED requires filesTouched to be empty and at least one DISAGREE or ASK response.
150
150
  </output_contract>`.trim();
151
+ export const triageCreatePrOutputContract = `
152
+ <output_contract>
153
+ Return exactly one JSON object and nothing else. Do not wrap it in markdown.
154
+
155
+ The object must match this shape:
156
+ {
157
+ "mode": "EDITED" | "REPLIED",
158
+ "commitSha": "full sha, required only when mode is EDITED; omit when mode is REPLIED",
159
+ "commitMessage": "fix(scope): short description, required only when mode is EDITED; omit when mode is REPLIED",
160
+ "filesTouched": ["relative/path.ext"],
161
+ "pullRequest": {
162
+ "title": "PR title, required only when mode is EDITED; omit when mode is REPLIED",
163
+ "body": "PR body, required only when mode is EDITED; omit when mode is REPLIED"
164
+ },
165
+ "responses": [{ "commentId": 123, "action": "FIXED" | "DISAGREE" | "ASK", "body": "Fixed." }]
166
+ }
167
+
168
+ Rules:
169
+ - Use EDITED only when you edited files, staged changes, and committed.
170
+ - Use REPLIED when you only replied without code changes.
171
+ - For EDITED, pullRequest.title and pullRequest.body must be non-empty and follow the repository's PR conventions.
172
+ - Do not push or create the PR. The orchestrator pushes and creates the PR using pullRequest exactly as provided.
173
+ - filesTouched must include every final changed file.
174
+ - responses may be empty when no review threads were addressed.
175
+ - REPLIED requires filesTouched to be empty and at least one DISAGREE or ASK response.
176
+ </output_contract>`.trim();
151
177
  export const ciClassificationOutputContract = `
152
178
  <output_contract>
153
179
  Return exactly one JSON object and nothing else. Do not wrap it in markdown.
@@ -255,6 +281,7 @@ const outputContractsBySchemaName = {
255
281
  "triage action": triageActionOutputContract,
256
282
  "triage acceptance": triageVoteOutputContract('"YES" | "NO" | "ASK"'),
257
283
  "triage category": triageVoteOutputContract('"ASK" or one of the configured category IDs'),
284
+ "triage create PR": triageCreatePrOutputContract,
258
285
  "triage comment classification": triageCommentClassificationOutputContract,
259
286
  "triage duplicate": triageDuplicateOutputContract,
260
287
  "triage existing PR": triageVoteOutputContract('"RELATED_PR_HANDLES_ISSUE" | "RELATED_PR_DOES_NOT_HANDLE_ISSUE"'),
@@ -317,6 +317,20 @@ export function parseCiClassificationOutput(text) {
317
317
  }),
318
318
  };
319
319
  }
320
+ function parsePullRequest(value, options) {
321
+ if (value == null) {
322
+ if (options.required)
323
+ throw new Error("pullRequest is required");
324
+ return undefined;
325
+ }
326
+ if (typeof value !== "object")
327
+ throw new Error("pullRequest must be an object");
328
+ const pullRequest = value;
329
+ return {
330
+ body: requireString(pullRequest.body, "pullRequest.body"),
331
+ title: requireString(pullRequest.title, "pullRequest.title"),
332
+ };
333
+ }
320
334
  function parseEditOutputWithOptions(text, options) {
321
335
  const data = extractJson(text);
322
336
  if (!data || typeof data !== "object")
@@ -340,11 +354,15 @@ function parseEditOutputWithOptions(text, options) {
340
354
  if (data.mode === "EDITED") {
341
355
  if (!filesTouched.length)
342
356
  throw new Error("EDITED requires filesTouched");
357
+ const pullRequest = parsePullRequest(data.pullRequest, {
358
+ required: options.requirePullRequest,
359
+ });
343
360
  return {
344
361
  commitMessage: requireString(data.commitMessage, "commitMessage"),
345
362
  commitSha: requireString(data.commitSha, "commitSha"),
346
363
  filesTouched,
347
364
  mode: data.mode,
365
+ ...(pullRequest ? { pullRequest } : {}),
348
366
  responses,
349
367
  };
350
368
  }
@@ -364,8 +382,14 @@ function parseEditOutputWithOptions(text, options) {
364
382
  };
365
383
  }
366
384
  export function parseEditOutput(text) {
367
- return parseEditOutputWithOptions(text, { requireResponses: true });
385
+ return parseEditOutputWithOptions(text, {
386
+ requirePullRequest: false,
387
+ requireResponses: true,
388
+ });
368
389
  }
369
390
  export function parseTriageCreatePrOutput(text) {
370
- return parseEditOutputWithOptions(text, { requireResponses: false });
391
+ return parseEditOutputWithOptions(text, {
392
+ requirePullRequest: true,
393
+ requireResponses: false,
394
+ });
371
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260520175008",
3
+ "version": "0.0.0-dev-20260520182750",
4
4
  "description": "Multi-agent PR review and merge orchestration plugin for OpenCode.",
5
5
  "license": "MIT",
6
6
  "author": "Hirotomo Yamada <hirotomo.yamada@avap.co.jp>",