@dynamicagents/plugins 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +14 -14
  2. package/dist/claude-code/cards.d.ts +4 -0
  3. package/dist/claude-code/cards.js +22 -0
  4. package/dist/claude-code/config.d.ts +10 -83
  5. package/dist/claude-code/config.js +0 -2
  6. package/dist/claude-code/credentials.d.ts +32 -50
  7. package/dist/claude-code/credentials.js +115 -74
  8. package/dist/claude-code/daemon/index.d.ts +24 -0
  9. package/dist/claude-code/daemon/index.js +203 -0
  10. package/dist/claude-code/egress.d.ts +35 -117
  11. package/dist/claude-code/egress.js +80 -325
  12. package/dist/claude-code/events.d.ts +14 -17
  13. package/dist/claude-code/events.js +19 -15
  14. package/dist/claude-code/harness-wire.d.ts +45 -0
  15. package/dist/claude-code/harness-wire.js +47 -0
  16. package/dist/claude-code/index.d.ts +19 -29
  17. package/dist/claude-code/index.js +17 -25
  18. package/dist/claude-code/language-model.d.ts +19 -0
  19. package/dist/claude-code/language-model.js +71 -0
  20. package/dist/claude-code/model.d.ts +77 -70
  21. package/dist/claude-code/model.js +328 -304
  22. package/dist/claude-code/placeholder.d.ts +12 -0
  23. package/dist/claude-code/placeholder.js +12 -0
  24. package/dist/claude-code/spec.d.ts +4 -4
  25. package/dist/claude-code/spec.js +4 -4
  26. package/dist/claude-code/verified.d.ts +1 -1
  27. package/dist/claude-code/verified.js +1 -1
  28. package/dist/computer/client.d.ts +88 -0
  29. package/dist/computer/client.js +6 -0
  30. package/dist/computer/context.d.ts +7 -7
  31. package/dist/computer/context.js +14 -18
  32. package/dist/computer/gate.d.ts +4 -23
  33. package/dist/computer/gate.js +1 -37
  34. package/dist/computer/index.d.ts +44 -43
  35. package/dist/computer/index.js +29 -50
  36. package/dist/computer/paths.d.ts +5 -27
  37. package/dist/computer/paths.js +7 -30
  38. package/dist/computer/proxy.d.ts +17 -31
  39. package/dist/computer/proxy.js +9 -44
  40. package/dist/computer/render.d.ts +3 -40
  41. package/dist/computer/render.js +3 -36
  42. package/dist/computer/sandbox.d.ts +55 -0
  43. package/dist/computer/sandbox.js +236 -0
  44. package/dist/computer/shell.d.ts +36 -0
  45. package/dist/computer/shell.js +41 -0
  46. package/dist/computer/tools-exec.js +3 -9
  47. package/dist/computer/tools-file.d.ts +1 -1
  48. package/dist/computer/tools-file.js +1 -1
  49. package/dist/{repo → repo-github}/checkout.d.ts +3 -4
  50. package/dist/{repo → repo-github}/checkout.js +3 -4
  51. package/dist/{repo → repo-github}/context.d.ts +14 -32
  52. package/dist/{repo → repo-github}/context.js +46 -81
  53. package/dist/{repo → repo-github}/index.d.ts +30 -13
  54. package/dist/{repo → repo-github}/index.js +25 -6
  55. package/dist/repo-github/mcp.d.ts +95 -0
  56. package/dist/repo-github/mcp.js +107 -0
  57. package/dist/repo-github/sandbox-egress.d.ts +68 -0
  58. package/dist/repo-github/sandbox-egress.js +272 -0
  59. package/dist/repo-github/sandbox-git.d.ts +30 -0
  60. package/dist/repo-github/sandbox-git.js +113 -0
  61. package/dist/{repo → repo-github}/tools-checks.js +43 -4
  62. package/dist/{repo → repo-github}/tools-clone.js +2 -21
  63. package/dist/{repo → repo-github}/tools-pr.d.ts +0 -1
  64. package/dist/{repo → repo-github}/tools-pr.js +85 -20
  65. package/dist/{repo → repo-github}/tools-review.d.ts +6 -0
  66. package/dist/{repo → repo-github}/tools-review.js +62 -176
  67. package/dist/{repo → repo-github}/url.d.ts +11 -0
  68. package/dist/{repo → repo-github}/url.js +12 -1
  69. package/dist/{workspace → sandbox}/advisory.d.ts +16 -35
  70. package/dist/{workspace → sandbox}/advisory.js +2 -29
  71. package/dist/sandbox/agent-exec.d.ts +31 -0
  72. package/dist/sandbox/agent-exec.js +62 -0
  73. package/dist/sandbox/ca-trust.d.ts +9 -0
  74. package/dist/sandbox/ca-trust.js +21 -0
  75. package/dist/sandbox/constants.d.ts +22 -0
  76. package/dist/sandbox/constants.js +22 -0
  77. package/dist/sandbox/daemon/composite.d.ts +9 -0
  78. package/dist/sandbox/daemon/composite.js +35 -0
  79. package/dist/sandbox/daemon/index.d.ts +47 -0
  80. package/dist/sandbox/daemon/index.js +116 -0
  81. package/dist/sandbox/egress.d.ts +81 -0
  82. package/dist/sandbox/egress.js +109 -0
  83. package/dist/sandbox/exec.d.ts +57 -0
  84. package/dist/sandbox/exec.js +52 -0
  85. package/dist/sandbox/format.d.ts +2 -0
  86. package/dist/sandbox/format.js +7 -0
  87. package/dist/sandbox/header.d.ts +50 -0
  88. package/dist/sandbox/header.js +94 -0
  89. package/dist/sandbox/index.d.ts +17 -0
  90. package/dist/sandbox/index.js +17 -0
  91. package/dist/{workspace/install.d.ts → sandbox/install-plan.d.ts} +10 -1
  92. package/dist/{workspace/install.js → sandbox/install-plan.js} +22 -4
  93. package/dist/sandbox/name.d.ts +27 -0
  94. package/dist/sandbox/name.js +32 -0
  95. package/dist/sandbox/object.d.ts +244 -0
  96. package/dist/sandbox/object.js +1139 -0
  97. package/dist/scratch/index.d.ts +1 -9
  98. package/dist/scratch/index.js +2 -18
  99. package/package.json +26 -22
  100. package/dist/claude-code/github.d.ts +0 -39
  101. package/dist/claude-code/github.js +0 -82
  102. package/dist/claude-code/run.d.ts +0 -474
  103. package/dist/claude-code/run.js +0 -901
  104. package/dist/claude-code/session.d.ts +0 -131
  105. package/dist/claude-code/session.js +0 -285
  106. package/dist/workspace/ca-trust.d.ts +0 -95
  107. package/dist/workspace/ca-trust.js +0 -312
  108. package/dist/workspace/container-deps.d.ts +0 -54
  109. package/dist/workspace/container-deps.js +0 -224
  110. package/dist/workspace/container-fault.d.ts +0 -38
  111. package/dist/workspace/container-fault.js +0 -73
  112. package/dist/workspace/exec.d.ts +0 -94
  113. package/dist/workspace/exec.js +0 -153
  114. package/dist/workspace/format.d.ts +0 -36
  115. package/dist/workspace/format.js +0 -57
  116. package/dist/workspace/git-host.d.ts +0 -79
  117. package/dist/workspace/git-host.js +0 -226
  118. package/dist/workspace/git-identity.d.ts +0 -57
  119. package/dist/workspace/git-identity.js +0 -147
  120. package/dist/workspace/index.d.ts +0 -49
  121. package/dist/workspace/index.js +0 -58
  122. package/dist/workspace/install-job.d.ts +0 -121
  123. package/dist/workspace/install-job.js +0 -866
  124. package/dist/workspace/object.d.ts +0 -502
  125. package/dist/workspace/object.js +0 -1427
  126. package/dist/workspace/open.d.ts +0 -95
  127. package/dist/workspace/open.js +0 -63
  128. package/dist/workspace/read.d.ts +0 -16
  129. package/dist/workspace/read.js +0 -22
  130. package/dist/workspace/shell.d.ts +0 -45
  131. package/dist/workspace/shell.js +0 -80
  132. package/dist/workspace/sync.d.ts +0 -111
  133. package/dist/workspace/sync.js +0 -124
  134. package/dist/workspace/wake.d.ts +0 -19
  135. package/dist/workspace/wake.js +0 -10
  136. /package/dist/{repo → repo-github}/tools-checks.d.ts +0 -0
  137. /package/dist/{repo → repo-github}/tools-clone.d.ts +0 -0
  138. /package/dist/{repo → repo-github}/tools-worktree.d.ts +0 -0
  139. /package/dist/{repo → repo-github}/tools-worktree.js +0 -0
  140. /package/dist/{repo → repo-github}/tools-worktrees.d.ts +0 -0
  141. /package/dist/{repo → repo-github}/tools-worktrees.js +0 -0
