opencode-magi 0.0.0-dev-20260714091053 → 0.0.0-dev-20260715023915

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
@@ -33,7 +33,7 @@ export class Magi {
33
33
  return this.exec(command("gh", "auth", "token", account && "--user", account && quote(account)), { signal });
34
34
  }
35
35
  async createOctokit(config, signal, account) {
36
- const auth = await this.getGhToken(account);
36
+ const auth = await this.getGhToken(account, signal);
37
37
  const retries = config.github.retryApiAttempts;
38
38
  return new Octokit({
39
39
  auth,
@@ -187,7 +187,7 @@ export class Magi {
187
187
  ...filterEmpty(this.getAgents(state).map(({ sessionId }) => sessionId)),
188
188
  ];
189
189
  }
190
- async createSession(parentID, title, { model, permissions, }) {
190
+ async createSession(parentID, title, { model, permissions, }, signal) {
191
191
  if (isArray(model) || !isObject(model))
192
192
  throw new Error();
193
193
  const { id, variant } = model;
@@ -201,7 +201,7 @@ export class Magi {
201
201
  parentID,
202
202
  permission: resolvePermissions(permissions),
203
203
  title,
204
- });
204
+ }, { signal });
205
205
  if (result.error) {
206
206
  throw new Error(result.response.statusText);
207
207
  }
@@ -210,11 +210,11 @@ export class Magi {
210
210
  return id;
211
211
  }
212
212
  }
213
- async promptSession(sessionID, text) {
213
+ async promptSession(sessionID, text, signal) {
214
214
  const result = await this.input.client.session.prompt({
215
215
  parts: [{ text, type: "text" }],
216
216
  sessionID,
217
- });
217
+ }, { signal });
218
218
  if (result.error) {
219
219
  throw new Error(result.response.statusText);
220
220
  }
@@ -15,6 +15,7 @@ export async function editCycles(callback) {
15
15
  await this.updateEvent(`Attempt ${count} failed to edit cycles. Retrying...`);
16
16
  },
17
17
  retries: this.config.merge.maxThreadResolutionCycles,
18
+ signal: this.context.abort,
18
19
  });
19
20
  if (!verdict || verdict === "CHANGES_REQUESTED")
20
21
  throw new MagiError("blocked", `Reached maximum edit cycles.`);
@@ -25,7 +25,7 @@ export async function edit() {
25
25
  });
26
26
  const repairMessage = await prompt.repair();
