@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,305 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { PROTECTED_BRANCHES, UNSAFE_BRANCH } from "./url.js";
4
+ import { resolveDefaultBranch } from "./checkout.js";
5
+ /** Reading the working tree, committing it, and publishing the branch. */
6
+ export function worktreeTools(ctx) {
7
+ const { author, bounded, config, fetchOrigin, logFailure, plain, origin, pushBranch } = ctx;
8
+ return {
9
+ repo_status: tool({
10
+ description: "Show which files changed in the checkout.",
11
+ inputSchema: z.object({ dir: z.string().describe("Checkout directory") }),
12
+ execute: async ({ dir }) => {
13
+ const result = await plain("status --short", dir);
14
+ // "(no changes)" is a claim about the tree. A command that failed
15
+ // supports no claim about anything, and reporting one as the other sends
16
+ // the model on to commit against a repository it cannot read.
17
+ if (!result.success) {
18
+ logFailure("repo_status", result);
19
+ return bounded(`could not read the status of ${dir}: ${result.stderr || result.stdout}`);
20
+ }
21
+ return bounded(result.stdout.trim()) || "(no changes)";
22
+ }
23
+ }),
24
+ repo_diff: tool({
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
+ inputSchema: z.object({
27
+ dir: z.string().describe("Checkout directory"),
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."),
37
+ stat: z
38
+ .boolean()
39
+ .optional()
40
+ .describe("Summarise as a per-file changed-line count instead of the full patch")
41
+ }),
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"`;
55
+ const flags = [staged ? "--staged" : "", stat ? "--stat" : ""]
56
+ .filter(Boolean)
57
+ .join(" ");
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);
78
+ // Same reasoning as `repo_status`: "(no diff)" and "the diff could not
79
+ // be read" are opposite answers, and this is the tool a reviewing agent
80
+ // trusts most.
81
+ if (!result.success) {
82
+ logFailure("repo_diff", result);
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}`);
91
+ }
92
+ // Truncated from the middle rather than the end: the head of a diff and
93
+ // its tail are both informative, and the middle of a large one rarely
94
+ // is. A model that needs the part that was dropped can ask for `stat`
95
+ // and then read the file.
96
+ return bounded(result.stdout.trim()) || "(no diff)";
97
+ }
98
+ }),
99
+ repo_commit: tool({
100
+ description: "Stage everything and commit. Write the message for a reviewer reading it in the log a year from now: what changed and why, not how.",
101
+ inputSchema: z.object({
102
+ dir: z.string().describe("Checkout directory"),
103
+ message: z.string().describe("Commit message")
104
+ }),
105
+ execute: async ({ dir, message }) => {
106
+ // Checked rather than fired and forgotten. A failed `add` leaves the
107
+ // index holding less than the model believes, and the commit that
108
+ // follows still succeeds — so the turn reports a commit that quietly
109
+ // does not contain the change.
110
+ const staged = await plain("add -A", dir);
111
+ if (!staged.success) {
112
+ logFailure("repo_commit", staged);
113
+ return bounded(`could not stage the changes in ${dir}: ${staged.stderr || staged.stdout}`);
114
+ }
115
+ // The message is model-authored free text — quotes, backticks, newlines,
116
+ // `$(…)`. Expanded from the environment inside the container, all of
117
+ // that is inert; interpolated into the command string, none of it is.
118
+ const result = await plain(`commit -m "$GIT_COMMIT_MESSAGE"`, dir, {
119
+ GIT_COMMIT_MESSAGE: message,
120
+ // On the commit as well as in the checkout's config, and this is
121
+ // the copy that is current: config is written when a checkout is set
122
+ // up, and the environment is read at the commit. A workspace that
123
+ // outlived a change of identity commits under the new one.
124
+ GIT_AUTHOR_NAME: author.name,
125
+ GIT_AUTHOR_EMAIL: author.email,
126
+ GIT_COMMITTER_NAME: author.name,
127
+ GIT_COMMITTER_EMAIL: author.email
128
+ });
129
+ if (!result.success && /nothing to commit/i.test(result.stdout))
130
+ return "nothing to commit — the working tree is clean";
131
+ if (!result.success) {
132
+ logFailure("repo_commit", result);
133
+ return bounded(`commit failed: ${result.stderr || result.stdout}`);
134
+ }
135
+ return bounded(result.stdout.trim());
136
+ }
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
+ }),
166
+ repo_push: tool({
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.",
168
+ inputSchema: z.object({
169
+ dir: z.string().describe("Checkout directory"),
170
+ branch: z
171
+ .string()
172
+ .describe("The branch your commits are on, or a new name to create at the current commit, e.g. 'coder/add-json-flag'")
173
+ }),
174
+ execute: async ({ dir, branch }) => {
175
+ // Enforced here rather than in the prompt: a guardrail a model can talk
176
+ // itself out of is not a guardrail.
177
+ //
178
+ // Shape first, then the name. `git push origin <name>` reads `<name>`
179
+ // as a refspec, so `+x:main` is a force push to main that the name
180
+ // check below would wave through — it only ever sees a literal string.
181
+ if (UNSAFE_BRANCH.test(branch))
182
+ return `"${branch}" is not a plain branch name — use something like "coder/add-json-flag"`;
183
+ if (PROTECTED_BRANCHES.has(branch))
184
+ return `refusing to push to "${branch}" — push a work branch and open a pull request`;
185
+ // The repository's *own* default, which is often none of the four names
186
+ // above: a repo whose trunk is `release` deserves the same protection.
187
+ // A command that never ran stops here rather than standing the guards
188
+ // down — the same rule as the probes further down, and the reason
189
+ // {@link resolveDefaultBranch} reports the two separately.
190
+ const head = await resolveDefaultBranch(plain, dir);
191
+ if (head.unreachable)
192
+ return bounded(`could not push "${branch}": ${head.unreachable}`);
193
+ const defaultBranch = head.branch;
194
+ if (defaultBranch && defaultBranch === branch)
195
+ return `refusing to push to "${branch}" — it is this repository's default branch; push a work branch and open a pull request`;
196
+ const { remote, unreachable } = await origin(dir);
197
+ if (unreachable)
198
+ return bounded(`could not push "${branch}": ${unreachable}`);
199
+ if (!remote)
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
+ }
257
+ // Switch to the branch, or create it — but never *reset* it.
258
+ //
259
+ // `-b`, never `-B`: `-B` is create-or-reset, so on a branch that already
260
+ // holds the commit it force-moves it to wherever HEAD is now. The commit
261
+ // survives only as an unreferenced object, and what gets pushed is an
262
+ // empty branch with a pull request opened on it — a complete loss
263
+ // dressed up as success.
264
+ const checkout = await plain(exists.success
265
+ ? `checkout "$REPO_BRANCH"`
266
+ : `checkout -b "$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
267
+ if (!checkout.success) {
268
+ logFailure("repo_push", checkout);
269
+ return bounded(`could not switch to branch "${branch}": ${checkout.stderr || checkout.stdout}`);
270
+ }
271
+ const result = await pushBranch(dir, remote.url, branch);
272
+ if (!result.success) {
273
+ logFailure("repo_push", result);
274
+ return bounded(`push failed: ${result.stderr || result.stdout}`);
275
+ }
276
+ const pushed = `pushed ${branch} at ${short} "${subject}", ${count} ` +
277
+ `${count === "1" ? "commit" : "commits"} no other branch on the remote has`;
278
+ // What `--set-upstream` would do as a side effect of the push, written
279
+ // here because the push happens on the host's side: a sub-agent reaching
280
+ // for a bare `git push` in the shell should still find the branch
281
+ // tracking something.
282
+ //
283
+ // Checked, but *reported* rather than raised: this is the one place in
284
+ // the plugin where a failed command is not a failed operation. The push
285
+ // has landed, saying so is the important half, and all that is lost is
286
+ // the convenience these two keys buy.
287
+ const remoteSet = await plain(`config "branch.$REPO_BRANCH.remote" origin`, dir, { REPO_BRANCH: branch });
288
+ const mergeSet = await plain(`config "branch.$REPO_BRANCH.merge" "refs/heads/$REPO_BRANCH"`, dir, { REPO_BRANCH: branch });
289
+ const untracked = [remoteSet, mergeSet].find((r) => !r.success);
290
+ if (untracked) {
291
+ // Named apart from the push, which succeeded — a log line reading
292
+ // "repo_push failed" next to a branch that is on the remote is worse
293
+ // than no log line.
294
+ logFailure("repo_push (upstream tracking)", untracked);
295
+ return bounded(`${pushed}, but could not record what it tracks: ` +
296
+ `${untracked.stderr || untracked.stdout}\n` +
297
+ `The push itself landed. The only consequence is local: a bare ` +
298
+ `\`git push\` from the shell will not know where to send this ` +
299
+ `branch, and needs \`git push -u origin ${branch}\` once.`);
300
+ }
301
+ return bounded(pushed);
302
+ }
303
+ })
304
+ };
305
+ }
@@ -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
package/dist/repo/url.js CHANGED
@@ -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 const DEFAULT_ALLOWED_HOSTS = ["github.com"];
11
11
  /** Branch names a push must never target, whatever the model believes. */
