@dynamicagents/plugins 0.11.1 → 0.13.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 (134) hide show
  1. package/README.md +59 -79
  2. package/dist/browser/index.d.ts +0 -2
  3. package/dist/browser/index.js +21 -27
  4. package/dist/claude-code/cards.d.ts +53 -0
  5. package/dist/claude-code/cards.js +272 -0
  6. package/dist/claude-code/config.d.ts +73 -31
  7. package/dist/claude-code/config.js +0 -2
  8. package/dist/claude-code/credentials.d.ts +21 -9
  9. package/dist/claude-code/credentials.js +43 -9
  10. package/dist/claude-code/egress.d.ts +15 -14
  11. package/dist/claude-code/egress.js +51 -14
  12. package/dist/claude-code/events.d.ts +153 -9
  13. package/dist/claude-code/events.js +289 -58
  14. package/dist/claude-code/github.d.ts +39 -0
  15. package/dist/claude-code/github.js +82 -0
  16. package/dist/claude-code/index.d.ts +24 -80
  17. package/dist/claude-code/index.js +19 -234
  18. package/dist/claude-code/model.d.ts +152 -0
  19. package/dist/claude-code/model.js +342 -0
  20. package/dist/claude-code/run.d.ts +283 -92
  21. package/dist/claude-code/run.js +620 -114
  22. package/dist/claude-code/session.d.ts +131 -0
  23. package/dist/claude-code/session.js +285 -0
  24. package/dist/claude-code/spec.d.ts +46 -0
  25. package/dist/claude-code/spec.js +83 -0
  26. package/dist/claude-code/verified.d.ts +15 -0
  27. package/dist/claude-code/verified.js +15 -0
  28. package/dist/computer/context.d.ts +19 -0
  29. package/dist/computer/context.js +222 -0
  30. package/dist/computer/file-lock.d.ts +5 -0
  31. package/dist/computer/file-lock.js +40 -0
  32. package/dist/computer/gate.d.ts +12 -12
  33. package/dist/computer/gate.js +12 -12
  34. package/dist/computer/index.d.ts +57 -255
  35. package/dist/computer/index.js +98 -1039
  36. package/dist/computer/paths.d.ts +47 -22
  37. package/dist/computer/paths.js +64 -49
  38. package/dist/computer/proxy.d.ts +45 -0
  39. package/dist/computer/proxy.js +234 -0
  40. package/dist/computer/render.d.ts +27 -38
  41. package/dist/computer/render.js +32 -65
  42. package/dist/computer/tools-exec.d.ts +3 -0
  43. package/dist/computer/tools-exec.js +268 -0
  44. package/dist/computer/tools-file.d.ts +12 -0
  45. package/dist/computer/tools-file.js +64 -0
  46. package/dist/computer/tools-grep.d.ts +3 -0
  47. package/dist/computer/tools-grep.js +129 -0
  48. package/dist/repo/checkout.d.ts +35 -6
  49. package/dist/repo/checkout.js +64 -6
  50. package/dist/repo/context.d.ts +135 -0
  51. package/dist/repo/context.js +582 -0
  52. package/dist/repo/index.d.ts +79 -57
  53. package/dist/repo/index.js +118 -1112
  54. package/dist/repo/tools-checks.d.ts +3 -0
  55. package/dist/repo/tools-checks.js +112 -0
  56. package/dist/repo/tools-clone.d.ts +4 -0
  57. package/dist/repo/tools-clone.js +192 -0
  58. package/dist/repo/tools-pr.d.ts +14 -0
  59. package/dist/repo/tools-pr.js +196 -0
  60. package/dist/repo/tools-review.d.ts +12 -0
  61. package/dist/repo/tools-review.js +393 -0
  62. package/dist/repo/tools-worktree.d.ts +4 -0
  63. package/dist/repo/tools-worktree.js +305 -0
  64. package/dist/repo/tools-worktrees.d.ts +12 -0
  65. package/dist/repo/tools-worktrees.js +48 -0
  66. package/dist/repo/url.d.ts +2 -2
  67. package/dist/repo/url.js +7 -7
  68. package/dist/scratch/index.d.ts +4 -4
  69. package/dist/scratch/index.js +12 -11
  70. package/dist/{computer → workspace}/advisory.d.ts +33 -20
  71. package/dist/{computer → workspace}/advisory.js +32 -7
  72. package/dist/workspace/ca-trust.d.ts +95 -0
  73. package/dist/workspace/ca-trust.js +312 -0
  74. package/dist/workspace/container-deps.d.ts +54 -0
  75. package/dist/workspace/container-deps.js +224 -0
  76. package/dist/workspace/container-fault.d.ts +38 -0
  77. package/dist/workspace/container-fault.js +73 -0
  78. package/dist/workspace/exec.d.ts +94 -0
  79. package/dist/workspace/exec.js +153 -0
  80. package/dist/workspace/format.d.ts +36 -0
  81. package/dist/workspace/format.js +57 -0
  82. package/dist/workspace/git-host.d.ts +79 -0
  83. package/dist/workspace/git-host.js +226 -0
  84. package/dist/workspace/git-identity.d.ts +57 -0
  85. package/dist/workspace/git-identity.js +147 -0
  86. package/dist/workspace/index.d.ts +43 -47
  87. package/dist/workspace/index.js +52 -112
  88. package/dist/workspace/install-job.d.ts +121 -0
  89. package/dist/workspace/install-job.js +866 -0
  90. package/dist/{computer → workspace}/install.d.ts +15 -19
  91. package/dist/{computer → workspace}/install.js +8 -11
  92. package/dist/workspace/object.d.ts +502 -0
  93. package/dist/workspace/object.js +1427 -0
  94. package/dist/workspace/open.d.ts +95 -0
  95. package/dist/workspace/open.js +63 -0
  96. package/dist/workspace/read.d.ts +16 -0
  97. package/dist/workspace/read.js +22 -0
  98. package/dist/workspace/shell.d.ts +45 -0
  99. package/dist/workspace/shell.js +80 -0
  100. package/dist/workspace/sync.d.ts +111 -0
  101. package/dist/workspace/sync.js +124 -0
  102. package/dist/workspace/wake.d.ts +19 -0
  103. package/dist/workspace/wake.js +10 -0
  104. package/package.json +28 -42
  105. package/dist/arc-agi/analysis.d.ts +0 -336
  106. package/dist/arc-agi/analysis.js +0 -829
  107. package/dist/arc-agi/client.d.ts +0 -82
  108. package/dist/arc-agi/client.js +0 -113
  109. package/dist/arc-agi/game-tools.d.ts +0 -25
  110. package/dist/arc-agi/game-tools.js +0 -26
  111. package/dist/arc-agi/index.d.ts +0 -42
  112. package/dist/arc-agi/index.js +0 -175
  113. package/dist/arc-agi/main-agent.d.ts +0 -27
  114. package/dist/arc-agi/main-agent.js +0 -65
  115. package/dist/arc-agi/recipe.d.ts +0 -51
  116. package/dist/arc-agi/recipe.js +0 -100
  117. package/dist/arc-agi/scorecard.d.ts +0 -118
  118. package/dist/arc-agi/scorecard.js +0 -163
  119. package/dist/arc-agi/soul.d.ts +0 -40
  120. package/dist/arc-agi/soul.js +0 -90
  121. package/dist/arc-agi/store.d.ts +0 -154
  122. package/dist/arc-agi/store.js +0 -216
  123. package/dist/arc-agi/tools.d.ts +0 -36
  124. package/dist/arc-agi/tools.js +0 -576
  125. package/dist/arc-agi/types.d.ts +0 -212
  126. package/dist/arc-agi/types.js +0 -19
  127. package/dist/claude-code/recipe.d.ts +0 -53
  128. package/dist/claude-code/recipe.js +0 -100
  129. package/dist/computer/read.d.ts +0 -137
  130. package/dist/computer/read.js +0 -203
  131. package/dist/recall/index.d.ts +0 -99
  132. package/dist/recall/index.js +0 -171
  133. package/dist/triage/index.d.ts +0 -355
  134. package/dist/triage/index.js +0 -302
