@dynamicagents/plugins 0.12.0 → 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 (130) hide show
  1. package/README.md +58 -77
  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 +28 -63
  7. package/dist/claude-code/config.js +0 -11
  8. package/dist/claude-code/egress.d.ts +8 -1
  9. package/dist/claude-code/egress.js +42 -4
  10. package/dist/claude-code/events.d.ts +106 -9
  11. package/dist/claude-code/events.js +201 -56
  12. package/dist/claude-code/github.d.ts +39 -0
  13. package/dist/claude-code/github.js +82 -0
  14. package/dist/claude-code/index.d.ts +16 -90
  15. package/dist/claude-code/index.js +14 -276
  16. package/dist/claude-code/model.d.ts +152 -0
  17. package/dist/claude-code/model.js +342 -0
  18. package/dist/claude-code/run.d.ts +209 -112
  19. package/dist/claude-code/run.js +339 -138
  20. package/dist/claude-code/session.d.ts +131 -0
  21. package/dist/claude-code/session.js +285 -0
  22. package/dist/claude-code/spec.d.ts +46 -0
  23. package/dist/claude-code/spec.js +83 -0
  24. package/dist/claude-code/verified.d.ts +15 -0
  25. package/dist/claude-code/verified.js +15 -0
  26. package/dist/computer/context.d.ts +1 -5
  27. package/dist/computer/context.js +7 -10
  28. package/dist/computer/file-lock.js +5 -4
  29. package/dist/computer/gate.d.ts +4 -4
  30. package/dist/computer/gate.js +5 -5
  31. package/dist/computer/index.d.ts +40 -258
  32. package/dist/computer/index.js +71 -324
  33. package/dist/computer/paths.d.ts +36 -16
  34. package/dist/computer/paths.js +42 -29
  35. package/dist/computer/proxy.d.ts +45 -0
  36. package/dist/computer/proxy.js +234 -0
  37. package/dist/computer/render.d.ts +1 -40
  38. package/dist/computer/render.js +2 -68
  39. package/dist/computer/tools-exec.js +12 -13
  40. package/dist/computer/tools-file.d.ts +9 -1
  41. package/dist/computer/tools-file.js +22 -82
  42. package/dist/computer/{tools-find.d.ts → tools-grep.d.ts} +1 -1
  43. package/dist/computer/tools-grep.js +129 -0
  44. package/dist/repo/checkout.d.ts +4 -5
  45. package/dist/repo/checkout.js +31 -8
  46. package/dist/repo/context.d.ts +35 -9
  47. package/dist/repo/context.js +101 -49
  48. package/dist/repo/index.d.ts +73 -28
  49. package/dist/repo/index.js +112 -96
  50. package/dist/repo/tools-checks.d.ts +3 -0
  51. package/dist/repo/tools-checks.js +112 -0
  52. package/dist/repo/tools-clone.js +1 -1
  53. package/dist/repo/tools-pr.d.ts +14 -0
  54. package/dist/repo/{tools-forge.js → tools-pr.js} +53 -36
  55. package/dist/repo/tools-review.d.ts +9 -0
  56. package/dist/repo/tools-review.js +67 -47
  57. package/dist/repo/tools-worktree.js +144 -60
  58. package/dist/repo/tools-worktrees.d.ts +12 -0
  59. package/dist/repo/tools-worktrees.js +48 -0
  60. package/dist/repo/url.d.ts +2 -2
  61. package/dist/repo/url.js +7 -7
  62. package/dist/scratch/index.d.ts +4 -4
  63. package/dist/scratch/index.js +12 -11
  64. package/dist/{computer → workspace}/advisory.d.ts +4 -4
  65. package/dist/{computer → workspace}/advisory.js +2 -2
  66. package/dist/{computer/host → workspace}/ca-trust.d.ts +8 -1
  67. package/dist/{computer/host → workspace}/ca-trust.js +12 -3
  68. package/dist/{computer/host → workspace}/container-fault.d.ts +3 -3
  69. package/dist/{computer/host → workspace}/container-fault.js +23 -8
  70. package/dist/workspace/exec.d.ts +94 -0
  71. package/dist/workspace/exec.js +153 -0
  72. package/dist/workspace/format.d.ts +36 -0
  73. package/dist/workspace/format.js +57 -0
  74. package/dist/{computer/host → workspace}/git-host.d.ts +21 -4
  75. package/dist/{computer/host → workspace}/git-host.js +72 -2
  76. package/dist/{computer/host → workspace}/git-identity.d.ts +1 -1
  77. package/dist/{computer/host → workspace}/git-identity.js +1 -1
  78. package/dist/workspace/index.d.ts +43 -47
  79. package/dist/workspace/index.js +52 -112
  80. package/dist/{computer/host → workspace}/install-job.d.ts +4 -4
  81. package/dist/{computer/host → workspace}/install-job.js +276 -176
  82. package/dist/{computer → workspace}/install.d.ts +8 -8
  83. package/dist/{computer → workspace}/install.js +3 -3
  84. package/dist/{computer/host/workspace.d.ts → workspace/object.d.ts} +105 -27
  85. package/dist/{computer/host/workspace.js → workspace/object.js} +217 -48
  86. package/dist/workspace/open.d.ts +95 -0
  87. package/dist/workspace/open.js +63 -0
  88. package/dist/workspace/read.d.ts +16 -0
  89. package/dist/workspace/read.js +22 -0
  90. package/dist/{computer → workspace}/shell.d.ts +5 -5
  91. package/dist/{computer → workspace}/shell.js +6 -6
  92. package/dist/{computer/host → workspace}/wake.d.ts +1 -1
  93. package/package.json +16 -36
  94. package/dist/arc-agi/analysis.d.ts +0 -322
  95. package/dist/arc-agi/analysis.js +0 -802
  96. package/dist/arc-agi/client.d.ts +0 -82
  97. package/dist/arc-agi/client.js +0 -113
  98. package/dist/arc-agi/game-tools.d.ts +0 -24
  99. package/dist/arc-agi/game-tools.js +0 -26
  100. package/dist/arc-agi/index.d.ts +0 -40
  101. package/dist/arc-agi/index.js +0 -175
  102. package/dist/arc-agi/main-agent.d.ts +0 -24
  103. package/dist/arc-agi/main-agent.js +0 -62
  104. package/dist/arc-agi/recipe.d.ts +0 -48
  105. package/dist/arc-agi/recipe.js +0 -97
  106. package/dist/arc-agi/scorecard.d.ts +0 -118
  107. package/dist/arc-agi/scorecard.js +0 -163
  108. package/dist/arc-agi/soul.d.ts +0 -29
  109. package/dist/arc-agi/soul.js +0 -79
  110. package/dist/arc-agi/store.d.ts +0 -153
  111. package/dist/arc-agi/store.js +0 -215
  112. package/dist/arc-agi/tools.d.ts +0 -36
  113. package/dist/arc-agi/tools.js +0 -574
  114. package/dist/arc-agi/types.d.ts +0 -211
  115. package/dist/arc-agi/types.js +0 -19
  116. package/dist/claude-code/recipe.d.ts +0 -53
  117. package/dist/claude-code/recipe.js +0 -100
  118. package/dist/computer/read.d.ts +0 -133
  119. package/dist/computer/read.js +0 -200
  120. package/dist/computer/tools-find.js +0 -216
  121. package/dist/recall/index.d.ts +0 -105
  122. package/dist/recall/index.js +0 -181
  123. package/dist/repo/tools-forge.d.ts +0 -4
  124. package/dist/triage/index.d.ts +0 -360
  125. package/dist/triage/index.js +0 -311
  126. /package/dist/{computer/host → workspace}/container-deps.d.ts +0 -0
  127. /package/dist/{computer/host → workspace}/container-deps.js +0 -0
  128. /package/dist/{computer/host → workspace}/sync.d.ts +0 -0
  129. /package/dist/{computer/host → workspace}/sync.js +0 -0
  130. /package/dist/{computer/host → workspace}/wake.js +0 -0