12
12
  export const PROTECTED_BRANCHES = new Set([
@@ -88,7 +88,7 @@ export function sameRepoUrl(a, b) {
88
88
  return canonical(a) === canonical(b);
89
89
  }
90
90
  /**
91
- * A name a forge would actually issue — and one that is safe to use as a path
91
+ * A name GitHub would actually issue — and one that is safe to use as a path
92
92
  * segment and as a key.
93
93
  *
94
94
  * GitHub's rule for an owner and a repository alike is alphanumerics, `-`, `_`
@@ -101,17 +101,17 @@ export function sameRepoUrl(a, b) {
101
101
  * is a separator in somebody else's key format, which is how two callers end up
102
102
  * sharing one workspace.
103
103
  */
104
- const FORGE_NAME = /^[A-Za-z0-9._-]+$/;
105
- function isForgeName(name) {
104
+ const GITHUB_NAME = /^[A-Za-z0-9._-]+$/;
105
+ function isGitHubName(name) {
106
106
  // `.` and `..` pass the character class and are exactly the two that must not.
107
- return name !== "." && name !== ".." && FORGE_NAME.test(name);
107
+ return name !== "." && name !== ".." && GITHUB_NAME.test(name);
108
108
  }
109
109
  /**
110
110
  * `owner/repo` out of a repository URL, or `undefined` if it is not one.
111
111
  *
112
112
  * Anchored on the **parsed host** rather than matched in the string, so
113
113
  * `https://evil.example.com/github.com/owner/repo` is not a github.com URL.
114
- * Both names are then checked against {@link isForgeName}, so a name that could
114
+ * Both names are then checked against {@link isGitHubName}, so a name that could
115
115
  * traverse a path or split somebody else's key never gets as far as being one.
116
116
  *
117
117
  * `undefined` is a refusal at every caller, never a reason to carry on with a
@@ -125,7 +125,7 @@ export function parseRepo(url, allowedHosts = DEFAULT_ALLOWED_HOSTS) {
125
125
  if (!match)
126
126
  return undefined;
127
127
  const [, owner, repo] = match;
128
- if (!isForgeName(owner) || !isForgeName(repo))
128
+ if (!isGitHubName(owner) || !isGitHubName(repo))
129
129
  return undefined;
130
130
  return { owner, repo };
131
131
  }
@@ -42,7 +42,7 @@ import type { AgentPlugin } from "@dynamicagents/core";
42
42
  /** Where a scratchpad lives, unless the host says otherwise. */
43
43
  export declare const DEFAULT_SCRATCH_DIR = "/workspace/scratch";
44
44
  /**
45
- * Run one command in the host's container. Matches `computerExec`'s shape.
45
+ * Run one command in the host's container. Matches `workspaceExec`'s shape.
46
46
  *
47
47
  * Injected rather than imported, like `/repo`'s: `npm run verify:exports` fails
48
48
  * any subpath whose module graph reaches a sibling's, and a scratchpad that
@@ -54,7 +54,7 @@ export type ScratchExec = (command: string, options?: {
54
54
  env?: Record<string, string | undefined>;
55
55
  timeout?: number;
56
56
  /**
57
- * The executing subtask's runtime state, forwarded **opaquely** — the same
57
+ * The running sub-agent's `runtime()`, forwarded **opaquely** — the same
58
58
  * pass-through `/repo` does, for the same reason. This plugin never looks
59
59
  * inside it.
60
60
  */
@@ -96,7 +96,7 @@ export interface ScratchConfig {
96
96
  * Runs commands in the container holding the workspace.
97
97
  *
98
98
  * Uncredentialed, and nothing here ever needs a credential: a scratchpad has
99
- * no remote, so no operation in this plugin talks to a forge.
99
+ * no remote, so no operation in this plugin talks to GitHub.
100
100
  */
101
101
  exec: ScratchExec;
102
102
  /** Where the scratchpad lives. Defaults to {@link DEFAULT_SCRATCH_DIR}. */
@@ -153,6 +153,6 @@ export interface ScratchConfig {
153
153
  * carries the same copy for the same reason.
154
154
  */
155
155
  export declare function truncateOutput(text: string, max: number): string;
156
- /** The tool name, exported so a host restricting its main agent can name it. */
156
+ /** The tool name, exported so a host restricting the plugin can name it. */
157
157
  export declare const SCRATCH_OPEN_TOOL = "scratch_open";
158
158
  export declare function scratch(config: ScratchConfig): AgentPlugin;
@@ -68,10 +68,10 @@ export function truncateOutput(text, max) {
68
68
  return text.slice(0, Math.max(0, max));
69
69
  return (text.slice(0, half) + marker(text.length - half * 2) + text.slice(-half));
70
70
  }
71
- /** The tool name, exported so a host restricting its main agent can name it. */
71
+ /** The tool name, exported so a host restricting the plugin can name it. */
72
72
  export const SCRATCH_OPEN_TOOL = "scratch_open";
73
73
  /**
74
- * What the delegating agent is told.
74
+ * What the model is told.
75
75
  *
76
76
  * The mixing rule at the end is the line worth its tokens. A host that keys one
77
77
  * workspace selection per caller — which is what makes a scratchpad routable at
@@ -294,19 +294,20 @@ export function scratch(config) {
294
294
  .filter(Boolean)
295
295
  .join(" "), maxChars);
296
296
  };
297
+ const context = capabilityFor(dir);
297
298
  return definePlugin({
298
- key: "scratch",
299
+ name: "scratch",
299
300
  /**
300
- * The main agent's, and no tool family — which is a decision rather than an
301
- * omission.
301
+ * For a parent, never a sub-agent — which the host decides by installing it
302
+ * on one and not the other.
302
303
  *
303
304
  * Opening a scratchpad *selects a workspace*, exactly as `repo_clone` does.
304
- * A subagent holding this could re-point the workspace its parent prepared
305
+ * A sub-agent holding this could re-point the workspace its parent prepared
305
306
  * half-way through its own run, which is the hazard a host's per-caller
306
- * selection already has to document. A delegated run works in whatever it
307
- * was given.
307
+ * selection already has to document. A sub-agent works in whatever it was
308
+ * given.
308
309
  */
309
- mainAgentTools: () => ({
310
+ tools: (ctx) => ({
310
311
  [SCRATCH_OPEN_TOOL]: tool({
311
312
  description: "Open a scratchpad: a git repository with no remote, in your container, " +
312
313
  "for work that does not need a cloned repository. Use it before " +
@@ -318,9 +319,9 @@ export function scratch(config) {
318
319
  .optional()
319
320
  .describe("Discard everything in the scratchpad first, including files an earlier task left")
320
321
  }),
321
- execute: ({ reset }) => open(reset, undefined)
322
+ execute: ({ reset }) => open(reset, ctx.runtime())
322
323
  })
323
324
  }),
324
- capability: capabilityFor(dir)
325
+ context: [{ provider: { get: async () => context } }]
325
326
  });
326
327
  }
@@ -9,21 +9,20 @@ import type { InstallState } from "./install.js";
9
9
  * and the generation marker. It is right for that and unchanged by this file.
10
10
  *
11
11
  * Every *reader*, though, is asking a different question: **can I rely on this
12
- * workspace right now?** The two diverge, and where they did the old code either
13
- * overloaded a variant or invented a record:
12
+ * workspace right now?** The two diverge, and expressing the second through the
13
+ * first goes wrong in ways that are each easy to reintroduce:
14
14
  *
15
- * - A workspace at its storage ceiling was written as `skipped` — the same
16
- * variant that means "this checkout has nothing to install". One is a hard
17
- * wall about the Durable Object, the other is routine and about the
18
- * repository, and they were told apart only by reading prose. Both readers
19
- * missed the first.
20
- * - Capacity therefore travelled through a channel gated by
21
- * {@link file://./gate.ts needsDependencies}, so a full workspace said nothing
15
+ * - A workspace at its storage ceiling is not `skipped`. That variant means
16
+ * "this checkout has nothing to install" — routine, and about the repository.
17
+ * A ceiling is a hard wall about the Durable Object, and the two are then told
18
+ * apart only by reading prose.
19
+ * - Capacity must not travel through a channel gated by
20
+ * {@link file://../computer/gate.ts needsDependencies}, or a full workspace says nothing
22
21
  * at all to `echo hi > file.txt` — the command whose write is being lost.
23
- * - "The tree is fine now", after a subagent installed by hand, had no way to be
24
- * said except by fabricating a `done` with an invented exit code — and the
25
- * probe it rested on, a bare `test -d node_modules`, is satisfied by the
26
- * wreckage of the very install it was overriding.
22
+ * - "The tree is fine now", after an agent installed by hand, has no honest
23
+ * spelling as a job record: fabricating a `done` rests on the presence of a
24
+ * `node_modules` directory, which the wreckage of the very install being
25
+ * overridden already satisfies.
27
26
  *
28
27
  * So an advisory is **derived at read time** and describes the workspace, not a
29
28
  * job. Its absence is the good case, which is what removes the need to invent
@@ -57,7 +56,7 @@ export type WorkspaceAdvisory = {
57
56
  * Reported rather than acted on, because its presence proves nothing in
58
57
  * either direction. A half-finished `npm ci` leaves the directory behind
59
58
  * with an incomplete tree, so treating existence as success hides the
60
- * common failure outright; and a subagent that re-ran the install by hand
59
+ * common failure outright; and an agent that re-ran the install by hand
61
60
  * leaves a record the host cannot update, since the host cannot see an
62
61
  * install it did not start. Both look identical from here, so the reader
63
62
  * is told which way the ambiguity runs rather than being handed a guess.
@@ -139,11 +138,11 @@ export interface AdvisoryInput {
139
138
  /**
140
139
  * Whether a `node_modules` directory exists — **existence only, not health**.
141
140
  *
142
- * Named for what it can actually observe. A host probes this with the
143
- * equivalent of `test -d`, and that answers a narrower question than
144
- * "are the dependencies fine": a `npm ci` that died partway leaves the
145
- * directory behind holding an incomplete tree, so a `true` here is entirely
146
- * consistent with the install having failed for real.
141
+ * Named for what it can actually observe. A host answers it by looking for the
142
+ * directory in the workspace, and that is a narrower question than "are the
143
+ * dependencies fine": an `npm ci` that died partway leaves the directory
144
+ * behind holding an incomplete tree, so a `true` here is entirely consistent
145
+ * with the install having failed for real.
147
146
  *
148
147
  * So it never suppresses a proven failure. It travels onto the advisory as
149
148
  * {@link WorkspaceAdvisory} `treePresent` and the reader is told which way the
@@ -152,6 +151,20 @@ export interface AdvisoryInput {
152
151
  * mode it was recording.
153
152
  */
154
153
  dependencyTreePresent: boolean;
154
+ /**
155
+ * A reinstall the host has already queued, as the moment it was queued.
156
+ *
157
+ * The difference between "your dependencies are broken" and "your
158
+ * dependencies are being rebuilt", and the two read identically from the
159
+ * durable record alone — an install whose container was replaced writes
160
+ * `failed`, and the arming that will repair it is a separate fact the record
161
+ * does not carry.
162
+ *
163
+ * A production session was briefed `deps-broken` six seconds before the
164
+ * armed reinstall it did not know about finished, and spent its own turns
165
+ * re-running `npm ci` on top of a tree that was already landing.
166
+ */
167
+ reinstallArmedAt?: number;
155
168
  }
156
169
  /**
157
170
  * Everything currently true about a workspace, from what the host can see.
@@ -168,7 +181,7 @@ export declare function deriveAdvisories(input: AdvisoryInput): readonly Workspa
168
181
  /**
169
182
  * Everything a session should know before it starts, or `undefined` for silence.
170
183
  *
171
- * The counterpart to {@link file://./gate.ts execGate}, for the reader that is a
184
+ * The counterpart to {@link file://../computer/gate.ts execGate}, for the reader that is a
172
185
  * whole run rather than one command. It cannot be blocked and does not need to
173
186
  * be: it can install dependencies itself, and the only thing it must not do is
174
187
  * work silently against a workspace that is not what it assumes.