@@ -0,0 +1,393 @@
1
+ import { action } from "@cloudflare/think";
2
+ import { tool } from "ai";
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. */
6
+ /**
7
+ * How many pages of review threads one `repo_pr_threads` call will walk.
8
+ *
9
+ * A bound rather than a full walk, for the reason {@link GITHUB_PAGE_SIZE} is one
10
+ * — but unlike the single page everything else here takes, threads genuinely
11
+ * have to page: a review long enough to spill one puts its newest threads on the
12
+ * last page, and those are exactly the ones an agent was sent to answer. So this
13
+ * is high enough that a real review never reaches it and low enough that a
14
+ * pathological pull request cannot turn one tool call into an unbounded number of
15
+ * round trips.
16
+ */
17
+ const MAX_THREAD_PAGES = 10;
18
+ /**
19
+ * How much of one thread's conversation is read back.
20
+ *
21
+ * Both ends, because the two uses pull opposite ways: what the reviewer asked for
22
+ * 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.
25
+ */
26
+ 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
+ /** Which pull request, in which repository, a thread id names. */
77
+ const THREAD_OWNER_QUERY = `
78
+ query($id:ID!){
79
+ node(id:$id){
80
+ ... on PullRequestReviewThread {
81
+ pullRequest{number repository{nameWithOwner}}
82
+ }
83
+ }
84
+ }`;
85
+ const THREAD_REPLY_MUTATION = `
86
+ mutation($id:ID!,$body:String!){
87
+ addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$id,body:$body}){
88
+ comment{url}
89
+ }
90
+ }`;
91
+ const THREAD_RESOLVE_MUTATION = `
92
+ mutation($id:ID!){
93
+ resolveReviewThread(input:{threadId:$id}){thread{isResolved}}
94
+ }`;
95
+ /** One thread, as the model reads it: where it is, its id, and the conversation. */
96
+ function renderThread(t) {
97
+ const where = t.path
98
+ ? `${t.path}${typeof t.line === "number" ? `:${t.line}` : ""}`
99
+ : "(no file)";
100
+ const total = t.latest?.totalCount ?? 0;
101
+ const tail = t.latest?.nodes ?? [];
102
+ // Whole when it fits, and both ends with the middle named when it does not.
103
+ // The opening comment is the reviewer's actual point and the tail is where a
104
+ // reply this plugin sent would be, so neither end can be the one dropped.
105
+ const conversation = total > tail.length
106
+ ? [
107
+ ...(t.opening?.nodes ?? []),
108
+ {
109
+ author: { login: "…" },
110
+ body: `(${total - tail.length - 1} earlier replies not shown)`
111
+ },
112
+ ...tail
113
+ ]
114
+ : tail;
115
+ return [
116
+ `--- ${where}${t.isResolved ? " [resolved]" : ""}${t.isOutdated ? " [outdated]" : ""}`,
117
+ `id: ${t.id}`,
118
+ ...conversation.map((c) => `${c.author?.login ?? "unknown"}: ${c.body ?? ""}`)
119
+ ].join("\n");
120
+ }
121
+ /**
122
+ * The owner and repository as their own names, rather than as REST path segments.
123
+ *
124
+ * {@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.
128
+ */
129
+ function decoded(target) {
130
+ return {
131
+ owner: decodeURIComponent(target.owner),
132
+ repo: decodeURIComponent(target.repo)
133
+ };
134
+ }
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
+ export function reviewTools(ctx) {
166
+ const { bounded, github, githubGraphql, githubRepo } = ctx;
167
+ 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
+ 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.",
234
+ inputSchema: z.object({
235
+ dir: z.string().describe("The checkout directory"),
236
+ number: z.number().int().positive().describe("Pull request number"),
237
+ includeResolved: z
238
+ .boolean()
239
+ .optional()
240
+ .describe("Include threads already resolved. Defaults to false.")
241
+ }),
242
+ execute: async ({ dir, number, includeResolved }) => {
243
+ const target = await githubRepo(dir);
244
+ if ("refusal" in target)
245
+ return target.refusal;
246
+ const { owner, repo } = decoded(target);
247
+ const threads = [];
248
+ let after = null;
249
+ let truncated = false;
250
+ // Paged, and that is not an optimisation. A review long enough to spill a
251
+ // page puts its newest threads on the last one — exactly the threads the
252
+ // model was sent to answer — so a single page can report a busy review as
253
+ // clean. The page cap is what stops a pathological pull request becoming
254
+ // an unbounded number of round trips.
255
+ for (let page = 0;; page++) {
256
+ if (page >= MAX_THREAD_PAGES) {
257
+ truncated = true;
258
+ break;
259
+ }
260
+ const answered = await githubGraphql("repo_pr_threads", REVIEW_THREADS_QUERY, {
261
+ owner,
262
+ repo,
263
+ number,
264
+ after
265
+ });
266
+ if (!answered.ok)
267
+ return bounded(answered.message);
268
+ const page_ = answered.data.repository?.pullRequest?.reviewThreads;
269
+ if (!page_)
270
+ return `#${number} is not a pull request in ${owner}/${repo}`;
271
+ threads.push(...(page_.nodes ?? []));
272
+ if (!page_.pageInfo?.hasNextPage)
273
+ break;
274
+ after = page_.pageInfo.endCursor ?? null;
275
+ if (!after)
276
+ break;
277
+ }
278
+ // Said once, and said on **every** exit including the empty one. A run
279
+ // that stopped early and found nothing open in what it did read must not
280
+ // answer "nothing to do": the unread pages are exactly where the newest
281
+ // threads are, which is the false-clean result the paging exists to
282
+ // prevent — reintroduced at the one exit that skipped the warning.
283
+ const unread = truncated
284
+ ? `\n\n(stopped after ${MAX_THREAD_PAGES} pages, so this is not the whole review — there are more threads than were read)`
285
+ : "";
286
+ const shown = includeResolved
287
+ ? threads
288
+ : threads.filter((t) => !t.isResolved);
289
+ if (shown.length === 0)
290
+ return bounded((includeResolved
291
+ ? `#${number} has no review threads.`
292
+ : `#${number} has no unresolved review threads.` +
293
+ (threads.length > 0
294
+ ? ` (${threads.length} resolved — pass includeResolved to read them.)`
295
+ : "")) + unread);
296
+ const rendered = shown.map((t) => renderThread(t)).join("\n\n");
297
+ return bounded(rendered + unread);
298
+ }
299
+ })
300
+ };
301
+ }
302
+ /**
303
+ * Answering a thread, as a Think action — see
304
+ * {@link file://./tools-pr.ts prActions} for why every failure is
305
+ * thrown. The one outcome returned short of success is a reply that landed on
306
+ * a thread that did not resolve: that reply must never be sent again, so it is
307
+ * settled, and the resolve-only call it asks for is a different key.
308
+ */
309
+ export function reviewActions(ctx) {
310
+ const { bounded, githubGraphql, githubRepo } = ctx;
311
+ const replyInput = z.object({
312
+ dir: z.string().describe("The checkout directory"),
313
+ number: z.number().int().positive().describe("Pull request number"),
314
+ threadId: z
315
+ .string()
316
+ .describe("The thread's id, exactly as repo_pr_threads printed it"),
317
+ body: z
318
+ .string()
319
+ .min(1)
320
+ .optional()
321
+ .describe("The reply, as markdown. Leave it out to resolve a thread you have already replied to."),
322
+ resolve: z
323
+ .boolean()
324
+ .optional()
325
+ .describe("Resolve the thread after replying. Defaults to true.")
326
+ });
327
+ return {
328
+ repo_pr_thread_reply: action({
329
+ description: "Answer one review thread on a pull request in the repository you have checked out, and resolve it. Reply with what you changed and where, or with why you did not — either way the thread ends resolved unless you say otherwise. The same reply to one thread twice in one task is sent once.",
330
+ inputSchema: replyInput,
331
+ timeoutMs: GITHUB_ACTION_TIMEOUT_MS,
332
+ // The thread and the resolve flag as well as the body, or the same reply
333
+ // to two threads — "Fixed." — would be one key.
334
+ idempotencyKey: async ({ input, ctx: turn }) => `${actionTask(turn)}:${input.dir}#${input.number}:${input.threadId}:` +
335
+ `${await sha256(input.body ?? "")}:${input.resolve ?? true}`,
336
+ execute: async ({ dir, number, threadId, body, resolve }) => {
337
+ const target = await githubRepo(dir);
338
+ if ("refusal" in target)
339
+ throw new Error(target.refusal);
340
+ const { owner, repo } = decoded(target);
341
+ // A thread id is a global node id, so unlike every other tool here this
342
+ // one takes something that can name a pull request in a repository
343
+ // nobody checked out — the reach `githubRepo` exists to prevent. Checked
344
+ // rather than trusted: the id came from a tool result the model read,
345
+ // and a model that misremembers one must not write into a stranger's
346
+ // review.
347
+ const belongs = await githubGraphql("repo_pr_thread_reply", THREAD_OWNER_QUERY, { id: threadId });
348
+ if (!belongs.ok)
349
+ throw new Error(bounded(belongs.message));
350
+ const node = belongs.data.node;
351
+ if (!node?.pullRequest)
352
+ throw new Error(`${threadId} is not a review thread — read the ids with repo_pr_threads`);
353
+ const at = node.pullRequest.repository?.nameWithOwner ?? "";
354
+ if (at.toLowerCase() !== `${owner}/${repo}`.toLowerCase() ||
355
+ node.pullRequest.number !== number)
356
+ throw new Error(`${threadId} belongs to ${at}#${node.pullRequest.number}, not ${owner}/${repo}#${number}. ` +
357
+ `Read the ids for this pull request with repo_pr_threads.`);
358
+ // Resolve-only. The reply and the resolve are two calls, so they fail
359
+ // independently — and the tool that tells a caller "the reply landed,
360
+ // resolve it alone" has to give it a way to do that. Without this the
361
+ // only route back is a second reply the reviewer has already read.
362
+ if (body === undefined) {
363
+ if (resolve === false)
364
+ throw new Error("nothing to do: give a body to reply, or leave resolve unset to resolve the thread");
365
+ const only = await githubGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
366
+ if (!only.ok)
367
+ throw new Error(bounded(`resolving the thread failed: ${only.message}`));
368
+ return `resolved ${threadId}`;
369
+ }
370
+ const replied = await githubGraphql("repo_pr_thread_reply", THREAD_REPLY_MUTATION, { id: threadId, body });
371
+ if (!replied.ok)
372
+ // The hazard `repo_pr_comment` names, and worse here: a reply that
373
+ // landed and an answer that did not arrive look identical, and the
374
+ // retry leaves the reviewer two of them.
375
+ throw new Error(bounded(`${replied.message}\nIf this was a timeout rather than a rejection the ` +
376
+ `reply may exist anyway — read the thread back with repo_pr_threads before retrying.`));
377
+ const url = replied.data.addPullRequestReviewThreadReply?.comment?.url ?? "(no url)";
378
+ if (resolve === false)
379
+ return `replied: ${url} (left unresolved)`;
380
+ // Reported separately, never folded into the reply's own failure: a
381
+ // thread that was answered and not resolved needs resolving, and one
382
+ // that was never answered needs answering. Saying "it failed" covers
383
+ // both and tells the model to do the wrong one.
384
+ const resolved = await githubGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
385
+ if (!resolved.ok)
386
+ return bounded(`replied: ${url}\nbut resolving the thread failed: ${resolved.message}\n` +
387
+ `The reply has landed — do not send it again. Call repo_pr_thread_reply on the same ` +
388
+ `threadId with no body to resolve it on its own.`);
389
+ return `replied and resolved: ${url}`;
390
+ }
391
+ })
392
+ };
393
+ }
@@ -0,0 +1,4 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { RepoContext } from "./context.js";
3
+ /** Reading the working tree, committing it, and publishing the branch. */
4
+ export declare function worktreeTools(ctx: RepoContext): ToolSet;