@@ -1,11 +1,12 @@
1
+ import { action } from "@cloudflare/think";
1
2
  import { tool } from "ai";
2
3
  import { z } from "zod";
3
- import { FORGE_PAGE_SIZE } from "./context.js";
4
+ import { actionTask, GITHUB_ACTION_TIMEOUT_MS, GITHUB_PAGE_SIZE, sha256 } from "./context.js";
4
5
  /** A review as a conversation: whether it landed, what it said, and answering it. */
5
6
  /**
6
7
  * How many pages of review threads one `repo_pr_threads` call will walk.
7
8
  *
8
- * A bound rather than a full walk, for the reason {@link FORGE_PAGE_SIZE} is one
9
+ * A bound rather than a full walk, for the reason {@link GITHUB_PAGE_SIZE} is one
9
10
  * — but unlike the single page everything else here takes, threads genuinely
10
11
  * have to page: a review long enough to spill one puts its newest threads on the
11
12
  * last page, and those are exactly the ones an agent was sent to answer. So this
@@ -35,7 +36,7 @@ const REVIEW_THREADS_QUERY = `
35
36
  query($owner:String!,$repo:String!,$number:Int!,$after:String){
36
37
  repository(owner:$owner,name:$repo){
37
38
  pullRequest(number:$number){
38
- reviewThreads(first:${FORGE_PAGE_SIZE},after:$after){
39
+ reviewThreads(first:${GITHUB_PAGE_SIZE},after:$after){
39
40
  pageInfo{hasNextPage endCursor}
40
41
  nodes{
41
42
  id isResolved isOutdated path line
@@ -60,7 +61,7 @@ const REVIEW_REQUESTS_QUERY = `
60
61
  query($owner:String!,$repo:String!,$number:Int!){
61
62
  repository(owner:$owner,name:$repo){
62
63
  pullRequest(number:$number){
63
- reviewRequests(first:${FORGE_PAGE_SIZE}){
64
+ reviewRequests(first:${GITHUB_PAGE_SIZE}){
64
65
  nodes{
65
66
  requestedReviewer{
66
67
  ... on User{login}
@@ -120,7 +121,7 @@ function renderThread(t) {
120
121
  /**
121
122
  * The owner and repository as their own names, rather than as REST path segments.
122
123
  *
123
- * {@link file://./context.ts forgeRepo} percent-encodes both, because that is
124
+ * {@link file://./context.ts githubRepo} percent-encodes both, because that is
124
125
  * what makes a path safe. A GraphQL *variable* is not a path — it is compared
125
126
  * against the repository's actual name — so an encoded one would silently stop
126
127
  * matching the moment a name contains a character worth encoding.
@@ -162,7 +163,7 @@ function sameReviewer(a, b) {
162
163
  return COPILOT_LOGIN.test(x) && COPILOT_LOGIN.test(y) ? true : x === y;
163
164
  }
164
165
  export function reviewTools(ctx) {
165
- const { bounded, forge, forgeGraphql, forgeRepo } = ctx;
166
+ const { bounded, github, githubGraphql, githubRepo } = ctx;
166
167
  return {
167
168
  repo_pr_review_status: tool({
168
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.",
@@ -175,7 +176,7 @@ export function reviewTools(ctx) {
175
176
  .describe('The reviewer to ask about, as their login. Defaults to "Copilot".')
176
177
  }),
177
178
  execute: async ({ dir, number, reviewer }) => {
178
- const target = await forgeRepo(dir);
179
+ const target = await githubRepo(dir);
179
180
  if ("refusal" in target)
180
181
  return target.refusal;
181
182
  const { owner, repo } = target;
@@ -186,7 +187,7 @@ export function reviewTools(ctx) {
186
187
  // asked for, so "still requested" is true now and "has reviewed" is
187
188
  // about the past. Reading them the other way round reports a re-review
188
189
  // as finished the moment it is asked for.
189
- const requested = await forgeGraphql("repo_pr_review_status", REVIEW_REQUESTS_QUERY, { owner, repo, number });
190
+ const requested = await githubGraphql("repo_pr_review_status", REVIEW_REQUESTS_QUERY, { owner, repo, number });
190
191
  if (!requested.ok)
191
192
  return bounded(requested.message);
192
193
  const waiting = requested.data.repository?.pullRequest?.reviewRequests;
@@ -198,7 +199,7 @@ export function reviewTools(ctx) {
198
199
  });
199
200
  if (pending)
200
201
  return `${who} has been asked to review #${number} and has not finished.`;
201
- const reviews = await forge("repo_pr_review_status", `/repos/${owner}/${repo}/pulls/${number}/reviews?per_page=${FORGE_PAGE_SIZE}`);
202
+ const reviews = await github("repo_pr_review_status", `/repos/${owner}/${repo}/pulls/${number}/reviews?per_page=${GITHUB_PAGE_SIZE}`);
202
203
  if (!reviews.ok)
203
204
  return bounded(reviews.message);
204
205
  const all = reviews.data;
@@ -216,8 +217,8 @@ export function reviewTools(ctx) {
216
217
  // review being waited for. So a truncated read cannot answer the
217
218
  // question at all, and must not answer it with "waiting will not help":
218
219
  // that is the one reply a caller ends its polling on.
219
- if (all.length >= FORGE_PAGE_SIZE)
220
- return bounded(`#${number} has at least ${FORGE_PAGE_SIZE} reviews and only the oldest were read, ` +
220
+ if (all.length >= GITHUB_PAGE_SIZE)
221
+ return bounded(`#${number} has at least ${GITHUB_PAGE_SIZE} reviews and only the oldest were read, ` +
221
222
  `so whether ${who} has reviewed it is unknown. Read the pull request itself with repo_pr_view, ` +
222
223
  `or look for threads with repo_pr_threads.`);
223
224
  return bounded(`#${number} has no review from ${who}, and none is pending. ` +
@@ -239,7 +240,7 @@ export function reviewTools(ctx) {
239
240
  .describe("Include threads already resolved. Defaults to false.")
240
241
  }),
241
242
  execute: async ({ dir, number, includeResolved }) => {
242
- const target = await forgeRepo(dir);
243
+ const target = await githubRepo(dir);
243
244
  if ("refusal" in target)
244
245
  return target.refusal;
245
246
  const { owner, repo } = decoded(target);
@@ -256,7 +257,7 @@ export function reviewTools(ctx) {
256
257
  truncated = true;
257
258
  break;
258
259
  }
259
- const answered = await forgeGraphql("repo_pr_threads", REVIEW_THREADS_QUERY, {
260
+ const answered = await githubGraphql("repo_pr_threads", REVIEW_THREADS_QUERY, {
260
261
  owner,
261
262
  repo,
262
263
  number,
@@ -295,45 +296,64 @@ export function reviewTools(ctx) {
295
296
  const rendered = shown.map((t) => renderThread(t)).join("\n\n");
296
297
  return bounded(rendered + unread);
297
298
  }
298
- }),
299
- repo_pr_thread_reply: tool({
300
- 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.",
301
- inputSchema: z.object({
302
- dir: z.string().describe("The checkout directory"),
303
- number: z.number().int().positive().describe("Pull request number"),
304
- threadId: z
305
- .string()
306
- .describe("The thread's id, exactly as repo_pr_threads printed it"),
307
- body: z
308
- .string()
309
- .optional()
310
- .describe("The reply, as markdown. Leave it out to resolve a thread you have already replied to."),
311
- resolve: z
312
- .boolean()
313
- .optional()
314
- .describe("Resolve the thread after replying. Defaults to true.")
315
- }),
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}`,
316
336
  execute: async ({ dir, number, threadId, body, resolve }) => {
317
- const target = await forgeRepo(dir);
337
+ const target = await githubRepo(dir);
318
338
  if ("refusal" in target)
319
- return target.refusal;
339
+ throw new Error(target.refusal);
320
340
  const { owner, repo } = decoded(target);
321
341
  // A thread id is a global node id, so unlike every other tool here this
322
342
  // one takes something that can name a pull request in a repository
323
- // nobody checked out — the reach `forgeRepo` exists to prevent. Checked
343
+ // nobody checked out — the reach `githubRepo` exists to prevent. Checked
324
344
  // rather than trusted: the id came from a tool result the model read,
325
345
  // and a model that misremembers one must not write into a stranger's
326
346
  // review.
327
- const belongs = await forgeGraphql("repo_pr_thread_reply", THREAD_OWNER_QUERY, { id: threadId });
347
+ const belongs = await githubGraphql("repo_pr_thread_reply", THREAD_OWNER_QUERY, { id: threadId });
328
348
  if (!belongs.ok)
329
- return bounded(belongs.message);
349
+ throw new Error(bounded(belongs.message));
330
350
  const node = belongs.data.node;
331
351
  if (!node?.pullRequest)
332
- return `${threadId} is not a review thread — read the ids with repo_pr_threads`;
352
+ throw new Error(`${threadId} is not a review thread — read the ids with repo_pr_threads`);
333
353
  const at = node.pullRequest.repository?.nameWithOwner ?? "";
334
354
  if (at.toLowerCase() !== `${owner}/${repo}`.toLowerCase() ||
335
355
  node.pullRequest.number !== number)
336
- return (`${threadId} belongs to ${at}#${node.pullRequest.number}, not ${owner}/${repo}#${number}. ` +
356
+ throw new Error(`${threadId} belongs to ${at}#${node.pullRequest.number}, not ${owner}/${repo}#${number}. ` +
337
357
  `Read the ids for this pull request with repo_pr_threads.`);
338
358
  // Resolve-only. The reply and the resolve are two calls, so they fail
339
359
  // independently — and the tool that tells a caller "the reply landed,
@@ -341,19 +361,19 @@ export function reviewTools(ctx) {
341
361
  // only route back is a second reply the reviewer has already read.
342
362
  if (body === undefined) {
343
363
  if (resolve === false)
344
- return "nothing to do: give a body to reply, or leave resolve unset to resolve the thread";
345
- const only = await forgeGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
346
- return only.ok
347
- ? `resolved ${threadId}`
348
- : bounded(`resolving the thread failed: ${only.message}`);
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}`;
349
369
  }
350
- const replied = await forgeGraphql("repo_pr_thread_reply", THREAD_REPLY_MUTATION, { id: threadId, body });
370
+ const replied = await githubGraphql("repo_pr_thread_reply", THREAD_REPLY_MUTATION, { id: threadId, body });
351
371
  if (!replied.ok)
352
372
  // The hazard `repo_pr_comment` names, and worse here: a reply that
353
373
  // landed and an answer that did not arrive look identical, and the
354
374
  // retry leaves the reviewer two of them.
355
- return bounded(`${replied.message}\nIf this was a timeout rather than a rejection the ` +
356
- `reply may exist anyway — read the thread back with repo_pr_threads before retrying.`);
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.`));
357
377
  const url = replied.data.addPullRequestReviewThreadReply?.comment?.url ?? "(no url)";
358
378
  if (resolve === false)
359
379
  return `replied: ${url} (left unresolved)`;
@@ -361,7 +381,7 @@ export function reviewTools(ctx) {
361
381
  // thread that was answered and not resolved needs resolving, and one
362
382
  // that was never answered needs answering. Saying "it failed" covers
363
383
  // both and tells the model to do the wrong one.
364
- const resolved = await forgeGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
384
+ const resolved = await githubGraphql("repo_pr_thread_reply", THREAD_RESOLVE_MUTATION, { id: threadId });
365
385
  if (!resolved.ok)
366
386
  return bounded(`replied: ${url}\nbut resolving the thread failed: ${resolved.message}\n` +
367
387
  `The reply has landed — do not send it again. Call repo_pr_thread_reply on the same ` +
@@ -4,7 +4,7 @@ import { PROTECTED_BRANCHES, UNSAFE_BRANCH } from "./url.js";
4
4
  import { resolveDefaultBranch } from "./checkout.js";
5
5
  /** Reading the working tree, committing it, and publishing the branch. */
