opencode-magi 0.0.0-dev-20260721051539 → 0.0.0-dev-20260722060754

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/magi.js CHANGED
@@ -217,27 +217,69 @@ export class Magi {
217
217
  }
218
218
  }
219
219
  async promptSession(sessionID, text, signal) {
220
- const result = await this.input.client.session.prompt({
221
- parts: [{ text, type: "text" }],
222
- sessionID,
223
- }, { signal });
224
- if (result.error) {
225
- if (!isUndefined(result.response) && result.response.statusText)
226
- throw new Error(result.response.statusText);
227
- else if (result.error instanceof Error)
228
- throw new Error(result.error.message);
229
- else
230
- throw new Error(JSON.stringify(result.error));
220
+ const controller = new AbortController();
221
+ const events = await this.input.client.event.subscribe(undefined, {
222
+ signal: controller.signal,
223
+ });
224
+ try {
225
+ const result = await Promise.race([
226
+ this.input.client.session.prompt({
227
+ parts: [{ text, type: "text" }],
228
+ sessionID,
229
+ }, { signal }),
230
+ this.monitorSession(sessionID, events.stream),
231
+ ]);
232
+ if (result.error) {
233
+ if (!isUndefined(result.response) && result.response.statusText)
234
+ throw new Error(result.response.statusText);
235
+ else if (result.error instanceof Error)
236
+ throw new Error(result.error.message);
237
+ else
238
+ throw new Error(JSON.stringify(result.error));
239
+ }
240
+ else {
241
+ const output = result.data.parts
242
+ .filter((part) => part.type === "text")
243
+ .map((part) => part.text)
244
+ .join("\n");
245
+ if (!output)
246
+ throw new Error("OpenCode session.prompt did not return text output.");
247
+ return output;
248
+ }
231
249
  }
232
- else {
233
- const output = result.data.parts
234
- .filter((part) => part.type === "text")
235
- .map((part) => part.text)
236
- .join("\n");
237
- if (!output)
238
- throw new Error("OpenCode session.prompt did not return text output.");
239
- return output;
250
+ catch (e) {
251
+ if (e instanceof MagiError)
252
+ await this.input.client.session.abort({ sessionID });
253
+ throw e;
254
+ }
255
+ finally {
256
+ controller.abort();
257
+ }
258
+ }
259
+ async monitorSession(sessionID, events) {
260
+ for await (const ev of events) {
261
+ if (ev.type === "permission.asked" &&
262
+ ev.properties.sessionID === sessionID) {
263
+ const { id, patterns, permission } = ev.properties;
264
+ const result = await this.input.client.permission.reply({
265
+ reply: "reject",
266
+ requestID: id,
267
+ });
268
+ if (result.error)
269
+ throw new MagiError("blocked", "Could not reject permission request.");
270
+ throw new MagiError("blocked", `OpenCode session requested ${permission} permission for ${patterns.join(", ")}.`);
271
+ }
272
+ if (ev.type === "question.asked" &&
273
+ ev.properties.sessionID === sessionID) {
274
+ const result = await this.input.client.question.reject({
275
+ requestID: ev.properties.id,
276
+ });
277
+ if (result.error)
278
+ throw new MagiError("blocked", "Could not reject user question.");
279
+ throw new MagiError("blocked", "OpenCode session requested user input.");
280
+ }
240
281
  }
282
+ return new Promise(() => { });
241
283
  }
242
284
  async deleteSessions(state) {
243
285
  const sessionIds = this.getSessionIds(state);
@@ -1,7 +1,7 @@
1
1
  import Ajv from "ajv";
2
2
  import { readFile } from "fs/promises";
3
3
  import { join } from "path";
4
- import { isString } from "@/utils";
4
+ import { isString, isUndefined } from "@/utils";
5
5
  export class Prompt {
6
6
  magi;
7
7
  config;
@@ -43,11 +43,16 @@ export class Prompt {
43
43
  ].join("\n\n");
44
44
  return Object.entries(values).reduce((prev, [key, value]) => prev.replaceAll(`{${key}}`, value), content);
45
45
  }
46
- async repair() {
46
+ async repair(e) {
47
47
  const instructions = [
48
- "Your previous output did not match the required schema. Regenerate the result.",
48
+ "Your previous output failed validation. Regenerate the result.",
49
+ !isUndefined(e)
50
+ ? `Validation error: ${e instanceof Error ? e.message : "Invalid output."}`
51
+ : undefined,
49
52
  "Return only a JSON object matching the output contract below. Do not include analysis, explanation, apologies, markdown, or any text before or after the JSON object.",
50
- ].join("\n\n");
53
+ ]
54
+ .filter((instruction) => !isUndefined(instruction))
55
+ .join("\n\n");
51
56
  try {
52
57
  const outputContract = await readFile(join(this.pathname, "output-contract.md"), "utf-8");
53
58
  return [
@@ -1,10 +1,10 @@
1
1
  import { MagiError } from "@/magi";
2
2
  import { retry } from "@/utils";
3
- export async function editCycles(callback) {
3
+ export async function editCycles(cb) {
4
4
  this.context.abort.throwIfAborted();
5
5
  const error = new Error("Continue edit cycle.");
6
6
  const verdict = await retry(async (cycle) => {
7
- const verdict = await callback(cycle);
7
+ const verdict = await cb(cycle);
8
8
  if (verdict === "CHANGES_REQUESTED")
9
9
  throw error;
10
10
  return verdict;
@@ -23,9 +23,8 @@ export async function edit() {
23
23
  repo: this.config.github.repo,
24
24
  worktreePath: this.state.worktree.path,
25
25
  });
26
- const repairMessage = await prompt.repair();
27
- const output = await retry(async (count) => {
28
- const raw = await this.magi.promptSession(this.state.editor.sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
26
+ const output = await retry(async (count, e) => {
27
+ const raw = await this.magi.promptSession(this.state.editor.sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
29
28
  await this.createAgentFile("edit", "editor", raw, count, cycle);
30
29
  const parsed = prompt.parse(raw);
31
30
  if (!prompt.validate(parsed))
@@ -44,7 +43,11 @@ export async function edit() {
44
43
  }
45
44
  return output;
46
45
  }, {
47
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to edit. Retrying...`),
46
+ error: async (e, count) => {
47
+ if (e instanceof MagiError)
48
+ throw e;
49
+ await this.updateEvent(`Attempt ${count} failed to edit. Retrying...`);
50
+ },
48
51
  retries: this.config.output.repairAttempts,
49
52
  signal: this.context.abort,
50
53
  });
@@ -111,9 +114,8 @@ export async function resolveConflict() {
111
114
  repo: this.config.github.repo,
112
115
  worktreePath: this.state.worktree.path,
113
116
  });
114
- const repairMessage = await prompt.repair();
115
- const output = await retry(async (count) => {
116
- const raw = await this.magi.promptSession(this.state.editor.sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
117
+ const output = await retry(async (count, e) => {
118
+ const raw = await this.magi.promptSession(this.state.editor.sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
117
119
  await this.createAgentFile("conflict", "editor", raw, count, cycle);
118
120
  const parsed = prompt.parse(raw);
119
121
  if (!prompt.validate(parsed))
@@ -136,7 +138,11 @@ export async function resolveConflict() {
136
138
  responses: [],
137
139
  };
138
140
  }, {
139
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to resolve conflicts. Retrying...`),
141
+ error: async (e, count) => {
142
+ if (e instanceof MagiError)
143
+ throw e;
144
+ await this.updateEvent(`Attempt ${count} failed to resolve conflicts. Retrying...`);
145
+ },
140
146
  retries: this.config.output.repairAttempts,
141
147
  signal: this.context.abort,
142
148
  });
@@ -78,16 +78,19 @@ export async function postReviews() {
78
78
  repo: this.config.github.repo,
79
79
  verdict: this.state.pr.verdict,
80
80
  });
81
- const repairMessage = await prompt.repair();
82
- const output = await retry(async (count) => {
83
- const raw = await this.magi.promptSession(this.state.operator.sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
81
+ const output = await retry(async (count, e) => {
82
+ const raw = await this.magi.promptSession(this.state.operator.sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
84
83
  await this.createAgentFile("comment", "operator", raw, count);
85
84
  const parsed = prompt.parse(raw);
86
85
  if (!prompt.validate(parsed))
87
86
  throw new Error("Invalid output for operator.");
88
87
  return parsed.comment;
89
88
  }, {
90
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to post comment by operator. Retrying...`),
89
+ error: async (e, count) => {
90
+ if (e instanceof MagiError)
91
+ throw e;
92
+ await this.updateEvent(`Attempt ${count} failed to post comment by operator. Retrying...`);
93
+ },
91
94
  retries: this.config.output.repairAttempts,
92
95
  signal: this.context.abort,
93
96
  });
@@ -101,20 +101,23 @@ export async function classifyChecks() {
101
101
  repo: this.config.github.repo,
102
102
  worktreePath: this.state.worktree.path,
103
103
  });
104
- const repairMessage = await prompt.repair();
105
104
  await Promise.all(Object.entries(this.state.reviewers ?? {}).map(([id, { sessionId }]) => worker.run(async () => {
106
105
  if (!sessionId)
107
106
  throw new Error(`No session ID found for reviewer ${id}.`);
108
107
  await this.updateEvent(`Classifying CI checks with reviewer ${id}.`);
109
- const output = await retry(async (count) => {
110
- const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
108
+ const output = await retry(async (count, e) => {
109
+ const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
111
110
  await this.createAgentFile("ci-classification", id, raw, count);
112
111
  const parsed = prompt.parse(raw);
113
112
  if (!prompt.validate(parsed))
114
113
  throw new Error(`Invalid output for reviewer ${id}.`);
115
114
  return parsed;
116
115
  }, {
117
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to classify CI checks with reviewer ${id}. Retrying...`),
116
+ error: async (e, count) => {
117
+ if (e instanceof MagiError)
118
+ throw e;
119
+ await this.updateEvent(`Attempt ${count} failed to classify CI checks with reviewer ${id}. Retrying...`);
120
+ },
118
121
  retries: this.config.output.repairAttempts,
119
122
  signal: this.context.abort,
120
123
  });
@@ -70,10 +70,10 @@ export async function review() {
70
70
  }), null, 2);
71
71
  tags.push(["previous_review", previousReviewContext]);
72
72
  }
73
- if (unresolvedThreads.length) {
74
- const unresolvedThreadsContext = JSON.stringify(unresolvedThreads, null, 2);
75
- tags.push(["unresolved_threads", unresolvedThreadsContext]);
76
- }
73
+ tags.push([
74
+ "unresolved_threads",
75
+ JSON.stringify(unresolvedThreads, null, 2),
76
+ ]);
77
77
  if (failedChecks.length) {
78
78
  const ciFailureContext = JSON.stringify(failedChecks, null, 2);
79
79
  tags.push(["ci_failure", ciFailureContext]);
@@ -94,9 +94,8 @@ export async function review() {
94
94
  repo: this.config.github.repo,
95
95
  worktreePath: this.state.worktree.path,
96
96
  }));
97
- const repairMessage = await prompt.repair();
98
- const output = await retry(async (count) => {
99
- const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
97
+ const output = await retry(async (count, e) => {
98
+ const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
100
99
  await this.createAgentFile(rereview ? "rereview" : "review", id, raw, count, cycle);
101
100
  const parsed = prompt.parse(raw);
102
101
  if (!prompt.validate(parsed))
@@ -126,7 +125,11 @@ export async function review() {
126
125
  ]).join("\n\n"));
127
126
  return parsed;
128
127
  }, {
129
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to ${label} with reviewer ${id}. Retrying...`),
128
+ error: async (e, count) => {
129
+ if (e instanceof MagiError)
130
+ throw e;
131
+ await this.updateEvent(`Attempt ${count} failed to ${label} with reviewer ${id}. Retrying...`);
132
+ },
130
133
  retries: this.config.output.repairAttempts,
131
134
  signal: this.context.abort,
132
135
  });
@@ -199,10 +202,9 @@ export async function reconsiderClose() {
199
202
  pr: this.number.toString(),
200
203
  repo: this.config.github.repo,
201
204
  });
202
- const repairMessage = await prompt.repair();
203
205
  await this.updateEvent(`Reconsidering close verdict with reviewer ${id}.`);
204
- const output = await retry(async (count) => {
205
- const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
206
+ const output = await retry(async (count, e) => {
207
+ const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
206
208
  await this.createAgentFile("close-reconsideration", id, raw, count, cycle);
207
209
  const parsed = prompt.parse(raw);
208
210
  if (!prompt.validate(parsed))
@@ -210,7 +212,11 @@ export async function reconsiderClose() {
210
212
  validateInlineCommentTargets("initial", parsed, inlineCommentTargets);
211
213
  return parsed;
212
214
  }, {
213
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to reconsider close verdict with reviewer ${id}. Retrying...`),
215
+ error: async (e, count) => {
216
+ if (e instanceof MagiError)
217
+ throw e;
218
+ await this.updateEvent(`Attempt ${count} failed to reconsider close verdict with reviewer ${id}. Retrying...`);
219
+ },
214
220
  retries: this.config.output.repairAttempts,
215
221
  signal: this.context.abort,
216
222
  });
@@ -267,9 +273,8 @@ async function collectAcceptedFindings(findings) {
267
273
  pr: this.number.toString(),
268
274
  repo: this.config.github.repo,
269
275
  });
270
- const repairMessage = await prompt.repair();
271
- const output = await retry(async (count) => {
272
- const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
276
+ const output = await retry(async (count, e) => {
277
+ const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
273
278
  await this.createAgentFile("finding-validation", id, raw, count);
274
279
  const parsed = prompt.parse(raw);
275
280
  if (!prompt.validate(parsed))
@@ -290,7 +295,11 @@ async function collectAcceptedFindings(findings) {
290
295
  throw new Error(`Missing finding vote: ${key}.`);
291
296
  return parsed;
292
297
  }, {
293
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to validate review findings with reviewer ${id}. Retrying...`),
298
+ error: async (e, count) => {
299
+ if (e instanceof MagiError)
300
+ throw e;
301
+ await this.updateEvent(`Attempt ${count} failed to validate review findings with reviewer ${id}. Retrying...`);
302
+ },
294
303
  retries: this.config.output.repairAttempts,
295
304
  signal: this.context.abort,
296
305
  });
@@ -386,12 +395,15 @@ function validateInlineCommentTargets(status, output, inlineCommentTargets) {
386
395
  }
387
396
  function validateThreadTargets({ followUps, resolves }, threads) {
388
397
  const targets = new Map(threads.flatMap(({ comments, id }) => comments.flatMap(({ databaseId }) => databaseId == null ? [] : [[databaseId, id]])));
398
+ const allowedTargets = [...targets.entries()]
399
+ .map(([commentId, threadId]) => `- ${commentId}:${threadId}`)
400
+ .join("\n") || "none";
389
401
  if (followUps)
390
402
  for (const [index, { commentId }] of followUps.entries())
391
403
  if (!targets.has(commentId))
392
- throw new Error(`followUps[${index}].commentId must target an unresolved thread owned by the reviewer.`);
404
+ throw new Error(`followUps[${index}].commentId must target an unresolved thread owned by the reviewer.\n\nAllowed targets:\n${allowedTargets}`);
393
405
  if (resolves)
394
406
  for (const [index, { commentId, threadId }] of resolves.entries())
395
407
  if (targets.get(commentId) !== threadId)
396
- throw new Error(`resolves[${index}] must target an unresolved thread owned by the reviewer.`);
408
+ throw new Error(`resolves[${index}] must target an unresolved thread owned by the reviewer.\n\nAllowed targets:\n${allowedTargets}`);
397
409
  }
@@ -12,24 +12,26 @@ export function wait(ms = 0, signal) {
12
12
  signal?.addEventListener("abort", abort, { once: true });
13
13
  });
14
14
  }
15
- export async function loop(callback, ms = 0) {
15
+ export async function loop(cb, ms = 0) {
16
16
  for (;;) {
17
- const value = await callback();
17
+ const value = await cb();
18
18
  if (value != null)
19
19
  return value;
20
20
  await wait(ms);
21
21
  }
22
22
  }
23
- export async function retry(callback, { error, retries = 1, signal }) {
23
+ export async function retry(cb, { error: errorCb, retries = 1, signal }) {
24
24
  let count = 1;
25
+ let error;
25
26
  while (count <= retries)
26
27
  try {
27
28
  signal?.throwIfAborted();
28
- return await callback(count);
29
+ return await cb(count, error);
29
30
  }
30
31
  catch (e) {
31
32
  signal?.throwIfAborted();
32
- await error?.(e, count);
33
+ await errorCb?.(e, count);
34
+ error = e;
33
35
  count += 1;
34
36
  }
35
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260721051539",
3
+ "version": "0.0.0-dev-20260722060754",
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>",