@@ -1,10 +1,10 @@
1
1
  import { action } from "@cloudflare/think";
2
2
  import { tool } from "ai";
3
3
  import { z } from "zod";
4
- import { actionTask, GITHUB_ACTION_TIMEOUT_MS, GITHUB_PAGE_SIZE, sha256 } from "./context.js";
5
- /** GitHub's REST surface: pull requests, issues and their comments. */
4
+ import { sha256Hex } from "@dynamicagents/core";
5
+ import { actionTask, GITHUB_ACTION_TIMEOUT_MS, GITHUB_PAGE_SIZE } from "./context.js";
6
6
  export function prTools(ctx) {
7
- const { bounded, github, githubRepo } = ctx;
7
+ const { bounded, github, mcp, githubRepo } = ctx;
8
8
  return {
9
9
  repo_open_pr: tool({
10
10
  description: "Open a pull request for a pushed branch and return its URL. Do this once the branch is pushed and the tests pass.",
@@ -67,14 +67,31 @@ export function prTools(ctx) {
67
67
  if ("refusal" in target)
68
68
  return target.refusal;
69
69
  const { owner, repo } = target;
70
- // The issues endpoint, deliberately, even for a pull request: GitHub
71
- // models every PR as an issue, and this is the one that carries the
72
- // discussion. `repo_pr_view` is for the parts that are only a PR's.
73
- const issue = await github("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}`);
70
+ const where = {
71
+ owner: decodeURIComponent(owner),
72
+ repo: decodeURIComponent(repo)
73
+ };
74
+ // `issue_read` deliberately, even for a pull request: GitHub models
75
+ // every PR as an issue, and that is the view carrying the discussion.
76
+ // `repo_pr_view` is for the parts that are only a PR's.
77
+ const issue = await mcp("repo_issue_view", "issue_read", {
78
+ ...where,
79
+ issue_number: number,
80
+ method: "get"
81
+ });
74
82
  if (!issue.ok)
75
83
  return bounded(issue.message);
76
84
  const data = issue.data;
77
- const comments = await github("repo_issue_view", `/repos/${owner}/${repo}/issues/${number}/comments?per_page=${GITHUB_PAGE_SIZE}`);
85
+ // No `pull_request` key on this read, so the kind comes from the URL
86
+ // GitHub builds for it: a pull request's is `/pull/<n>` where an
87
+ // issue's is `/issues/<n>`.
88
+ const isPull = /\/pull\/\d+/.test(data.html_url ?? "");
89
+ const comments = await mcp("repo_issue_view", "issue_read", {
90
+ ...where,
91
+ issue_number: number,
92
+ method: "get_comments",
93
+ perPage: GITHUB_PAGE_SIZE
94
+ });
78
95
  // A failure here is not a failure of the tool: the issue itself was
79
96
  // read, and half an answer beats none.
80
97
  const thread = (comments.ok && Array.isArray(comments.data) ? comments.data : []);
@@ -85,7 +102,7 @@ export function prTools(ctx) {
85
102
  const moreComments = thread.length >= GITHUB_PAGE_SIZE;
86
103
  return bounded([
87
104
  `#${number} ${data.title ?? "(no title)"} [${data.state ?? "?"}]` +
88
- (data.pull_request ? " (pull request)" : ""),
105
+ (isPull ? " (pull request)" : ""),
89
106
  `opened by ${data.user?.login ?? "unknown"}`,
90
107
  "",
91
108
  data.body?.trim() || "(no description)",
@@ -101,7 +118,7 @@ export function prTools(ctx) {
101
118
  }
102
119
  }),
103
120
  repo_pr_view: tool({
104
- description: "Read a pull request's state and the files it touches — whether it is mergeable, its review state, and which paths changed. Use this to check on a pull request you opened, or to see what an existing one does before duplicating it.",
121
+ description: "Read a pull request's state, the reviews that have landed on it, and the files it touches — whether it is mergeable, who has reviewed and with what verdict, and which paths changed. Use this to check on a pull request you opened, to see whether a review has arrived before reading its threads, or to see what an existing one does before duplicating it.",
105
122
  inputSchema: z.object({
106
123
  dir: z.string().describe("The checkout directory"),
107
124
  number: z.number().int().positive().describe("Pull request number")
@@ -111,28 +128,76 @@ export function prTools(ctx) {
111
128
  if ("refusal" in target)
112
129
  return target.refusal;
113
130
  const { owner, repo } = target;
114
- const pr = await github("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}`);
131
+ const where = {
132
+ owner: decodeURIComponent(owner),
133
+ repo: decodeURIComponent(repo),
134
+ pullNumber: number
135
+ };
136
+ const pr = await mcp("repo_pr_view", "pull_request_read", {
137
+ ...where,
138
+ method: "get"
139
+ });
115
140
  if (!pr.ok)
116
141
  return bounded(pr.message);
117
142
  const data = pr.data;
118
- const files = await github("repo_pr_view", `/repos/${owner}/${repo}/pulls/${number}/files?per_page=${GITHUB_PAGE_SIZE}`);
143
+ const files = await mcp("repo_pr_view", "pull_request_read", {
144
+ ...where,
145
+ method: "get_files",
146
+ perPage: GITHUB_PAGE_SIZE
147
+ });
119
148
  const changed = (files.ok && Array.isArray(files.data) ? files.data : []);
120
149
  const moreFiles = changed.length >= GITHUB_PAGE_SIZE;
150
+ // The reviews, because "has one landed yet" is the question an agent
151
+ // watching a pull request between sessions actually has, and an empty
152
+ // list of threads does not answer it: a review can finish having left
153
+ // no comments.
154
+ const reviews = await mcp("repo_pr_view", "pull_request_read", {
155
+ ...where,
156
+ method: "get_reviews",
157
+ perPage: GITHUB_PAGE_SIZE
158
+ });
159
+ const landed = (reviews.ok && Array.isArray(reviews.data) ? reviews.data : []);
160
+ // A draft its author has not sent, visible only to whoever holds the
161
+ // token that wrote it. Reporting one as a review names a verdict
162
+ // nobody has read.
163
+ const submitted = landed.filter((r) => (r.state ?? "").toUpperCase() !== "PENDING");
164
+ // Reviews arrive oldest first, so a full page leaves the newest unread
165
+ // — and "has a review landed yet" is what this tool is called for. Said
166
+ // rather than left to the list, which would otherwise read as complete.
167
+ const moreReviews = landed.length >= GITHUB_PAGE_SIZE;
121
168
  return bounded([
122
169
  `#${number} ${data.title ?? "(no title)"}`,
123
170
  `${data.merged ? "merged" : (data.state ?? "?")}` +
124
171
  (data.draft ? " (draft)" : "") +
125
- // `mergeable` is computed asynchronously by GitHub and is `null`
126
- // until it has been, which is common on a pull request opened
127
- // seconds ago — exactly when this tool is most likely to be called.
128
- (data.mergeable === null
172
+ // GitHub computes mergeability asynchronously and reports
173
+ // `unknown` until it has, which is common on a pull request
174
+ // opened seconds ago — exactly when this tool is most likely to
175
+ // be called.
176
+ (data.mergeable_state === undefined ||
177
+ data.mergeable_state === "unknown"
129
178
  ? ", mergeability not yet computed"
130
- : data.mergeable === false
131
- ? ", NOT mergeable"
132
- : ""),
179
+ : data.mergeable_state === "clean"
180
+ ? ""
181
+ : `, mergeable_state ${data.mergeable_state}`),
133
182
  `${data.head?.ref ?? "?"} → ${data.base?.ref ?? "?"}`,
134
183
  data.html_url ?? "",
135
184
  "",
185
+ !reviews.ok
186
+ ? "Reviews: (could not be read)"
187
+ : submitted.length === 0
188
+ ? "Reviews: none has landed yet."
189
+ : [
190
+ "Reviews:",
191
+ ...submitted.map((r) => ` ${r.user?.login ?? "unknown"} — ${(r.state ?? "?").toLowerCase()}` +
192
+ `${r.submitted_at ? ` at ${r.submitted_at}` : ""}`)
193
+ ].join("\n"),
194
+ ...(moreReviews
195
+ ? [
196
+ `(showing the first ${GITHUB_PAGE_SIZE} reviews; this pull ` +
197
+ `request has more, and the newest are not among them)`
198
+ ]
199
+ : []),
200
+ "",
136
201
  changed.length
137
202
  ? changed
138
203
  .map((f) => ` ${f.filename ?? "?"} (+${f.additions ?? 0} -${f.deletions ?? 0})`)
@@ -175,7 +240,7 @@ export function prActions(ctx) {
175
240
  description: "Leave a comment on a pull request or issue in the repository you have checked out. Use this to report what you did, or to answer a review — not to announce work you have not finished. The same comment twice in one task is posted once.",
176
241
  inputSchema: commentInput,
177
242
  timeoutMs: GITHUB_ACTION_TIMEOUT_MS,
178
- idempotencyKey: async ({ input, ctx: turn }) => `${actionTask(turn)}:${input.dir}#${input.number}:comment:${await sha256(input.body)}`,
243
+ idempotencyKey: async ({ input, ctx: turn }) => `${actionTask(turn)}:${input.dir}#${input.number}:comment:${await sha256Hex(input.body)}`,
179
244
  execute: async ({ dir, number, body }) => {
180
245
  const target = await githubRepo(dir);
181
246
  if ("refusal" in target)
@@ -8,5 +8,11 @@ export declare function reviewTools(ctx: RepoContext): ToolSet;
8
8
  * thrown. The one outcome returned short of success is a reply that landed on
9
9
  * a thread that did not resolve: that reply must never be sent again, so it is
10
10
  * settled, and the resolve-only call it asks for is a different key.
11
+ *
12
+ * **Still GraphQL, and still this plugin's own.** Replying and resolving have no
13
+ * REST equivalent, and the hosted MCP server offers them only under a tool that
14
+ * also submits reviews — `APPROVE` included — which is a grant nothing here
15
+ * wants. See `./mcp.ts`. Not installed on claude-coordinator, whose revising
16
+ * Claude Code sessions answer their own reviews from inside their containers.
11
17
  */
12
18
  export declare function reviewActions(ctx: RepoContext): Record<string, Action>;
@@ -1,8 +1,9 @@
1
1
  import { action } from "@cloudflare/think";
2
2
  import { tool } from "ai";
3
3
  import { z } from "zod";
4
- import { actionTask, GITHUB_ACTION_TIMEOUT_MS, GITHUB_PAGE_SIZE, sha256 } from "./context.js";
5
- /** A review as a conversation: whether it landed, what it said, and answering it. */
4
+ import { sha256Hex } from "@dynamicagents/core";
5
+ import { actionTask, GITHUB_ACTION_TIMEOUT_MS, GITHUB_PAGE_SIZE } from "./context.js";
6
+ /** A review as a conversation: what it said, and answering it. */
6
7
  /**
7
8
  * How many pages of review threads one `repo_pr_threads` call will walk.
8
9
  *
@@ -20,59 +21,10 @@ const MAX_THREAD_PAGES = 10;
20
21
  *
21
22
  * Both ends, because the two uses pull opposite ways: what the reviewer asked for
22
23
  * is at the top, and whether a reply landed is at the bottom. A thread shorter
23
- * than this is returned whole and the two selections are the same comments, which
24
- * is why the render de-duplicates on the count rather than on the text.
24
+ * than this is returned whole, which is the ordinary case — the read asks for a
25
+ * hundred comments per thread and almost every thread has a handful.
25
26
  */
26
27
  const MAX_THREAD_COMMENTS = 6;
27
- /**
28
- * The review threads on one pull request.
29
- *
30
- * `isResolved` is the field the whole workflow turns on and REST does not expose
31
- * at all — which is why these three operations are GraphQL while everything else
32
- * in this file is REST. Both ends of each thread are selected; see
33
- * {@link MAX_THREAD_COMMENTS} for why one end is not enough.
34
- */
35
- const REVIEW_THREADS_QUERY = `
36
- query($owner:String!,$repo:String!,$number:Int!,$after:String){
37
- repository(owner:$owner,name:$repo){
38
- pullRequest(number:$number){
39
- reviewThreads(first:${GITHUB_PAGE_SIZE},after:$after){
40
- pageInfo{hasNextPage endCursor}
41
- nodes{
42
- id isResolved isOutdated path line
43
- opening: comments(first:1){nodes{author{login} body}}
44
- latest: comments(last:${MAX_THREAD_COMMENTS}){totalCount nodes{author{login} body}}
45
- }
46
- }
47
- }
48
- }
49
- }`;
50
- /**
51
- * Who is currently *asked* to review, apps included.
52
- *
53
- * REST's `pulls/{n}/requested_reviewers` carries `users` and `teams` and no
54
- * third key, so a Bot reviewer — Copilot is one — is absent from a request that
55
- * is live, and its answer for "Copilot is working on it right now" is byte for
56
- * byte its answer for "nobody was ever asked". GraphQL types the requested
57
- * reviewer as a union with `Bot` in it, which is what makes the two
58
- * distinguishable at all.
59
- */
60
- const REVIEW_REQUESTS_QUERY = `
61
- query($owner:String!,$repo:String!,$number:Int!){
62
- repository(owner:$owner,name:$repo){
63
- pullRequest(number:$number){
64
- reviewRequests(first:${GITHUB_PAGE_SIZE}){
65
- nodes{
66
- requestedReviewer{
67
- ... on User{login}
68
- ... on Bot{login}
69
- ... on Team{slug}
70
- }
71
- }
72
- }
73
- }
74
- }
75
- }`;
76
28
  /** Which pull request, in which repository, a thread id names. */
77
29
  const THREAD_OWNER_QUERY = `
78
30
  query($id:ID!){
@@ -92,39 +44,59 @@ const THREAD_RESOLVE_MUTATION = `
92
44
  mutation($id:ID!){
93
45
  resolveReviewThread(input:{threadId:$id}){thread{isResolved}}
94
46
  }`;
95
- /** One thread, as the model reads it: where it is, its id, and the conversation. */
47
+ /**
48
+ * One thread, as the model reads it: where it is, its id, and the conversation.
49
+ *
50
+ * Where it is comes from the **first comment** rather than the thread: a thread
51
+ * is a location only through the comments on it, so a thread whose comments did
52
+ * not come back has no file to name.
53
+ */
96
54
  function renderThread(t) {
97
- const where = t.path
98
- ? `${t.path}${typeof t.line === "number" ? `:${t.line}` : ""}`
55
+ const comments = t.comments ?? [];
56
+ const first = comments[0];
57
+ const line = first?.line ?? first?.original_line;
58
+ const where = first?.path
59
+ ? `${first.path}${typeof line === "number" ? `:${line}` : ""}`
99
60
  : "(no file)";
100
- const total = t.latest?.totalCount ?? 0;
101
- const tail = t.latest?.nodes ?? [];
61
+ const total = t.total_count ?? comments.length;
102
62
  // Whole when it fits, and both ends with the middle named when it does not.
103
63
  // The opening comment is the reviewer's actual point and the tail is where a
104
64
  // reply this plugin sent would be, so neither end can be the one dropped.
105
- const conversation = total > tail.length
65
+ const shown = comments.length > MAX_THREAD_COMMENTS
106
66
  ? [
107
- ...(t.opening?.nodes ?? []),
67
+ ...comments.slice(0, 1),
108
68
  {
109
- author: { login: "…" },
110
- body: `(${total - tail.length - 1} earlier replies not shown)`
69
+ author: "…",
70
+ body: `(${comments.length - MAX_THREAD_COMMENTS} earlier replies not shown)`
111
71
  },
112
- ...tail
72
+ ...comments.slice(-(MAX_THREAD_COMMENTS - 1))
73
+ ]
74
+ : comments;
75
+ // Said only when the read itself was short of the conversation, which the
76
+ // slice above is not: that one is this render's own choice and both ends
77
+ // survive it.
78
+ const unread = total > comments.length
79
+ ? [
80
+ {
81
+ author: "…",
82
+ body: `(${total - comments.length} comments were not read)`
83
+ }
113
84
  ]
114
- : tail;
85
+ : [];
115
86
  return [
116
- `--- ${where}${t.isResolved ? " [resolved]" : ""}${t.isOutdated ? " [outdated]" : ""}`,
87
+ `--- ${where}${t.is_resolved ? " [resolved]" : ""}${t.is_outdated ? " [outdated]" : ""}`,
117
88
  `id: ${t.id}`,
118
- ...conversation.map((c) => `${c.author?.login ?? "unknown"}: ${c.body ?? ""}`)
89
+ ...[...shown, ...unread].map((c) => `${c.author ?? "unknown"}: ${c.body ?? ""}`)
119
90
  ].join("\n");
120
91
  }
121
92
  /**
122
93
  * The owner and repository as their own names, rather than as REST path segments.
123
94
  *
124
95
  * {@link file://./context.ts githubRepo} percent-encodes both, because that is
125
- * what makes a path safe. A GraphQL *variable* is not a path — it is compared
126
- * against the repository's actual name — so an encoded one would silently stop
127
- * matching the moment a name contains a character worth encoding.
96
+ * what makes a path safe. A GraphQL *variable* and an MCP argument are not paths
97
+ * — both are compared against the repository's actual name — so an encoded one
98
+ * would silently stop matching the moment a name contains a character worth
99
+ * encoding.
128
100
  */
129
101
  function decoded(target) {
130
102
  return {
@@ -132,105 +104,11 @@ function decoded(target) {
132
104
  repo: decodeURIComponent(target.repo)
133
105
  };
134
106
  }
135
- /**
136
- * The review bot's logins, which GitHub does not spell the same way twice.
137
- *
138
- * One account arrives as `Copilot` from the issue timeline, as
139
- * `copilot-pull-request-reviewer` from GraphQL, and as
140
- * `copilot-pull-request-reviewer[bot]` from `pulls/{n}/reviews`. Compared
141
- * exactly against the login a caller names, the request matches and the review
142
- * never does, so `repo_pr_review_status` reports "none pending — waiting will
143
- * not change it" at the moment the review lands. That is the one answer a
144
- * polling caller stops on, so the mismatch does not degrade the tool, it
145
- * inverts it.
146
- *
147
- * An alternation of the spellings that account answers to, and deliberately not
148
- * a `copilot-` prefix: `copilot-swe-agent` is a **different** account, one that
149
- * opens pull requests rather than reviewing them, so a prefix would let its
150
- * request or its authorship answer a question asked about the reviewer.
151
- */
152
- const COPILOT_LOGIN = /^copilot(-pull-request-reviewer)?$/;
153
- /**
154
- * Whether a login from the API is the reviewer the caller asked about.
155
- *
156
- * `[bot]` is a suffix REST appends to an app's login and GraphQL does not, so it
157
- * is never part of the identity — stripping it is what lets a caller name any
158
- * app reviewer the way GitHub's UI shows it.
159
- */
160
- function sameReviewer(a, b) {
161
- const bare = (login) => login.toLowerCase().replace(/\[bot\]$/, "");
162
- const [x, y] = [bare(a), bare(b)];
163
- return COPILOT_LOGIN.test(x) && COPILOT_LOGIN.test(y) ? true : x === y;
164
- }
165
107
  export function reviewTools(ctx) {
166
- const { bounded, github, githubGraphql, githubRepo } = ctx;
108
+ const { bounded, mcp, githubRepo } = ctx;
167
109
  return {
168
- repo_pr_review_status: tool({
169
- description: "Say whether a reviewer has finished reviewing a pull request in the repository you have checked out. Use it before reading review comments: a review that has not landed has left no comments, and an empty thread list means nothing yet.",
170
- inputSchema: z.object({
171
- dir: z.string().describe("The checkout directory"),
172
- number: z.number().int().positive().describe("Pull request number"),
173
- reviewer: z
174
- .string()
175
- .optional()
176
- .describe('The reviewer to ask about, as their login. Defaults to "Copilot".')
177
- }),
178
- execute: async ({ dir, number, reviewer }) => {
179
- const target = await githubRepo(dir);
180
- if ("refusal" in target)
181
- return target.refusal;
182
- const { owner, repo } = target;
183
- const who = reviewer ?? "Copilot";
184
- // A *pending* request outranks any review already on the pull request,
185
- // and that ordering is the whole logic here. GitHub clears the request
186
- // when a review is submitted and writes a new one when a re-review is
187
- // asked for, so "still requested" is true now and "has reviewed" is
188
- // about the past. Reading them the other way round reports a re-review
189
- // as finished the moment it is asked for.
190
- const requested = await githubGraphql("repo_pr_review_status", REVIEW_REQUESTS_QUERY, { owner, repo, number });
191
- if (!requested.ok)
192
- return bounded(requested.message);
193
- const waiting = requested.data.repository?.pullRequest?.reviewRequests;
194
- if (!waiting)
195
- return `#${number} is not a pull request in ${owner}/${repo}`;
196
- const pending = (waiting.nodes ?? []).some((n) => {
197
- const name = n?.requestedReviewer?.login ?? n?.requestedReviewer?.slug;
198
- return !!name && sameReviewer(name, who);
199
- });
200
- if (pending)
201
- return `${who} has been asked to review #${number} and has not finished.`;
202
- const reviews = await github("repo_pr_review_status", `/repos/${owner}/${repo}/pulls/${number}/reviews?per_page=${GITHUB_PAGE_SIZE}`);
203
- if (!reviews.ok)
204
- return bounded(reviews.message);
205
- const all = reviews.data;
206
- const submitted = all.filter((r) => !!r.user?.login &&
207
- sameReviewer(r.user.login, who) &&
208
- // A `PENDING` review is a draft its author has not sent, and it is
209
- // visible to whoever holds the token that wrote it. Counting one as
210
- // finished reports a review nobody has read, which is the answer a
211
- // poll loop acts on by stopping.
212
- (r.state ?? "").toUpperCase() !== "PENDING");
213
- const theirs = submitted.at(-1);
214
- if (!theirs) {
215
- // The endpoint pages oldest-first, so a match beyond the first page is
216
- // **newer** than everything read — which is to say it is exactly the
217
- // review being waited for. So a truncated read cannot answer the
218
- // question at all, and must not answer it with "waiting will not help":
219
- // that is the one reply a caller ends its polling on.
220
- if (all.length >= GITHUB_PAGE_SIZE)
221
- return bounded(`#${number} has at least ${GITHUB_PAGE_SIZE} reviews and only the oldest were read, ` +
222
- `so whether ${who} has reviewed it is unknown. Read the pull request itself with repo_pr_view, ` +
223
- `or look for threads with repo_pr_threads.`);
224
- return bounded(`#${number} has no review from ${who}, and none is pending. ` +
225
- `Nobody asked them, or the request was withdrawn — waiting will not change it.`);
226
- }
227
- return bounded(`${who} reviewed #${number}${theirs.submitted_at ? ` at ${theirs.submitted_at}` : ""}` +
228
- `${theirs.state ? ` (${theirs.state.toLowerCase()})` : ""}. ` +
229
- `Read what it said with repo_pr_threads — a review can finish having left no comments.`);
230
- }
231
- }),
232
110
  repo_pr_threads: tool({
233
- description: "Read the review threads on a pull request in the repository you have checked out — the inline comments a reviewer left on specific lines, which are not on the conversation timeline repo_issue_view reads. Unresolved ones only, unless you ask for all.",
111
+ description: "Read the review threads on a pull request in the repository you have checked out — the inline comments a reviewer left on specific lines, which are not on the conversation timeline repo_issue_view reads. Unresolved ones only, unless you ask for all. An empty answer on a pull request whose review has not landed means nothing yet rather than nothing to do — repo_pr_view says whether one has.",
234
112
  inputSchema: z.object({
235
113
  dir: z.string().describe("The checkout directory"),
236
114
  number: z.number().int().positive().describe("Pull request number"),
@@ -245,7 +123,7 @@ export function reviewTools(ctx) {
245
123
  return target.refusal;
246
124
  const { owner, repo } = decoded(target);
247
125
  const threads = [];
248
- let after = null;
126
+ let after;
249
127
  let truncated = false;
250
128
  // Paged, and that is not an optimisation. A review long enough to spill a
251
129
  // page puts its newest threads on the last one — exactly the threads the
@@ -257,21 +135,23 @@ export function reviewTools(ctx) {
257
135
  truncated = true;
258
136
  break;
259
137
  }
260
- const answered = await githubGraphql("repo_pr_threads", REVIEW_THREADS_QUERY, {
138
+ const answered = await mcp("repo_pr_threads", "pull_request_read", {
261
139
  owner,
262
140
  repo,
263
- number,
264
- after
141
+ pullNumber: number,
142
+ method: "get_review_comments",
143
+ perPage: GITHUB_PAGE_SIZE,
144
+ ...(after ? { after } : {})
265
145
  });
266
146
  if (!answered.ok)
267
147
  return bounded(answered.message);
268
- const page_ = answered.data.repository?.pullRequest?.reviewThreads;
269
- if (!page_)
148
+ const page_ = answered.data;
149
+ if (!page_?.review_threads)
270
150
  return `#${number} is not a pull request in ${owner}/${repo}`;
271
- threads.push(...(page_.nodes ?? []));
272
- if (!page_.pageInfo?.hasNextPage)
151
+ threads.push(...page_.review_threads);
152
+ if (!page_.page_info?.has_next_page)
273
153
  break;
274
- after = page_.pageInfo.endCursor ?? null;
154
+ after = page_.page_info.end_cursor ?? undefined;
275
155
  if (!after)
276
156
  break;
277
157
  }
@@ -285,7 +165,7 @@ export function reviewTools(ctx) {
285
165
  : "";
286
166
  const shown = includeResolved
287
167
  ? threads
288
- : threads.filter((t) => !t.isResolved);
168
+ : threads.filter((t) => !t.is_resolved);
289
169
  if (shown.length === 0)
290
170
  return bounded((includeResolved
291
171
  ? `#${number} has no review threads.`
@@ -305,6 +185,12 @@ export function reviewTools(ctx) {
305
185
  * thrown. The one outcome returned short of success is a reply that landed on
306
186
  * a thread that did not resolve: that reply must never be sent again, so it is
307
187
  * settled, and the resolve-only call it asks for is a different key.
188
+ *
189
+ * **Still GraphQL, and still this plugin's own.** Replying and resolving have no
190
+ * REST equivalent, and the hosted MCP server offers them only under a tool that
191
+ * also submits reviews — `APPROVE` included — which is a grant nothing here
192
+ * wants. See `./mcp.ts`. Not installed on claude-coordinator, whose revising
193
+ * Claude Code sessions answer their own reviews from inside their containers.
308
194
  */
309
195
  export function reviewActions(ctx) {
310
196
  const { bounded, githubGraphql, githubRepo } = ctx;
@@ -332,7 +218,7 @@ export function reviewActions(ctx) {
332
218
  // The thread and the resolve flag as well as the body, or the same reply
333
219
  // to two threads — "Fixed." — would be one key.
334
220
  idempotencyKey: async ({ input, ctx: turn }) => `${actionTask(turn)}:${input.dir}#${input.number}:${input.threadId}:` +
335
- `${await sha256(input.body ?? "")}:${input.resolve ?? true}`,
221
+ `${await sha256Hex(input.body ?? "")}:${input.resolve ?? true}`,
336
222
  execute: async ({ dir, number, threadId, body, resolve }) => {
337
223
  const target = await githubRepo(dir);
338
224
  if ("refusal" in target)
@@ -6,6 +6,17 @@
6
6
  * naming it": every pattern here turns away a string that resolves to something
7
7
  * other than what it appears to name.
8
8
  */
9
+ /**
10
+ * The one host whose API these tools talk to.
11
+ *
12
+ * Narrower than {@link DEFAULT_ALLOWED_HOSTS}, which a host may widen: cloning
13
+ * is generic git and works anywhere, while every API read goes to github.com's
14
+ * hosted MCP server and every write to github.com's REST and GraphQL. So a
15
+ * checkout on another allowed host has an origin these tools must refuse rather
16
+ * than resolve — see `githubRepo` in `./context.ts`, which is where that is
17
+ * enforced.
18
+ */
19
+ export declare const GITHUB_HOST = "github.com";
9
20
  /** The hosts a clone URL may name. `RepoConfig.allowedHosts` overrides it. */
10
21
  export declare const DEFAULT_ALLOWED_HOSTS: string[];
11
22
  /** Branch names a push must never target, whatever the model believes. */
@@ -6,8 +6,19 @@
6
6
  * naming it": every pattern here turns away a string that resolves to something
7
7
  * other than what it appears to name.
8
8
  */
9
+ /**
10
+ * The one host whose API these tools talk to.
11
+ *
12
+ * Narrower than {@link DEFAULT_ALLOWED_HOSTS}, which a host may widen: cloning
13
+ * is generic git and works anywhere, while every API read goes to github.com's
14
+ * hosted MCP server and every write to github.com's REST and GraphQL. So a
15
+ * checkout on another allowed host has an origin these tools must refuse rather
16
+ * than resolve — see `githubRepo` in `./context.ts`, which is where that is
17
+ * enforced.
18
+ */
19
+ export const GITHUB_HOST = "github.com";
9
20
  /** The hosts a clone URL may name. `RepoConfig.allowedHosts` overrides it. */
10
- export const DEFAULT_ALLOWED_HOSTS = ["github.com"];
21
+ export const DEFAULT_ALLOWED_HOSTS = [GITHUB_HOST];
11
22
  /** Branch names a push must never target, whatever the model believes. */
12
23
  export const PROTECTED_BRANCHES = new Set([
13
24
  "main",