6
6
  export function worktreeTools(ctx) {
7
- const { author, bounded, logFailure, plain, origin, pushBranch } = ctx;
7
+ const { author, bounded, config, fetchOrigin, logFailure, plain, origin, pushBranch } = ctx;
8
8
  return {
9
9
  repo_status: tool({
10
10
  description: "Show which files changed in the checkout.",
@@ -22,26 +22,72 @@ export function worktreeTools(ctx) {
22
22
  }
23
23
  }),
24
24
  repo_diff: tool({
25
- description: "Show the current diff. Read this before committing — it is the cheapest way to catch an edit that did more than you intended. Pass stat:true first on a large change to see which files moved and by how much, then read the full diff of what matters.",
25
+ description: "Show a diff. With no ref, the uncommitted changes in the checkout — read this before committing, it is the cheapest way to catch an edit that did more than you intended. With a ref, everything that ref adds, which is how you review work that arrived on a branch rather than in your own tree. Pass stat:true first on a large change to see which files moved and by how much, then read the full diff of what matters.",
26
26
  inputSchema: z.object({
27
27
  dir: z.string().describe("Checkout directory"),
28
28
  staged: z.boolean().optional().describe("Show staged changes instead"),
29
+ ref: z
30
+ .string()
31
+ .optional()
32
+ .describe("Review a ref instead of the working tree — e.g. 'origin/coder/add-json-flag'. Fetch it first; a ref this checkout has never seen cannot be diffed."),
33
+ base: z
34
+ .string()
35
+ .optional()
36
+ .describe("Review what this checkout's own commits add since a ref — e.g. 'origin/main' — the other direction from `ref`. For a branch a sub-agent committed in a worktree you have switched into."),
29
37
  stat: z
30
38
  .boolean()
31
39
  .optional()
32
40
  .describe("Summarise as a per-file changed-line count instead of the full patch")
33
41
  }),
34
- execute: async ({ dir, staged, stat }) => {
42
+ execute: async ({ dir, staged, stat, ref, base }) => {
43
+ if (ref !== undefined && base !== undefined)
44
+ return "pass `ref` or `base`, not both — `ref` is what another ref adds, `base` is what this checkout's commits add";
45
+ if (base !== undefined && (base === "" || UNSAFE_BRANCH.test(base)))
46
+ return `"${base}" is not a plain ref — pass something like "origin/main"`;
47
+ // Shape-checked for the same reason `repo_push` checks a branch: a ref is
48
+ // model-authored, and `git diff -x` reads a leading `-` as an option
49
+ // rather than a name. `UNSAFE_BRANCH` already refuses that and the
50
+ // traversal spellings.
51
+ // Empty is refused with the rest: it reads as "no ref" below, and the
52
+ // working tree's diff would be reported as the ref's.
53
+ if (ref !== undefined && (ref === "" || UNSAFE_BRANCH.test(ref)))
54
+ return `"${ref}" is not a plain ref — pass something like "origin/coder/add-json-flag"`;
35
55
  const flags = [staged ? "--staged" : "", stat ? "--stat" : ""]
36
56
  .filter(Boolean)
37
57
  .join(" ");
38
- const result = await plain(`diff ${flags}`, dir);
58
+ // `HEAD...<ref>`, and the order is the whole point: `git diff A...B`
59
+ // compares the merge base of the two to **B**, so the reviewed ref has to
60
+ // be on the right. Reversed, this reports what the reviewer's own HEAD
61
+ // gained since the branch diverged — which is the "the branch reverted
62
+ // things" reading it exists to avoid, printed with confidence.
63
+ //
64
+ // The ref goes in an env var and is never interpolated — see `shell`'s
65
+ // `vars`, which carries why. A `--` would not be enough on its own here,
66
+ // because the injection this prevents is a second shell command.
67
+ const result = ref
68
+ ? await plain(`diff ${flags} HEAD..."$REPO_REF" --`, dir, {
69
+ REPO_REF: ref
70
+ })
71
+ : base
72
+ ? // The same rule from the other side: the checkout's HEAD on the
73
+ // right, so what is reported is what its commits add.
74
+ await plain(`diff ${flags} "$REPO_BASE"...HEAD --`, dir, {
75
+ REPO_BASE: base
76
+ })
77
+ : await plain(`diff ${flags}`, dir);
39
78
  // Same reasoning as `repo_status`: "(no diff)" and "the diff could not
40
79
  // be read" are opposite answers, and this is the tool a reviewing agent
41
80
  // trusts most.
42
81
  if (!result.success) {
43
82
  logFailure("repo_diff", result);
44
- return bounded(`could not read the diff in ${dir}: ${result.stderr || result.stdout}`);
83
+ const named = ref ?? base;
84
+ return bounded(named
85
+ ? // Names the ref and the likely cause: the common failure is a ref
86
+ // this checkout has never fetched, and "unknown revision" on its
87
+ // own does not say that.
88
+ `could not diff "${named}" in ${dir} — fetch it first if it has not ` +
89
+ `been fetched: ${result.stderr || result.stdout}`
90
+ : `could not read the diff in ${dir}: ${result.stderr || result.stdout}`);
45
91
  }
46
92
  // Truncated from the middle rather than the end: the head of a diff and
47
93
  // its tail are both informative, and the middle of a large one rarely
@@ -59,7 +105,7 @@ export function worktreeTools(ctx) {
59
105
  execute: async ({ dir, message }) => {
60
106
  // Checked rather than fired and forgotten. A failed `add` leaves the
61
107
  // index holding less than the model believes, and the commit that
62
- // follows still succeeds — so the round reports a commit that quietly
108
+ // follows still succeeds — so the turn reports a commit that quietly
63
109
  // does not contain the change.
64
110
  const staged = await plain("add -A", dir);
65
111
  if (!staged.success) {
@@ -89,13 +135,41 @@ export function worktreeTools(ctx) {
89
135
  return bounded(result.stdout.trim());
90
136
  }
91
137
  }),
138
+ /**
139
+ * Bring the remote's branches into a checkout without touching its tree.
140
+ *
141
+ * `repo_clone` fetches too, but it resets the tree afterwards and only ever
142
+ * reaches the checkout it clones into — so work pushed to a branch by
143
+ * somebody else, a submodule's included, had no way into a checkout that
144
+ * already existed. Nothing here writes to the working tree, so there is no
145
+ * refusal path for uncommitted work.
146
+ */
147
+ repo_fetch: tool({
148
+ description: "Fetch every branch from a checkout's own origin, without touching its working tree. This is how a branch someone else pushed becomes a ref you can review: after it, repo_diff with ref 'origin/<branch>'. Pass the directory of the repository the branch was pushed to — in a superproject, that is the submodule's directory.",
149
+ inputSchema: z.object({ dir: z.string().describe("Checkout directory") }),
150
+ execute: async ({ dir }) => {
151
+ // The checkout's own origin, for the reason `repo_push` reads it: it has
152
+ // already passed the allowlist, and a URL named here would not have.
153
+ const { remote, unreachable } = await origin(dir);
154
+ if (unreachable)
155
+ return bounded(`could not fetch in ${dir}: ${unreachable}`);
156
+ if (!remote)
157
+ return `${dir} has no origin on an allowed host — clone it with repo_clone first`;
158
+ const fetched = await fetchOrigin(dir, remote.url);
159
+ if (!fetched.success) {
160
+ logFailure("repo_fetch", fetched);
161
+ return bounded(`fetch failed: ${fetched.stderr || fetched.stdout}`);
162
+ }
163
+ return `fetched ${remote.url} into ${dir}; its branches are under origin/ — review one with repo_diff and ref "origin/<branch>"`;
164
+ }
165
+ }),
92
166
  repo_push: tool({
93
- description: "Push a branch to the remote. Refuses to push to a protected branch and refuses to force-push — open a pull request instead.",
167
+ description: "Push a branch to the remote: one this checkout holds, or a new one created at its current commit. Refuses a protected branch, a force-push, and a branch that adds no commit the remote does not already have — open a pull request instead.",
94
168
  inputSchema: z.object({
95
169
  dir: z.string().describe("Checkout directory"),
96
170
  branch: z
97
171
  .string()
98
- .describe("Branch name to create and push, e.g. 'coder/add-json-flag'")
172
+ .describe("The branch your commits are on, or a new name to create at the current commit, e.g. 'coder/add-json-flag'")
99
173
  }),
100
174
  execute: async ({ dir, branch }) => {
101
175
  // Enforced here rather than in the prompt: a guardrail a model can talk
@@ -111,7 +185,7 @@ export function worktreeTools(ctx) {
111
185
  // The repository's *own* default, which is often none of the four names
112
186
  // above: a repo whose trunk is `release` deserves the same protection.
113
187
  // A command that never ran stops here rather than standing the guards
114
- // down — the same rule as the two probes further down, and the reason
188
+ // down — the same rule as the probes further down, and the reason
115
189
  // {@link resolveDefaultBranch} reports the two separately.
116
190
  const head = await resolveDefaultBranch(plain, dir);
117
191
  if (head.unreachable)
@@ -124,6 +198,62 @@ export function worktreeTools(ctx) {
124
198
  return bounded(`could not push "${branch}": ${unreachable}`);
125
199
  if (!remote)
126
200
  return `${dir} has no origin on an allowed host — clone it with repo_clone first`;
201
+ const exists = await plain(`rev-parse --verify --quiet "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
202
+ // Same reasoning as the probe in `repo_clone`: `!success` means "no such
203
+ // branch, create it" only when git was there to say so.
204
+ if (exists.unreachable)
205
+ return bounded(`could not push "${branch}": ${exists.stderr}`);
206
+ // The commit the push would publish: the branch's own, or HEAD for a
207
+ // branch this creates, which is where `checkout -b` puts it.
208
+ const tip = await plain(`log -1 --format=%H%n%s "$REPO_REF" --`, dir, {
209
+ REPO_REF: exists.success ? `refs/heads/${branch}` : "HEAD"
210
+ });
211
+ if (tip.unreachable)
212
+ return bounded(`could not push "${branch}": ${tip.stderr}`);
213
+ const [commit = "", subject = ""] = tip.stdout.trim().split("\n");
214
+ if (!tip.success || !commit) {
215
+ logFailure("repo_push", tip);
216
+ return bounded(`could not resolve "${branch}" to a commit: ${tip.stderr || tip.stdout}`);
217
+ }
218
+ const short = commit.slice(0, 7);
219
+ // Nothing to push is refused — the README's "Guardrails" says why — and
220
+ // measured against every remote branch, since a pull request's base need
221
+ // not be the default. The branch's own remote ref is left out so a landed
222
+ // push can be retried: `--exclude` wants it without `refs/remotes/`, and
223
+ // with that prefix excludes nothing. Checked before the branch is
224
+ // created, so a refusal leaves none behind for a retry to find.
225
+ const fresh = await plain(`rev-list --count "$REPO_TIP" --not --exclude="origin/$REPO_BRANCH" --remotes=origin`, dir, { REPO_TIP: commit, REPO_BRANCH: branch });
226
+ if (fresh.unreachable)
227
+ return bounded(`could not push "${branch}": ${fresh.stderr}`);
228
+ const count = fresh.stdout.trim();
229
+ // A comparison with no answer is not a pass, and an empty one is not 0.
230
+ if (!fresh.success || !/^\d+$/.test(count)) {
231
+ logFailure("repo_push", fresh);
232
+ return bounded(`could not tell whether "${branch}" has anything to push: comparing it ` +
233
+ `against the remote's branches failed with ` +
234
+ `${fresh.stderr || fresh.stdout || "no output"}\n` +
235
+ `Nothing was pushed. Pushing without that answer risks an empty pull ` +
236
+ `request reported as success, so check repo_status and repo_diff first.`);
237
+ }
238
+ if (count === "0") {
239
+ // Where the work went is the host's to say, when it keeps worktrees:
240
+ // a writing sub-agent's commits are on its worktree's branch.
241
+ let held;
242
+ try {
243
+ held = await config.worktrees?.list();
244
+ }
245
+ catch (err) {
246
+ console.warn("[repo] worktrees.list failed", { err: String(err) });
247
+ }
248
+ return bounded(`refusing to push "${branch}" — ` +
249
+ (exists.success
250
+ ? ""
251
+ : `it is not a branch in ${dir}, so it would be created at the current commit, and `) +
252
+ `${short} adds no commit the remote does not already have, so the pull ` +
253
+ `request would be empty. Either the change was never committed, or it was ` +
254
+ `committed on a different branch` +
255
+ (held ? `.\n\n${held}` : ` — check repo_status and repo_diff.`));
256
+ }
127
257
  // Switch to the branch, or create it — but never *reset* it.
128
258
  //
129
259
  // `-b`, never `-B`: `-B` is create-or-reset, so on a branch that already
@@ -131,11 +261,6 @@ export function worktreeTools(ctx) {
131
261
  // survives only as an unreferenced object, and what gets pushed is an
132
262
  // empty branch with a pull request opened on it — a complete loss
133
263
  // dressed up as success.
134
- const exists = await plain(`rev-parse --verify --quiet "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
135
- // Same reasoning as the probe in `repo_clone`: `!success` means "no such
136
- // branch, create it" only when git was there to say so.
137
- if (exists.unreachable)
138
- return bounded(`could not push "${branch}": ${exists.stderr}`);
139
264
  const checkout = await plain(exists.success
140
265
  ? `checkout "$REPO_BRANCH"`
141
266
  : `checkout -b "$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
@@ -143,56 +268,15 @@ export function worktreeTools(ctx) {
143
268
  logFailure("repo_push", checkout);
144
269
  return bounded(`could not switch to branch "${branch}": ${checkout.stderr || checkout.stdout}`);
145
270
  }
146
- // Nothing to push is a bug upstream of here, not a no-op.
147
- //
148
- // A branch level with the default branch means the commit went
149
- // somewhere else, or was never made. Pushing it succeeds, `repo_open_pr`
150
- // opens an empty pull request, and the round reports a URL as if the
151
- // work had landed — the one outcome worse than an error.
152
- //
153
- // The guard is skipped only when there is no baseline to compare
154
- // against: a repository with no `origin/HEAD` is unusual but legitimate,
155
- // and guessing is worse than not guarding. Once there *is* one, a
156
- // comparison that fails is an unanswered question rather than a pass —
157
- // the same rule the three probes around this one follow.
158
- if (defaultBranch) {
159
- const ahead = await plain(`rev-list --count "origin/$REPO_BASE..HEAD"`, dir, {
160
- REPO_BASE: defaultBranch
161
- });
162
- if (ahead.unreachable)
163
- return bounded(`could not push "${branch}": ${ahead.stderr}`);
164
- if (!ahead.success) {
165
- logFailure("repo_push", ahead);
166
- return bounded(`could not tell whether "${branch}" has anything to push: comparing it ` +
167
- `against origin/${defaultBranch} failed with ` +
168
- `${ahead.stderr || ahead.stdout || "no output"}\n` +
169
- `Nothing was pushed. Pushing without that answer risks an empty pull ` +
170
- `request reported as success, so check repo_status and repo_diff first.`);
171
- }
172
- if (ahead.stdout.trim() === "0") {
173
- return (`refusing to push "${branch}" — it has no commits that origin/${defaultBranch} ` +
174
- `does not already have, so the pull request would be empty. Check repo_status ` +
175
- `and repo_diff: either the change was never committed, or it was committed on ` +
176
- `a different branch.`);
177
- }
178
- }
179
- // A pre-flight check, not a value anyone downstream consumes: `push`
180
- // takes the branch *name* and resolves it itself, against the same
181
- // files. What this buys is the failure arriving here, where the sentence
182
- // can name the branch and the checkout, instead of surfacing as a push
183
- // that rejects a ref nobody can see.
184
- const tip = await plain(`rev-parse --verify "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
185
- if (!tip.success || !tip.stdout.trim()) {
186
- logFailure("repo_push", tip);
187
- return bounded(`could not resolve "${branch}" to a commit: ${tip.stderr || tip.stdout}`);
188
- }
189
271
  const result = await pushBranch(dir, remote.url, branch);
190
272
  if (!result.success) {
191
273
  logFailure("repo_push", result);
192
274
  return bounded(`push failed: ${result.stderr || result.stdout}`);
193
275
  }
276
+ const pushed = `pushed ${branch} at ${short} "${subject}", ${count} ` +
277
+ `${count === "1" ? "commit" : "commits"} no other branch on the remote has`;
194
278
  // What `--set-upstream` would do as a side effect of the push, written
195
- // here because the push happens on the host's side: a subagent reaching
279
+ // here because the push happens on the host's side: a sub-agent reaching
196
280
  // for a bare `git push` in the shell should still find the branch
197
281
  // tracking something.
198
282
  //
@@ -208,13 +292,13 @@ export function worktreeTools(ctx) {
208
292
  // "repo_push failed" next to a branch that is on the remote is worse
209
293
  // than no log line.
210
294
  logFailure("repo_push (upstream tracking)", untracked);
211
- return bounded(`pushed ${branch}, but could not record what it tracks: ` +
295
+ return bounded(`${pushed}, but could not record what it tracks: ` +
212
296
  `${untracked.stderr || untracked.stdout}\n` +
213
297
  `The push itself landed. The only consequence is local: a bare ` +
214
298
  `\`git push\` from the shell will not know where to send this ` +
215
299
  `branch, and needs \`git push -u origin ${branch}\` once.`);
216
300
  }
217
- return `pushed ${branch}`;
301
+ return bounded(pushed);
218
302
  }
219
303
  })
220
304
  };
@@ -0,0 +1,12 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { RepoWorktrees } from "./index.js";
3
+ /**
4
+ * Moving between the host's worktrees — not to be confused with
5
+ * `./tools-worktree.ts`, which reads and writes the one checkout the tools point
6
+ * at.
7
+ *
8
+ * Thin by design: which worktrees exist and what a switch reaches are the host's
9
+ * facts — see {@link RepoWorktrees} — so these tools check a branch's shape and
10
+ * pass the host's sentence through.
11
+ */
12
+ export declare function worktreesTools(worktrees: RepoWorktrees): ToolSet;
@@ -0,0 +1,48 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { UNSAFE_BRANCH } from "./url.js";
4
+ /**
5
+ * Moving between the host's worktrees — not to be confused with
6
+ * `./tools-worktree.ts`, which reads and writes the one checkout the tools point
7
+ * at.
8
+ *
9
+ * Thin by design: which worktrees exist and what a switch reaches are the host's
10
+ * facts — see {@link RepoWorktrees} — so these tools check a branch's shape and
11
+ * pass the host's sentence through.
12
+ */
13
+ export function worktreesTools(worktrees) {
14
+ const refuse = (branch) => `"${branch}" is not a plain branch name — pass it as the sub-agent's report named it`;
15
+ const unsafe = (branch) => branch === "" || UNSAFE_BRANCH.test(branch);
16
+ return {
17
+ repo_worktrees: tool({
18
+ description: "List the worktrees your writing sub-agents committed in: each one's branch, whether a session is still working in it, and whether its commits are pushed. Pass `release` with a branch to give up its worktree once you have decided not to keep that work — unpushed commits in it are lost.",
19
+ inputSchema: z.object({
20
+ release: z
21
+ .string()
22
+ .optional()
23
+ .describe("A branch whose worktree to give up")
24
+ }),
25
+ execute: async ({ release }) => {
26
+ if (release === undefined)
27
+ return await worktrees.list();
28
+ if (unsafe(release))
29
+ return refuse(release);
30
+ return await worktrees.release(release);
31
+ }
32
+ }),
33
+ repo_worktree: tool({
34
+ description: "Point every repo tool and your file reads at the worktree holding a branch a writing sub-agent reported, to read what it did. They stay there until you call this again; call it with no branch to come back to your own checkout.",
35
+ inputSchema: z.object({
36
+ branch: z
37
+ .string()
38
+ .optional()
39
+ .describe("The branch a sub-agent's report named, e.g. 'claude-coordinator/<task>/<n>'; omit to go back to your own checkout")
40
+ }),
41
+ execute: async ({ branch }) => {
42
+ if (branch !== undefined && unsafe(branch))
43
+ return refuse(branch);
44
+ return await worktrees.use(branch);
45
+ }
46
+ })
47
+ };
48
+ }
@@ -6,7 +6,7 @@
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
- /** The forges a clone URL may name. `RepoConfig.allowedHosts` overrides it. */
9
+ /** The hosts a clone URL may name. `RepoConfig.allowedHosts` overrides it. */
10
10
  export declare const DEFAULT_ALLOWED_HOSTS: string[];
11
11
  /** Branch names a push must never target, whatever the model believes. */
12
12
  export declare const PROTECTED_BRANCHES: Set<string>;
@@ -50,7 +50,7 @@ export declare function sameRepoUrl(a: string, b: string): boolean;
50
50
  *
51
51
  * Anchored on the **parsed host** rather than matched in the string, so
52
52
  * `https://evil.example.com/github.com/owner/repo` is not a github.com URL.
53
- * Both names are then checked against {@link isForgeName}, so a name that could
53
+ * Both names are then checked against {@link isGitHubName}, so a name that could
54
54
  * traverse a path or split somebody else's key never gets as far as being one.
55
55
  *
56
56
  * `undefined` is a refusal at every caller, never a reason to carry on with a