27
27
  const output = await retry(async (count) => {
28
- const raw = await this.magi.promptSession(this.state.editor.sessionId, count === 1 ? taskMessage : repairMessage);
28
+ const raw = await this.magi.promptSession(this.state.editor.sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
29
29
  await this.createAgentFile("edit", "editor", raw, count, cycle);
30
30
  const parsed = prompt.parse(raw);
31
31
  if (!prompt.validate(parsed))
@@ -46,6 +46,7 @@ export async function edit() {
46
46
  }, {
47
47
  error: (_, count) => this.updateEvent(`Attempt ${count} failed to edit. Retrying...`),
48
48
  retries: this.config.output.repairAttempts,
49
+ signal: this.context.abort,
49
50
  });
50
51
  if (!output)
51
52
  throw new MagiError("blocked", "Invalid output for editor.");
@@ -112,7 +113,7 @@ export async function resolveConflict() {
112
113
  });
113
114
  const repairMessage = await prompt.repair();
114
115
  const output = await retry(async (count) => {
115
- const raw = await this.magi.promptSession(this.state.editor.sessionId, count === 1 ? taskMessage : repairMessage);
116
+ const raw = await this.magi.promptSession(this.state.editor.sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
116
117
  await this.createAgentFile("conflict", "editor", raw, count, cycle);
117
118
  const parsed = prompt.parse(raw);
118
119
  if (!prompt.validate(parsed))
@@ -137,6 +138,7 @@ export async function resolveConflict() {
137
138
  }, {
138
139
  error: (_, count) => this.updateEvent(`Attempt ${count} failed to resolve conflicts. Retrying...`),
139
140
  retries: this.config.output.repairAttempts,
141
+ signal: this.context.abort,
140
142
  });
141
143
  if (!output)
142
144
  throw new MagiError("blocked", "Invalid output for conflict editor.");
@@ -181,7 +183,7 @@ async function push() {
181
183
  throw new MagiError("blocked", "PR metadata not found.");
182
184
  if (!this.state.worktree)
183
185
  throw new MagiError("blocked", "PR worktree not found.");
184
- const token = await this.magi.getGhToken(this.state.editor.account);
186
+ const token = await this.magi.getGhToken(this.state.editor.account, this.context.abort);
185
187
  const url = `https://${this.config.github.host}/${this.state.pr.metadata.head.repo.owner.login}/${this.state.pr.metadata.head.repo.name}.git`;
186
188
  const ref = `HEAD:refs/heads/${this.state.pr.metadata.head.ref}`;
187
189
  await this.exec(command("git", "push", quote(url), quote(ref)), {
@@ -1,38 +1,21 @@
1
1
  import { join } from "node:path";
2
2
  import { MagiError } from "@/magi";
3
3
  import { Review } from "@/tools/review/review";
4
- import { createExecWithGitHubApiRetry, quote } from "@/utils";
5
4
  import { editCycles, postReplies } from "./action";
6
5
  import { fetchMergeContext, markRepliedReviewers } from "./context";
7
6
  import { edit, resolveConflict } from "./editor";
8
7
  import { createReport } from "./report";
9
8
  export class Merge extends Review {
10
9
  static async init(number, magi, config, context, options) {
11
- const url = `${config.github.url}/pull/${number}`;
12
- const octokit = await magi.createOctokit(config, context.abort);
13
- const graphql = magi.createGraphql(octokit);
14
- const reviewers = Object.fromEntries(config.review.reviewers.map(({ account, id, model, permissions }) => [id, { account, model, permissions }]));
15
- const operator = config.review.operator
16
- ? config.review.reviewers.find(({ id }) => id === config.review.operator)
17
- : config.review.reviewers[Math.abs(number) % config.review.reviewers.length];
10
+ const { exec, graphql, octokit, ...rest } = await this.setup(number, magi, config, context);
18
11
  const editor = {
19
12
  account: config.merge.editor.account,
20
13
  author: config.merge.editor.author,
21
14
  model: config.merge.editor.model,
22
15
  permissions: config.merge.editor.permissions,
23
16
  };
24
- const state = await magi.createState(join(config.review.output, number.toString()), {
25
- ...options,
26
- command: "merge",
27
- editor,
28
- operator,
29
- pr: { number, url },
30
- repo: quote(`${config.github.owner}/${config.github.repo}`),
31
- reviewers,
32
- sessionId: context.sessionID,
33
- });
17
+ const state = await magi.createState(join(config.review.output, number.toString()), { ...options, ...rest, command: "merge", editor });
34
18
  await magi.updateEvent(state.output, `Started merging.`);
35
- const exec = createExecWithGitHubApiRetry(magi.exec, config.github.retryApiAttempts);
36
19
  return new Merge(number, magi, config, context, octokit, graphql, exec, state);
37
20
  }
38
21
  createReport = createReport;
@@ -51,7 +34,7 @@ export class Merge extends Review {
51
34
  sessionId: await this.magi.createSession(this.state.sessionId, `magi merge #${this.number} editor`, {
52
35
  model: this.state.editor.model,
53
36
  permissions: this.state.editor.permissions,
54
- }),
37
+ }, this.context.abort),
55
38
  };
56
39
  await this.updateState({ editor });
57
40
  await this.updateEvent(`Finished creating editor session.`);
@@ -4,6 +4,7 @@ import { MagiError } from "@/magi";
4
4
  import { createCheckContent, createMetaContent, createReviewerContent, } from "@/tools/review/report";
5
5
  import { filterEmpty, toTitleCase } from "@/utils";
6
6
  export async function createReport(e) {
7
+ this.context.abort.throwIfAborted();
7
8
  if (!e) {
8
9
  const status = "completed";
9
10
  const text = await createContent.call(this, { status });
@@ -80,7 +80,7 @@ export async function postReviews() {
80
80
  });
81
81
  const repairMessage = await prompt.repair();
82
82
  const output = await retry(async (count) => {
83
- const raw = await this.magi.promptSession(this.state.operator.sessionId, count === 1 ? taskMessage : repairMessage);
83
+ const raw = await this.magi.promptSession(this.state.operator.sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
84
84
  await this.createAgentFile("comment", "operator", raw, count);
85
85
  const parsed = prompt.parse(raw);
86
86
  if (!prompt.validate(parsed))
@@ -89,6 +89,7 @@ export async function postReviews() {
89
89
  }, {
90
90
  error: (_, count) => this.updateEvent(`Attempt ${count} failed to post comment by operator. Retrying...`),
91
91
  retries: this.config.output.repairAttempts,
92
+ signal: this.context.abort,
92
93
  });
93
94
  if (!output)
94
95
  throw new MagiError("blocked", "Invalid output for operator.");
@@ -206,7 +207,7 @@ export async function automate() {
206
207
  await this.updateEvent(`Skipped ${action} automation during dry run.`);
207
208
  return "SKIPPED";
208
209
  }
209
- const token = await this.magi.getGhToken(account);
210
+ const token = await this.magi.getGhToken(account, this.context.abort);
210
211
  if (action === "merge" && !this.config.review.merge.queue) {
211
212
  const rules = JSON.parse(await this.exec(command("gh", "api", quote(`repos/${this.config.github.owner}/${this.config.github.repo}/rules/branches/${this.state.pr.metadata.base.ref}`)), {
212
213
  env: { GH_TOKEN: token },
@@ -375,7 +376,7 @@ async function waitMergeQueue(leftMergeQueue = false) {
375
376
  }
376
377
  throw new MagiError("blocked", `PR left the merge queue before merging.`);
377
378
  }
378
- await wait(30_000);
379
+ await wait(30_000, this.context.abort);
379
380
  return await waitMergeQueue.call(this, nextLeftMergeQueue);
380
381
  }
381
382
  async function isConflict() {
@@ -107,7 +107,7 @@ export async function classifyChecks() {
107
107
  throw new Error(`No session ID found for reviewer ${id}.`);
108
108
  await this.updateEvent(`Classifying CI checks with reviewer ${id}.`);
109
109
  const output = await retry(async (count) => {
110
- const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage);
110
+ const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
111
111
  await this.createAgentFile("ci-classification", id, raw, count);
112
112
  const parsed = prompt.parse(raw);
113
113
  if (!prompt.validate(parsed))
@@ -116,6 +116,7 @@ export async function classifyChecks() {
116
116
  }, {
117
117
  error: (_, count) => this.updateEvent(`Attempt ${count} failed to classify CI checks with reviewer ${id}. Retrying...`),
118
118
  retries: this.config.output.repairAttempts,
119
+ signal: this.context.abort,
119
120
  });
120
121
  if (!output)
121
122
  throw new MagiError("blocked", `Invalid output for reviewer ${id}.`);
@@ -201,6 +202,7 @@ export async function rerunChecks() {
201
202
  }, {
202
203
  error: (_, count) => this.updateEvent(`Attempt ${count} failed to rerun checks ${label}. Retrying...`),
203
204
  retries: this.config.review.checks.retryFailedJobs,
205
+ signal: this.context.abort,
204
206
  });
205
207
  }
206
208
  const passedChecksAfterRerun = checks.passed.filter((check) => "scope" in check && !check.scope);
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { MagiError } from "@/magi";
4
4
  import { filterEmpty, toTitleCase } from "@/utils";
5
5
  export async function createReport(e) {
6
+ this.context.abort.throwIfAborted();
6
7
  if (!e) {
7
8
  const status = "completed";
8
9
  const text = await createContent.call(this, { status });
@@ -27,6 +27,12 @@ export class Review {
27
27
  this.state = state;
28
28
  }
29
29
  static async init(number, magi, config, context, options) {
30
+ const { exec, graphql, octokit, ...rest } = await this.setup(number, magi, config, context);
31
+ const state = await magi.createState(join(config.review.output, number.toString()), { ...options, ...rest, command: "review" });
32
+ await magi.updateEvent(state.output, `Started reviewing.`);
33
+ return new Review(number, magi, config, context, octokit, graphql, exec, state);
34
+ }
35
+ static async setup(number, magi, config, context) {
30
36
  const url = `${config.github.url}/pull/${number}`;
31
37
  const octokit = await magi.createOctokit(config, context.abort);
32
38
  const graphql = magi.createGraphql(octokit);
@@ -34,18 +40,17 @@ export class Review {
34
40
  const operator = config.review.operator
35
41
  ? config.review.reviewers.find(({ id }) => id === config.review.operator)
36
42
  : config.review.reviewers[Math.abs(number) % config.review.reviewers.length];
37
- const state = await magi.createState(join(config.review.output, number.toString()), {
38
- ...options,
39
- command: "review",
43
+ const exec = createExecWithGitHubApiRetry(magi.exec, config.github.retryApiAttempts);
44
+ return {
45
+ exec,
46
+ graphql,
47
+ octokit,
40
48
  operator,
41
49
  pr: { number, url },
42
50
  repo: quote(`${config.github.owner}/${config.github.repo}`),
43
51
  reviewers,
44
52
  sessionId: context.sessionID,
45
- });
46
- await magi.updateEvent(state.output, `Started reviewing.`);
47
- const exec = createExecWithGitHubApiRetry(magi.exec, config.github.retryApiAttempts);
48
- return new Review(number, magi, config, context, octokit, graphql, exec, state);
53
+ };
49
54
  }
50
55
  checkPr = checkPr;
51
56
  checkExistingReviews = checkExistingReviews;
@@ -60,6 +65,12 @@ export class Review {
60
65
  automate = automate;
61
66
  createReport = createReport;
62
67
  async cleanup() {
68
+ if (this.context.abort.aborted &&
69
+ ["preparing", "running"].includes(this.state.status))
70
+ await this.updateState({
71
+ completedAt: new Date().toISOString(),
72
+ status: "cancelled",
73
+ });
63
74
  if (this.state.worktree?.path)
64
75
  await this.magi.deleteWorktree(this.state.worktree.path);
65
76
  }
@@ -84,14 +95,14 @@ export class Review {
84
95
  const reviewers = Object.fromEntries(await Promise.all(Object.entries(this.state.reviewers).map(async ([id, { model, permissions }]) => [
85
96
  id,
86
97
  {
87
- sessionId: await this.magi.createSession(this.state.sessionId, `magi review #${this.number} ${id}`, { model, permissions }),
98
+ sessionId: await this.magi.createSession(this.state.sessionId, `magi review #${this.number} ${id}`, { model, permissions }, this.context.abort),
88
99
  },
89
100
  ])));
90
101
  const operator = {
91
102
  sessionId: await this.magi.createSession(this.state.sessionId, `magi review #${this.number} operator`, {
92
103
  model: this.state.operator.model,
93
104
  permissions: this.state.operator.permissions,
94
- }),
105
+ }, this.context.abort),
95
106
  };
96
107
  await this.updateState({ operator, reviewers });
97
108
  }
@@ -96,7 +96,7 @@ export async function review() {
96
96
  }));
97
97
  const repairMessage = await prompt.repair();
98
98
  const output = await retry(async (count) => {
99
- const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage);
99
+ const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
100
100
  await this.createAgentFile(rereview ? "rereview" : "review", id, raw, count, cycle);
101
101
  const parsed = prompt.parse(raw);
102
102
  if (!prompt.validate(parsed))
@@ -127,6 +127,7 @@ export async function review() {
127
127
  }, {
128
128
  error: (_, count) => this.updateEvent(`Attempt ${count} failed to ${label} with reviewer ${id}. Retrying...`),
129
129
  retries: this.config.output.repairAttempts,
130
+ signal: this.context.abort,
130
131
  });
131
132
  if (!output)
132
133
  throw new MagiError("blocked", `Invalid output for reviewer ${id}.`);
@@ -200,7 +201,7 @@ export async function reconsiderClose() {
200
201
  const repairMessage = await prompt.repair();
201
202
  await this.updateEvent(`Reconsidering close verdict with reviewer ${id}.`);
202
203
  const output = await retry(async (count) => {
203
- const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage);
204
+ const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
204
205
  await this.createAgentFile("close-reconsideration", id, raw, count, cycle);
205
206
  const parsed = prompt.parse(raw);
206
207
  if (!prompt.validate(parsed))
@@ -210,6 +211,7 @@ export async function reconsiderClose() {
210
211
  }, {
211
212
  error: (_, count) => this.updateEvent(`Attempt ${count} failed to reconsider close verdict with reviewer ${id}. Retrying...`),
212
213
  retries: this.config.output.repairAttempts,
214
+ signal: this.context.abort,
213
215
  });
214
216
  if (!output)
215
217
  throw new MagiError("blocked", `Invalid close reconsideration output for reviewer ${id}.`);
@@ -266,7 +268,7 @@ async function collectAcceptedFindings(findings) {
266
268
  });
267
269
  const repairMessage = await prompt.repair();
268
270
  const output = await retry(async (count) => {
269
- const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage);
271
+ const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : repairMessage, this.context.abort);
270
272
  await this.createAgentFile("finding-validation", id, raw, count);
271
273
  const parsed = prompt.parse(raw);
272
274
  if (!prompt.validate(parsed))
@@ -289,6 +291,7 @@ async function collectAcceptedFindings(findings) {
289
291
  }, {
290
292
  error: (_, count) => this.updateEvent(`Attempt ${count} failed to validate review findings with reviewer ${id}. Retrying...`),
291
293
  retries: this.config.output.repairAttempts,
294
+ signal: this.context.abort,
292
295
  });
293
296
  if (!output)
294
297
  throw new MagiError("blocked", `Invalid finding validation output for reviewer ${id}.`);
@@ -1,6 +1,17 @@
1
- export const wait = async (ms = 0) => new Promise((resolve) => {
2
- setTimeout(resolve, ms);
3
- });
1
+ export function wait(ms = 0, signal) {
2
+ signal?.throwIfAborted();
3
+ return new Promise((resolve, reject) => {
4
+ const abort = () => {
5
+ clearTimeout(timeout);
6
+ reject(signal?.reason);
7
+ };
8
+ const timeout = setTimeout(() => {
9
+ signal?.removeEventListener("abort", abort);
10
+ resolve();
11
+ }, ms);
12
+ signal?.addEventListener("abort", abort, { once: true });
13
+ });
14
+ }
4
15
  export async function loop(callback, ms = 0) {
5
16
  for (;;) {
6
17
  const value = await callback();
@@ -9,13 +20,15 @@ export async function loop(callback, ms = 0) {
9
20
  await wait(ms);
10
21
  }
11
22
  }
12
- export async function retry(callback, { error, retries = 1 }) {
23
+ export async function retry(callback, { error, retries = 1, signal }) {
13
24
  let count = 1;
14
25
  while (count <= retries)
15
26
  try {
27
+ signal?.throwIfAborted();
16
28
  return await callback(count);
17
29
  }
18
30
  catch (e) {
31
+ signal?.throwIfAborted();
19
32
  await error?.(e, count);
20
33
  count += 1;
21
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260714091053",
3
+ "version": "0.0.0-dev-20260715023915",
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>",