opencode-magi 0.0.0-dev-20260702001022 → 0.0.0-dev-20260714064227

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/commands.js CHANGED
@@ -1,8 +1,4 @@
1
1
  export const commands = {
2
- "magi:cancel": {
3
- description: "Cancel active background Magi runs",
4
- template: ["Call the `magi_cancel` tool.", "PR or Issue: $ARGUMENTS"].join("\n"),
5
- },
6
2
  "magi:clear": {
7
3
  description: [
8
4
  "Clear inactive Magi runs, sessions, worktrees, and outputs",
package/dist/magi.js CHANGED
@@ -1,14 +1,13 @@
1
1
  import { createOpencodeClient } from "@opencode-ai/sdk/v2";
2
2
  import { print } from "graphql";
3
3
  import { randomUUID } from "node:crypto";
4
- import { mkdir, readdir, readFile, rm, rmdir, writeFile, } from "node:fs/promises";
4
+ import { appendFile, mkdir, readdir, readFile, rm, rmdir, writeFile, } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join, relative } from "node:path";
6
6
  import { Octokit } from "octokit";
7
7
  import { getConfig, resolvePermissions, validateConfig } from "@/config";
8
8
  import { graphql } from "@/graphql";
9
9
  import { command, createExec, filterEmpty, isArray, isNumber, isObject, merge, quote, } from "@/utils";
10
10
  const active = new Set(["preparing", "running"]);
11
- const backgrounds = new Map();
12
11
  export class MagiError extends Error {
13
12
  status;
14
13
  constructor(status, message) {
@@ -55,45 +54,6 @@ export class Magi {
55
54
  createGraphql(octokit) {
56
55
  return graphql((document, variables) => octokit.graphql(print(document), variables));
57
56
  }
58
- registerBackground(number, controller) {
59
- if (backgrounds.has(number))
60
- this.cancelBackground(number);
61
- backgrounds.set(number, controller);
62
- }
63
- unregisterBackground(number, signal) {
64
- if (backgrounds.get(number)?.signal === signal)
65
- backgrounds.delete(number);
66
- }
67
- cancelBackgrounds(numbers) {
68
- const results = {
69
- cancelled: [],
70
- missing: [],
71
- };
72
- if (!numbers?.length)
73
- numbers = [...backgrounds.keys()];
74
- for (const number of numbers) {
75
- const cancelled = this.cancelBackground(number);
76
- results[cancelled ? "cancelled" : "missing"].push(number);
77
- }
78
- return results;
79
- }
80
- cancelBackground(number) {
81
- const controller = backgrounds.get(number);
82
- if (controller) {
83
- controller.abort();
84
- backgrounds.delete(number);
85
- return true;
86
- }
87
- else {
88
- return false;
89
- }
90
- }
91
- async notify(sessionID, text) {
92
- await this.input.client.session.promptAsync({
93
- parts: [{ synthetic: true, text, type: "text" }],
94
- sessionID,
95
- });
96
- }
97
57
  async clear(config) {
98
58
  const summary = {
99
59
  branch: 0,
@@ -177,10 +137,7 @@ export class Magi {
177
137
  updatedAt: createdAt,
178
138
  ...initialState,
179
139
  };
180
- const values = [this.createStateFile(state)];
181
- if (!state.sync && state.text)
182
- values.push(this.notify(state.sessionId, state.text));
183
- await Promise.all(values);
140
+ await this.createStateFile(state);
184
141
  return state;
185
142
  }
186
143
  async createStateFile(state) {
@@ -200,12 +157,30 @@ export class Magi {
200
157
  next.updatedAt = new Date().toISOString();
201
158
  const prev = await this.getState(dir);
202
159
  const state = merge(prev, next);
203
- const values = [this.createStateFile(state)];
204
- if (!state.sync && next.text)
205
- values.push(this.notify(prev.sessionId, next.text));
206
- await Promise.all(values);
160
+ await this.createStateFile(state);
207
161
  return state;
208
162
  }
163
+ async updateEvent(output, message) {
164
+ const event = {
165
+ createdAt: new Date().toISOString(),
166
+ message,
167
+ };
168
+ await appendFile(join(output, "events.jsonl"), `${JSON.stringify(event)}\n`);
169
+ }
170
+ async getEvents(output) {
171
+ try {
172
+ const content = await readFile(join(output, "events.jsonl"), "utf8");
173
+ return content
174
+ .split("\n")
175
+ .filter(Boolean)
176
+ .map((line) => JSON.parse(line));
177
+ }
178
+ catch (e) {
179
+ if (e instanceof Error && "code" in e && e.code === "ENOENT")
180
+ return [];
181
+ throw e;
182
+ }
183
+ }
209
184
  getSessionIds(state) {
210
185
  return [
211
186
  state.sessionId,
@@ -1,11 +1,9 @@
1
- import { cancel } from "./cancel";
2
1
  import { clear } from "./clear";
3
2
  import { merge } from "./merge";
4
3
  import { review } from "./review";
5
4
  import { triage } from "./triage";
6
5
  import { validate } from "./validate";
7
6
  export const tools = {
8
- cancel,
9
7
  clear,
10
8
  merge,
11
9
  review,
@@ -12,12 +12,12 @@ export async function editCycles(callback) {
12
12
  error: async (e, count) => {
13
13
  if (e !== error)
14
14
  throw e;
15
- await this.notify(`Attempt ${count} failed to edit cycles for ${this.getLink()}. Retrying...`);
15
+ await this.updateEvent(`Attempt ${count} failed to edit cycles. Retrying...`);
16
16
  },
17
17
  retries: this.config.merge.maxThreadResolutionCycles,
18
18
  });
19
19
  if (!verdict || verdict === "CHANGES_REQUESTED")
20
- throw new MagiError("blocked", `Reached maximum edit cycles for ${this.getLink()}.`);
20
+ throw new MagiError("blocked", `Reached maximum edit cycles.`);
21
21
  }
22
22
  export async function postReplies() {
23
23
  this.context.abort.throwIfAborted();
@@ -28,9 +28,7 @@ export async function postReplies() {
28
28
  throw new MagiError("blocked", "Editor output not found.");
29
29
  if (!output.responses.length)
30
30
  return;
31
- this.state = await this.magi.updateState(this.state.output, {
32
- text: `Posting editor replies for ${this.getLink()}.`,
33
- });
31
+ await this.updateEvent(`Posting editor replies.`);
34
32
  if (!this.state.editor?.account)
35
33
  throw new MagiError("blocked", "Editor account not found.");
36
34
  const octokit = await this.magi.createOctokit(this.config, this.context.abort, this.state.editor.account);
@@ -44,7 +42,5 @@ export async function postReplies() {
44
42
  body,
45
43
  comment_id: commentId,
46
44
  })));
47
- this.state = await this.magi.updateState(this.state.output, {
48
- text: `Finished posting editor replies for ${this.getLink()}.`,
49
- });
45
+ await this.updateEvent(`Finished posting editor replies.`);
50
46
  }
@@ -6,16 +6,14 @@ export async function fetchMergeContext() {
6
6
  const output = this.state.editor?.outputs?.at(-1);
7
7
  if (!output)
8
8
  throw new MagiError("blocked", "Editor output not found.");
9
- this.state = await this.magi.updateState(this.state.output, {
10
- text: `Fetching merge context for ${this.getLink()}.`,
11
- });
9
+ await this.updateEvent(`Fetching merge context.`);
12
10
  const [comments, issues, threads] = await Promise.all([
13
11
  getComments.call(this),
14
12
  getClosingIssues.call(this),
15
13
  getReviewThreads.call(this),
16
14
  ]);
17
15
  const inlineCommentTargets = await getInlineCommentTargets.call(this);
18
- this.state = await this.magi.updateState(this.state.output, {
16
+ await this.updateState({
19
17
  pr: {
20
18
  comments,
21
19
  inlineCommentTargets,
@@ -27,14 +25,12 @@ export async function fetchMergeContext() {
27
25
  ])
28
26
  : threads,
29
27
  },
30
- text: `Finished fetching merge context for ${this.getLink()}.`,
31
28
  });
29
+ await this.updateEvent(`Finished fetching merge context.`);
32
30
  }
33
31
  export async function markRepliedReviewers() {
34
32
  this.context.abort.throwIfAborted();
35
- this.state = await this.magi.updateState(this.state.output, {
36
- text: `Marking replied reviewers for ${this.getLink()}.`,
37
- });
33
+ await this.updateEvent(`Marking replied reviewers.`);
38
34
  const output = this.state.editor?.outputs?.at(-1);
39
35
  if (!output)
40
36
  throw new MagiError("blocked", "Editor output not found.");
@@ -60,10 +56,8 @@ export async function markRepliedReviewers() {
60
56
  id,
61
57
  { status: replied.includes(id) ? "reply" : "skip" },
62
58
  ]));
63
- this.state = await this.magi.updateState(this.state.output, {
64
- reviewers,
65
- text: `Finished marking replied reviewers for ${this.getLink()}.`,
66
- });
59
+ await this.updateState({ reviewers });
60
+ await this.updateEvent(`Finished marking replied reviewers.`);
67
61
  }
68
62
  function createSyntheticThreads() {
69
63
  const findings = Object.entries(this.state.reviewers ?? {}).flatMap(([reviewer, { account, outputs }]) => {
@@ -4,9 +4,7 @@ import { getMetadata } from "@/tools/review/check";
4
4
  import { command, filterDuplicates, filterEmpty, quote, retry } from "@/utils";
5
5
  export async function edit() {
6
6
  this.context.abort.throwIfAborted();
7
- this.state = await this.magi.updateState(this.state.output, {
8
- text: `Editing ${this.getLink()}.`,
9
- });
7
+ await this.updateEvent(`Editing.`);
10
8
  await setAccount.call(this);
11
9
  const unresolvedThreads = await getUnresolvedThreads.call(this);
12
10
  const threads = this.state.dryRun
@@ -46,17 +44,17 @@ export async function edit() {
46
44
  }
47
45
  return output;
48
46
  }, {
49
- error: (_, count) => this.notify(`Attempt ${count} failed to edit ${this.getLink()}. Retrying...`),
47
+ error: (_, count) => this.updateEvent(`Attempt ${count} failed to edit. Retrying...`),
50
48
  retries: this.config.output.repairAttempts,
51
49
  });
52
50
  if (!output)
53
51
  throw new MagiError("blocked", "Invalid output for editor.");
54
- this.state = await this.magi.updateState(this.state.output, {
52
+ await this.updateState({
55
53
  editor: { outputs: [...(this.state.editor.outputs ?? []), output] },
56
- text: `Finished editing ${this.getLink()}.`,
57
54
  });
58
- await this.notify(filterEmpty([
59
- `Editor ${output.mode === "EDITED" ? "edited" : "replied to"} ${this.getLink()}.`,
55
+ await this.updateEvent(`Finished editing.`);
56
+ await this.updateEvent(filterEmpty([
57
+ `Editor ${output.mode.toLocaleLowerCase()}.`,
60
58
  output.commitSha
61
59
  ? `Commit: ${output.commitSha}${output.commitMessage ? ` ${output.commitMessage}` : ""}`
62
60
  : undefined,
@@ -71,7 +69,7 @@ export async function edit() {
71
69
  ]).join("\n\n"));
72
70
  if (output.mode === "EDITED")
73
71
  if (this.state.dryRun) {
74
- this.state = await this.magi.updateState(this.state.output, {
72
+ await this.updateState({
75
73
  pr: {
76
74
  files: filterDuplicates([
77
75
  ...(this.state.pr?.files ?? []),
@@ -79,26 +77,20 @@ export async function edit() {
79
77
  ]),
80
78
  metadata: { head: { sha: output.commitSha } },
81
79
  },
82
- text: `Skipped pushing editor changes for ${this.getLink()} during dry run.`,
83
80
  });
81
+ await this.updateEvent(`Skipped pushing editor changes during dry run.`);
84
82
  }
85
83
  else {
86
- this.state = await this.magi.updateState(this.state.output, {
87
- text: `Pushing editor changes for ${this.getLink()}.`,
88
- });
84
+ await this.updateEvent(`Pushing editor changes.`);
89
85
  const pr = await push.call(this);
90
- this.state = await this.magi.updateState(this.state.output, {
91
- pr,
92
- text: `Finished pushing editor changes for ${this.getLink()}.`,
93
- });
86
+ await this.updateState({ pr });
87
+ await this.updateEvent(`Finished pushing editor changes.`);
94
88
  }
95
89
  return output.mode === "EDITED" && !this.state.dryRun;
96
90
  }
97
91
  export async function resolveConflict() {
98
92
  this.context.abort.throwIfAborted();
99
- this.state = await this.magi.updateState(this.state.output, {
100
- text: `Resolving merge conflicts for ${this.getLink()}.`,
101
- });
93
+ await this.updateEvent(`Resolving merge conflicts.`);
102
94
  if (!this.state.pr?.metadata)
103
95
  throw new MagiError("blocked", "PR metadata not found.");
104
96
  const options = { cwd: this.state.worktree.path, signal: this.context.abort };
@@ -143,36 +135,32 @@ export async function resolveConflict() {
143
135
  responses: [],
144
136
  };
145
137
  }, {
146
- error: (_, count) => this.notify(`Attempt ${count} failed to resolve conflicts for ${this.getLink()}. Retrying...`),
138
+ error: (_, count) => this.updateEvent(`Attempt ${count} failed to resolve conflicts. Retrying...`),
147
139
  retries: this.config.output.repairAttempts,
148
140
  });
149
141
  if (!output)
150
142
  throw new MagiError("blocked", "Invalid output for conflict editor.");
151
- this.state = await this.magi.updateState(this.state.output, {
143
+ await this.updateState({
152
144
  editor: { outputs: [...(this.state.editor.outputs ?? []), output] },
153
- text: `Finished resolving merge conflicts for ${this.getLink()}.`,
154
145
  });
146
+ await this.updateEvent(`Finished resolving merge conflicts.`);
155
147
  if (this.state.dryRun) {
156
- this.state = await this.magi.updateState(this.state.output, {
148
+ await this.updateState({
157
149
  pr: {
158
150
  files: filterDuplicates([
159
- ...(this.state.pr?.files ?? []),
151
+ ...(this.state.pr.files ?? []),
160
152
  ...output.filesTouched,
161
153
  ]),
162
154
  metadata: { head: { sha: output.commitSha } },
163
155
  },
164
- text: `Skipped pushing conflict resolution for ${this.getLink()} during dry run.`,
165
156
  });
157
+ await this.updateEvent(`Skipped pushing conflict resolution during dry run.`);
166
158
  }
167
159
  else {
168
- this.state = await this.magi.updateState(this.state.output, {
169
- text: `Pushing conflict resolution for ${this.getLink()}.`,
170
- });
160
+ await this.updateEvent(`Pushing conflict resolution.`);
171
161
  const pr = await push.call(this);
172
- this.state = await this.magi.updateState(this.state.output, {
173
- pr,
174
- text: `Finished pushing conflict resolution for ${this.getLink()}.`,
175
- });
162
+ await this.updateState({ pr });
163
+ await this.updateEvent(`Finished pushing conflict resolution.`);
176
164
  }
177
165
  }
178
166
  async function setAccount() {
@@ -1,5 +1,5 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
- import { filterEmpty, parsePrs, split, Worker } from "@/utils";
2
+ import { parsePrs, split, Worker } from "@/utils";
3
3
  import { Merge } from "./merge";
4
4
  async function editCycle(run, { conflict, cycle } = {}) {
5
5
  if (!run.state.editor?.sessionId)
@@ -32,7 +32,7 @@ async function editCycle(run, { conflict, cycle } = {}) {
32
32
  await run.postReviews();
33
33
  return verdict;
34
34
  }
35
- function overrideConfig(config, args, dryRun = false, sync = false) {
35
+ function overrideConfig(config, args, dryRun = false) {
36
36
  for (let index = 0; index < args.length; index++) {
37
37
  const arg = args[index];
38
38
  const value = args[index + 1];
@@ -42,9 +42,6 @@ function overrideConfig(config, args, dryRun = false, sync = false) {
42
42
  case "--dry-run":
43
43
  dryRun = true;
44
44
  break;
45
- case "--sync":
46
- sync = true;
47
- break;
48
45
  case "--retry-api-attempts":
49
46
  config.github.retryApiAttempts = parseInt(value);
50
47
  break;
@@ -89,7 +86,7 @@ function overrideConfig(config, args, dryRun = false, sync = false) {
89
86
  break;
90
87
  }
91
88
  }
92
- return { config, dryRun, sync };
89
+ return { config, dryRun };
93
90
  }
94
91
  export const merge = function (magi) {
95
92
  return {
@@ -97,23 +94,18 @@ export const merge = function (magi) {
97
94
  args: {
98
95
  dryRun: tool.schema.boolean().optional(),
99
96
  prs: tool.schema.string(),
100
- sync: tool.schema.boolean().optional(),
101
97
  },
102
- description: "Start background Magi merge runs for one or more pull requests with configured Magi agents. After starting, monitor progress yourself when useful; do not tell users to call follow-up tools by name.",
98
+ description: "Review, fix, and merge one or more pull requests.",
103
99
  async execute(args, context) {
104
100
  const prs = parsePrs(args.prs);
105
- const { config, dryRun, sync } = overrideConfig(await magi.getConfig({ editor: true, reviewers: true }), split(args.prs), args.dryRun, args.sync);
101
+ const { config, dryRun } = overrideConfig(await magi.getConfig({ editor: true, reviewers: true }), split(args.prs), args.dryRun);
106
102
  const worker = new Worker(config.review.concurrency.runs);
107
- const states = [];
108
- const tasks = [];
109
- for (const pr of prs) {
103
+ const reports = await Promise.all(prs.map(async (pr) => {
110
104
  context.abort.throwIfAborted();
111
- const run = await Merge.init(pr, magi, config, context, {
112
- dryRun,
113
- sync,
114
- });
115
- states.push(run.state);
116
- const task = worker.run(async () => {
105
+ return worker.run(async () => {
106
+ const run = await Merge.init(pr, magi, config, context, {
107
+ dryRun,
108
+ });
117
109
  try {
118
110
  await run.checkPr();
119
111
  const skip = await run.checkExistingReviews();
@@ -148,20 +140,8 @@ export const merge = function (magi) {
148
140
  await run.cleanup();
149
141
  }
150
142
  });
151
- tasks.push(task);
152
- if (!sync)
153
- void task;
154
- }
155
- if (sync) {
156
- const results = await Promise.all(tasks);
157
- const output = filterEmpty(results.map(({ text }) => text)).join("\n\n");
158
- if (results.some(({ status }) => status === "failed" || status === "cancelled"))
159
- throw new Error(output);
160
- return output;
161
- }
162
- else {
163
- return filterEmpty(states.map(({ text }) => text)).join("\n");
164
- }
143
+ }));
144
+ return reports.join("\n\n");
165
145
  },
166
146
  }),
167
147
  };
@@ -8,11 +8,6 @@ import { edit, resolveConflict } from "./editor";
8
8
  import { createReport } from "./report";
9
9
  export class Merge extends Review {
10
10
  static async init(number, magi, config, context, options) {
11
- if (!options.sync) {
12
- const controller = new AbortController();
13
- context = { ...context, abort: controller.signal };
14
- magi.registerBackground(number, controller);
15
- }
16
11
  const url = `${config.github.url}/pull/${number}`;
17
12
  const octokit = await magi.createOctokit(config, context.abort);
18
13
  const graphql = magi.createGraphql(octokit);
@@ -35,8 +30,8 @@ export class Merge extends Review {
35
30
  repo: quote(`${config.github.owner}/${config.github.repo}`),
36
31
  reviewers,
37
32
  sessionId: context.sessionID,
38
- text: `Started merging [#${number}](${url}).`,
39
33
  });
34
+ await magi.updateEvent(state.output, `Started merging.`);
40
35
  const exec = createExecWithGitHubApiRetry(magi.exec, config.github.retryApiAttempts);
41
36
  return new Merge(number, magi, config, context, octokit, graphql, exec, state);
42
37
  }
@@ -51,18 +46,14 @@ export class Merge extends Review {
51
46
  this.context.abort.throwIfAborted();
52
47
  if (!this.state.editor)
53
48
  throw new MagiError("blocked", "Editor not found.");
54
- this.state = await this.magi.updateState(this.state.output, {
55
- text: `Creating editor session for ${this.getLink()}.`,
56
- });
49
+ await this.updateEvent(`Creating editor session.`);
57
50
  const editor = {
58
51
  sessionId: await this.magi.createSession(this.state.sessionId, `magi merge #${this.number} editor`, {
59
52
  model: this.state.editor.model,
60
53
  permissions: this.state.editor.permissions,
61
54
  }),
62
55
  };
63
- this.state = await this.magi.updateState(this.state.output, {
64
- editor,
65
- text: `Finished creating editor session for ${this.getLink()}.`,
66
- });
56
+ await this.updateState({ editor });
57
+ await this.updateEvent(`Finished creating editor session.`);
67
58
  }
68
59
  }
@@ -6,13 +6,13 @@ import { filterEmpty, toTitleCase } from "@/utils";
6
6
  export async function createReport(e) {
7
7
  if (!e) {
8
8
  const status = "completed";
9
- const text = createContent.call(this, { status });
9
+ const text = await createContent.call(this, { status });
10
10
  await writeFile(join(this.state.output, "report.md"), `${text}\n`);
11
- return this.magi.updateState(this.state.output, {
11
+ await this.updateState({
12
12
  completedAt: new Date().toISOString(),
13
13
  status,
14
- text,
15
14
  });
15
+ return text;
16
16
  }
17
17
  else {
18
18
  const error = e instanceof Error ? e.message : "Unknown error";
@@ -21,19 +21,18 @@ export async function createReport(e) {
21
21
  : this.context.abort.aborted
22
22
  ? "cancelled"
23
23
  : "failed";
24
- const text = createContent.call(this, { error, status });
24
+ const text = await createContent.call(this, { error, status });
25
25
  await writeFile(join(this.state.output, "report.md"), `${text}\n`);
26
- return this.magi.updateState(this.state.output, {
26
+ await this.updateState({
27
27
  completedAt: new Date().toISOString(),
28
- error,
29
28
  status,
30
- text: `${toTitleCase(status)} merging ${this.getLink()}.\n\n${text}`,
31
29
  });
30
+ return text;
32
31
  }
33
32
  }
34
- function createContent(input) {
33
+ async function createContent(input) {
35
34
  return filterEmpty([
36
- ...createMetaContent.call(this, input),
35
+ ...(await createMetaContent.call(this, input)),
37
36
  ...createCheckContent.call(this),
38
37
  ...createReviewerContent.call(this),
39
38
  ...createEditorContent.call(this),