@dynamicagents/plugins 0.16.0 → 0.17.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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/claude-code/cards.d.ts +6 -1
  3. package/dist/claude-code/cards.js +21 -2
  4. package/dist/claude-code/model.d.ts +4 -0
  5. package/dist/claude-code/model.js +74 -7
  6. package/dist/claude-code/verified.d.ts +1 -1
  7. package/dist/claude-code/verified.js +1 -1
  8. package/dist/computer/client.d.ts +9 -1
  9. package/dist/computer/context.js +1 -1
  10. package/dist/computer/index.d.ts +10 -6
  11. package/dist/computer/index.js +34 -17
  12. package/dist/computer/paths.d.ts +26 -2
  13. package/dist/computer/paths.js +51 -5
  14. package/dist/computer/proxy.d.ts +10 -3
  15. package/dist/computer/proxy.js +77 -87
  16. package/dist/computer/render.d.ts +6 -35
  17. package/dist/computer/render.js +5 -43
  18. package/dist/computer/sandbox.d.ts +4 -3
  19. package/dist/computer/sandbox.js +9 -2
  20. package/dist/computer/shell.d.ts +8 -7
  21. package/dist/computer/shell.js +8 -7
  22. package/dist/computer/tools-exec.d.ts +39 -0
  23. package/dist/computer/tools-exec.js +357 -228
  24. package/dist/computer/tools-grep.js +10 -4
  25. package/dist/repo-github/checkout.d.ts +16 -0
  26. package/dist/repo-github/checkout.js +16 -7
  27. package/dist/repo-github/context.d.ts +11 -0
  28. package/dist/repo-github/context.js +1 -0
  29. package/dist/repo-github/skills.d.ts +167 -0
  30. package/dist/repo-github/skills.js +414 -0
  31. package/dist/repo-github/tools-clone.js +25 -4
  32. package/dist/sandbox/header.d.ts +13 -0
  33. package/dist/sandbox/header.js +19 -4
  34. package/dist/sandbox/install-plan.d.ts +1 -1
  35. package/dist/sandbox/install-plan.js +2 -4
  36. package/dist/sandbox/object.d.ts +1 -0
  37. package/dist/sandbox/object.js +21 -13
  38. package/package.json +9 -7
@@ -0,0 +1,414 @@
1
+ /**
2
+ * What skills a checkout carries, and how a prompt may reach each one.
3
+ *
4
+ * A Claude Code session reads the skills of the repository it is running in, and
5
+ * the agent that briefs that session has no other way to learn they are there:
6
+ * the session is a separate process with a context of its own, so a skill it
7
+ * should follow has to be named in the prompt it is given. This is what lets
8
+ * `repo_clone` answer with the list.
9
+ *
10
+ * **Why it is here rather than in the plugin that owns a session.** This package
11
+ * fails any subpath whose module graph reaches a sibling's, so the plugin that
12
+ * holds the checkout is the only one that can read it. What belongs to the
13
+ * session's own domain is the *policy*, which is to say when a skill should be
14
+ * named, and that stays with the host writing the prompt. This module reports
15
+ * what is in the checkout and what each skill's own frontmatter says about
16
+ * reaching it.
17
+ *
18
+ * The split inside the module is the one {@link file://./checkout.ts checkout.ts}
19
+ * uses: a runner arrives as a parameter and everything that reads is pure, so
20
+ * the parsing and the rendering are tested without a container.
21
+ */
22
+ /**
23
+ * Where Claude Code reads a project's skills from, relative to the checkout.
24
+ *
25
+ * One level of it: `<dir>/<name>/SKILL.md` and no deeper, which is the layout a
26
+ * session loads. A skill a repository keeps somewhere else of its own is not
27
+ * collected and is not reported as missing either, because nothing here can tell
28
+ * such a directory from any other.
29
+ */
30
+ export const SKILLS_DIR = ".claude/skills";
31
+ /**
32
+ * The backstop on each `SKILL.md` read: a byte ceiling, and a line count on what
33
+ * is left after it.
34
+ *
35
+ * Neither is where frontmatter *ends*. {@link READ_SKILLS} stops at the closing
36
+ * delimiter, which is the one place that decides it, and these stop a file
37
+ * carrying no such delimiter from sending a whole skill body through the read:
38
+ * frontmatter is at the top of a file and a body runs to tens of kilobytes, so
39
+ * without them a directory of dozens of skills is not a read worth making.
40
+ *
41
+ * They are a backstop rather than a limit because a bound that cuts real
42
+ * frontmatter costs more than a long read does: a field nobody saw is a field
43
+ * {@link invocationOf} cannot report, and a restriction sits wherever in the
44
+ * block its author put it, which in the prompt-only skill this was written
45
+ * against is below a description of several lines. The longest frontmatter in
46
+ * this workspace is 15 lines and 938 bytes, its description 831 characters of
47
+ * that, and these bounds are several times both.
48
+ *
49
+ * Stopping at the delimiter is most of what the read costs, too: this
50
+ * workspace's own set of skills arrives in 9,936 bytes, where a bounded head of
51
+ * every file carries a page of each body along with it and costs five times
52
+ * that.
53
+ *
54
+ * **Bytes are cut by `head` and lines by `awk`**, each by the tool that can do it
55
+ * without holding the file: `head -c` is what keeps a file whose top is one
56
+ * enormous line out of `awk`'s `$0`, and `awk` prints whole lines, so what `head`
57
+ * cut mid-line is still a line of its own in the stream.
58
+ */
59
+ const SKILL_HEAD_BYTES = 4_000;
60
+ const SKILL_HEAD_LINES = 120;
61
+ /**
62
+ * How many skills the answer lists, and how long a description it carries.
63
+ *
64
+ * Both bound an answer the model pays for on every clone, and neither is a
65
+ * judgement about which skills matter. They are set against a real checkout
66
+ * rather than guessed: this workspace's own set of skills renders whole at these
67
+ * values, in 5,731 characters, inside the sixteen thousand `maxOutputChars`
68
+ * defaults to. A cap tight enough to bite would drop skills in ordinal order,
69
+ * and the one dropped could be the one somebody has to name, which is the
70
+ * failure these numbers are chosen to stay clear of.
71
+ *
72
+ * A description here is a line that helps decide whether to open the skill, not
73
+ * a substitute for reading it.
74
+ *
75
+ * Exported for the specs, which assert the bound rather than restate the
76
+ * number. Nothing outside this package reads them: `./index.ts` is what the
77
+ * subpath publishes, and it exports neither.
78
+ */
79
+ export const MAX_SKILLS_LISTED = 40;
80
+ export const MAX_DESCRIPTION_CHARS = 200;
81
+ /**
82
+ * Collect every skill's frontmatter block, each file's lines indented by one tab
83
+ * under the line naming its directory.
84
+ *
85
+ * **The tab is what makes the stream unambiguous.** A line at column zero names
86
+ * a skill's directory and every line of that skill's file is indented by one, so
87
+ * nothing in a file can be read as the start of another record. The obvious
88
+ * alternative, a separator line of its own, is a string a folded description
89
+ * could legally contain, and the failure would be a skill silently merged into
90
+ * its neighbour. It is written by `awk`'s own string escape, which is portable
91
+ * where `sed`'s `\t` is GNU's, and a published package does not get to assume a
92
+ * host's image.
93
+ *
94
+ * **`awk` stops at the closing delimiter**, so what arrives is the whole block
95
+ * and not a guess at how long one is. {@link readFrontmatter} reports a block
96
+ * that arrived without it, and {@link invocationOf} is why that matters: a
97
+ * restriction sits in whichever line of the block its author put it on, so a
98
+ * read that stops early is a read that cannot speak for the skill. The `NR == 1`
99
+ * test is the other half of the saving: a file with no block at all costs one
100
+ * line rather than the bound.
101
+ *
102
+ * **A file this cannot read costs its own record and no other.** The loop's exit
103
+ * status is the last `printf`'s, so a failed read leaves the directory line with
104
+ * nothing indented under it, which {@link readFrontmatter} reads as a block
105
+ * nobody has seen. Failing the whole read instead would spend every other
106
+ * skill's inventory on one unreadable file.
107
+ *
108
+ * It reads through whatever `<dir>/<name>` is. A checkout may commit each entry
109
+ * as a symlink to a directory it keeps elsewhere, which is how this workspace
110
+ * holds its own, and the glob and `head` both follow one.
111
+ *
112
+ * A glob that matches nothing stays literal in a POSIX shell, so the `-f` test
113
+ * is what makes an absent `.claude/skills` an empty answer rather than an error.
114
+ *
115
+ * The blank line after each record is the stream's own shape rather than a
116
+ * property of the tool above it: a name starts a line whatever the file below it
117
+ * ended with. No model input reaches any of this; every path is this module's
118
+ * own constant.
119
+ */
120
+ export const READ_SKILLS = `for d in ${SKILLS_DIR}/*/; do
121
+ [ -f "$d/SKILL.md" ] || continue
122
+ printf '%s\\n' "\${d%/}"
123
+ head -c ${SKILL_HEAD_BYTES} "$d/SKILL.md" | awk -v lines=${SKILL_HEAD_LINES} '
124
+ { print "\\t" $0 }
125
+ NR == 1 { if ($0 !~ /^---[[:space:]]*$/) exit; next }
126
+ /^---[[:space:]]*$/ { exit }
127
+ NR >= lines { exit }
128
+ '
129
+ printf '\\n'
130
+ done`;
131
+ /**
132
+ * The skills in a checkout, as a sentence the model reads, or why they could not
133
+ * be read.
134
+ *
135
+ * A failed read is reported and never raised: the clone it is appended to
136
+ * succeeded, and a missing inventory is worth a line rather than an error that
137
+ * sends the model to investigate git.
138
+ */
139
+ export async function skillInventory(shell, dir) {
140
+ const read = await shell(READ_SKILLS, dir);
141
+ if (!read.success) {
142
+ // Clipped the way a description is, and for the same reason: this is one
143
+ // line of an answer about a clone that worked. What `context.ts` hands a
144
+ // tool for a command that never ran is a paragraph on whether retrying a
145
+ // lost `git push` is safe, which is the right thing to tell a model whose
146
+ // push vanished and the wrong thing to attach to a checkout that is on disk.
147
+ const why = read.stderr.trim() || read.stdout.trim();
148
+ return (`The skills under \`${SKILLS_DIR}\` in this checkout could not be read: ` +
149
+ `${why ? clip(why) : "the read failed"}`);
150
+ }
151
+ return skillInventoryText(readSkillInventory(read.stdout));
152
+ }
153
+ /**
154
+ * {@link READ_SKILLS}'s output, read back.
155
+ *
156
+ * Unindented lines name directories and indented ones belong to the file above
157
+ * them, so the shape of the stream is what separates the records. A blank
158
+ * unindented line is the trailing newline of the stream, never the end of a
159
+ * record.
160
+ *
161
+ * **A record starts only on a path under {@link SKILLS_DIR}**, which is the one
162
+ * thing the indentation alone does not settle. Directory names come from a
163
+ * cloned repository, and one carrying a newline writes a second line at column
164
+ * zero: without the path, that line opens a record of its own and the skill
165
+ * above it loses the frontmatter that follows to a name nobody wrote. A
166
+ * directory name holds no `/`, so what the second line cannot be is a path,
167
+ * which is what closes it.
168
+ */
169
+ export function readSkillInventory(stdout) {
170
+ const skills = [];
171
+ let dir;
172
+ let head = [];
173
+ const flush = () => {
174
+ if (dir !== undefined)
175
+ skills.push(skillOf(dir, head));
176
+ head = [];
177
+ };
178
+ for (const raw of stdout.split("\n")) {
179
+ // A checkout written on Windows is still a checkout, and a stray `\r` would
180
+ // otherwise end up inside a name.
181
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
182
+ if (line.startsWith("\t")) {
183
+ head.push(line.slice(1));
184
+ continue;
185
+ }
186
+ if (!line.startsWith(`${SKILLS_DIR}/`))
187
+ continue;
188
+ flush();
189
+ dir = line.trim();
190
+ }
191
+ flush();
192
+ return skills;
193
+ }
194
+ /** One skill, from its directory and the head of its `SKILL.md`. */
195
+ function skillOf(dir, head) {
196
+ const front = readFrontmatter(head);
197
+ const described = front.fields.description?.trim();
198
+ return {
199
+ // The directory is the fallback because it is the only name left: a block
200
+ // that did not parse, or did not arrive at all, still leaves a directory
201
+ // with a `SKILL.md` in it, and a reader given its name can go and look.
202
+ name: front.fields.name?.trim() || directoryName(dir),
203
+ ...(described ? { description: described } : {}),
204
+ invocation: invocationOf(front)
205
+ };
206
+ }
207
+ function directoryName(dir) {
208
+ const at = dir.lastIndexOf("/");
209
+ return at < 0 ? dir : dir.slice(at + 1);
210
+ }
211
+ /** What the two invocation fields leave open. See {@link SkillInvocation}. */
212
+ function invocationOf(front) {
213
+ // A block that did not arrive whole is a block whose remaining fields nobody
214
+ // has seen, and an absent field is a default only among fields that were
215
+ // read. A restriction sits wherever in the block its author put it, below the
216
+ // description as often as above it, so reading the defaults here would let
217
+ // the length of a skill's prose decide whether its restriction is reported.
218
+ if (!front.whole)
219
+ return "unknown";
220
+ const model = !isTrue(front.fields["disable-model-invocation"]);
221
+ const person = !isFalse(front.fields["user-invocable"]);
222
+ if (model && person)
223
+ return "either";
224
+ if (person)
225
+ return "command";
226
+ if (model)
227
+ return "prose";
228
+ return "neither";
229
+ }
230
+ /**
231
+ * The YAML 1.1 spellings of a boolean, because that is what a frontmatter author
232
+ * writes. Anything else reads as absent, which leaves the field's default: a
233
+ * skill is model-invocable and user-invocable unless it says otherwise, so an
234
+ * unreadable value never takes a way in away.
235
+ */
236
+ const TRUE = /^(?:true|yes|on)$/i;
237
+ const FALSE = /^(?:false|no|off)$/i;
238
+ const isTrue = (value) => value !== undefined && TRUE.test(value.trim());
239
+ const isFalse = (value) => value !== undefined && FALSE.test(value.trim());
240
+ /** The frontmatter delimiter, opening and closing. */
241
+ const DELIMITER = /^---\s*$/;
242
+ /**
243
+ * A value that says the scalar is the lines below it: a block indicator, or
244
+ * nothing at all. An empty value is the plain multi-line form,
245
+ * `description:` with its text indented under it, and it folds the same way.
246
+ */
247
+ const BLOCK = /^(?:[>|][-+]?\s*)?$/;
248
+ /**
249
+ * The frontmatter block's **top-level** keys.
250
+ *
251
+ * Top-level is the load-bearing word. A skill's frontmatter carries nested
252
+ * mappings (`metadata:` with children under it), and a child spelled `name:`
253
+ * would otherwise overwrite the skill's own. Reading column zero only is what
254
+ * keeps a nested key out, and it costs nothing: every field this module wants is
255
+ * at the top level.
256
+ *
257
+ * Not a YAML implementation, and deliberately narrow: nothing inside quotes is
258
+ * unescaped, and every block form folds to one line. What it does carry is the
259
+ * YAML that changes an answer here, which is {@link commentOff}'s.
260
+ */
261
+ function readFrontmatter(head) {
262
+ const fields = {};
263
+ // Nothing arrived at all, which is what a `SKILL.md` the read could not open
264
+ // looks like from here: the directory was named and no line followed it.
265
+ if (head.length === 0)
266
+ return { fields, whole: false };
267
+ let at = head.findIndex((line) => line.trim() !== "");
268
+ // No block to read. Nothing was cut short either, so what the fields below
269
+ // default to is a reading of this file rather than a guess about it.
270
+ if (at < 0 || !DELIMITER.test(head[at]))
271
+ return { fields, whole: true };
272
+ for (at += 1; at < head.length; at += 1) {
273
+ const line = head[at];
274
+ if (DELIMITER.test(line))
275
+ return { fields, whole: true };
276
+ // Indented or blank: a nested key, a block scalar's own lines when one was
277
+ // not consumed below, or spacing. A whole-line comment is not a field
278
+ // either, and reading one as a key is how a commented-out field comes back.
279
+ if (line.trim() === "" || /^[\s#]/.test(line))
280
+ continue;
281
+ const colon = line.indexOf(":");
282
+ if (colon < 0)
283
+ continue;
284
+ const key = line.slice(0, colon).trim();
285
+ const value = commentOff(line.slice(colon + 1).trim());
286
+ if (BLOCK.test(value)) {
287
+ const block = readBlock(head, at + 1);
288
+ fields[key] = block.text;
289
+ at = block.next - 1;
290
+ continue;
291
+ }
292
+ fields[key] = unquote(value);
293
+ }
294
+ return { fields, whole: false };
295
+ }
296
+ /**
297
+ * A block scalar's lines, folded to one.
298
+ *
299
+ * Every block form is folded the same way, `|` included, and that is a
300
+ * simplification rather than an oversight: the answer carries one clipped line
301
+ * per skill, so a literal block's newlines are removed by the clip a moment
302
+ * later. Reading this as YAML's folding rules would be reading it as more than
303
+ * it is.
304
+ */
305
+ function readBlock(head, from) {
306
+ const lines = [];
307
+ let at = from;
308
+ for (; at < head.length; at += 1) {
309
+ const line = head[at];
310
+ if (DELIMITER.test(line))
311
+ break;
312
+ if (line.trim() !== "" && !/^\s/.test(line))
313
+ break;
314
+ if (line.trim() !== "")
315
+ lines.push(line.trim());
316
+ }
317
+ return { text: lines.join(" "), next: at };
318
+ }
319
+ /**
320
+ * Where a YAML comment starts in a plain scalar: a `#` at the value's start, or
321
+ * one with whitespace in front of it. Nowhere else, which is what keeps `C#` and
322
+ * `#tag` inside a value.
323
+ */
324
+ const COMMENT = /(?:^|\s)#/;
325
+ /** What may follow a quoted scalar's closing quote, and all that may. */
326
+ const AFTER_QUOTE = /^\s+#/;
327
+ /**
328
+ * One scalar's text with a YAML comment taken off, quotes left as they are.
329
+ *
330
+ * Both halves of YAML's rule change an answer here.
331
+ * `disable-model-invocation: true # named from the prompt` is a restriction
332
+ * whose value is not the string `true`, so left whole it reads as a spelling
333
+ * nothing recognises and hands the skill back the way in it just gave up; and
334
+ * `name: audit # the command` would otherwise be offered as `/audit # the
335
+ * command`. Inside quotes nothing starts a comment, and a description carries a
336
+ * hash legitimately, so a quoted value is read to its closing quote and only a
337
+ * comment may follow it.
338
+ *
339
+ * A plain value that loses text to a ` #` has lost it in Claude Code's reading
340
+ * of the same file, which is the test for whether cutting it is right: this
341
+ * module reads a field the way the parser that acts on it does, or it reports
342
+ * something nobody wrote.
343
+ */
344
+ function commentOff(value) {
345
+ const quote = value[0];
346
+ if (quote === '"' || quote === "'") {
347
+ const close = value.lastIndexOf(quote);
348
+ // A scalar whose quote closes, with nothing after it or a comment. Anything
349
+ // else never was a quoted scalar, and is left to `unquote` to read as it
350
+ // stands.
351
+ if (close > 0 && AFTER_QUOTE.test(value.slice(close + 1)))
352
+ return value.slice(0, close + 1);
353
+ return value;
354
+ }
355
+ const at = value.search(COMMENT);
356
+ return at < 0 ? value : value.slice(0, at).trimEnd();
357
+ }
358
+ /** A scalar with one matching pair of quotes taken off. */
359
+ function unquote(value) {
360
+ const quoted = value.length >= 2 &&
361
+ (value.startsWith('"') || value.startsWith("'")) &&
362
+ value.endsWith(value[0]);
363
+ return quoted ? value.slice(1, -1) : value;
364
+ }
365
+ /**
366
+ * The inventory as the model reads it: where this looked, a line per skill, and
367
+ * what the list left out.
368
+ *
369
+ * **The counts are unconditional**, the empty answer included. "No skills here"
370
+ * and "this tool does not report skills" are different facts, and a repository
371
+ * that keeps its skills somewhere Claude Code does not read them is the case
372
+ * that makes the difference worth a sentence.
373
+ */
374
+ export function skillInventoryText(skills) {
375
+ // Ordinal, and not a ranking: nothing here knows which skill matters to the
376
+ // task, so the order is the one a reader can predict.
377
+ const sorted = [...skills].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
378
+ const listed = sorted.slice(0, MAX_SKILLS_LISTED);
379
+ const counts = `${skills.length} ${skills.length === 1 ? "skill" : "skills"}, ` +
380
+ `${listed.length} listed, ${skills.length - listed.length} omitted.`;
381
+ if (listed.length === 0) {
382
+ return `No skills under \`${SKILLS_DIR}\` in this checkout: ${counts}`;
383
+ }
384
+ return [
385
+ `Skills under \`${SKILLS_DIR}\` in this checkout:`,
386
+ ...listed.map(skillLine),
387
+ counts
388
+ ].join("\n");
389
+ }
390
+ function skillLine(skill) {
391
+ const note = {
392
+ either: "",
393
+ command: ` (name it as \`/${skill.name}\`)`,
394
+ prose: " (name it in prose; it has no `/` form)",
395
+ neither: " (no prompt can invoke it)",
396
+ unknown: " (how to reach it is unknown: its frontmatter did not read whole)"
397
+ }[skill.invocation];
398
+ const described = skill.description ? `: ${clip(skill.description)}` : "";
399
+ return `- \`${skill.name}\`${note}${described}`;
400
+ }
401
+ /**
402
+ * One line, cut at a word rather than mid-word.
403
+ *
404
+ * The bound is a description's, and a failed read's reason takes the same one:
405
+ * both are one line of an answer whose subject is the clone.
406
+ */
407
+ function clip(text) {
408
+ const flat = text.replace(/\s+/g, " ").trim();
409
+ if (flat.length <= MAX_DESCRIPTION_CHARS)
410
+ return flat;
411
+ const cut = flat.slice(0, MAX_DESCRIPTION_CHARS);
412
+ const space = cut.lastIndexOf(" ");
413
+ return `${space > 0 ? cut.slice(0, space) : cut}…`;
414
+ }
@@ -2,12 +2,31 @@ import { tool } from "ai";
2
2
  import { z } from "zod";
3
3
  import { parseRepo, repoLocation, UNSAFE_BRANCH } from "./url.js";
4
4
  import { refreshCheckout, writeGitIdentity } from "./checkout.js";
5
+ import { skillInventory } from "./skills.js";
5
6
  /** Putting a checkout on disk, fresh or refreshed. */
6
7
  export function cloneTools(ctx) {
7
- const { config, workdir, author, allowedHosts, bounded, notifyCheckout, logFailure, plain, runGit, fetchOrigin } = ctx;
8
+ const { config, workdir, author, allowedHosts, bounded, notifyCheckout, logFailure, plain, shell, runGit, fetchOrigin } = ctx;
9
+ /**
10
+ * One answer about a checkout that is on disk: what happened to it, and the
11
+ * skills the repository carries.
12
+ *
13
+ * **Only for an answer that establishes such a checkout.** A URL that names no
14
+ * repository, a directory holding a different one, and a tree git could not
15
+ * read are all answers about where the checkout is *not*, and an inventory
16
+ * read on that path would be describing something nobody asked about. What
17
+ * tells the two apart on the refresh path is `checkout` in
18
+ * {@link file://./checkout.ts refreshCheckout}'s outcome.
19
+ *
20
+ * Bounded here rather than in the inventory: the inventory caps itself at a
21
+ * list a reader can use, and this is the host's own ceiling on an answer,
22
+ * which the rest of the plugin applies in the same place. The refusals that
23
+ * carry no inventory are bounded in the same expression, since each of them
24
+ * quotes something it was given: another remote's URL, or git's own output.
25
+ */
26
+ const withSkills = async (message, dir) => bounded(`${message}\n\n${await skillInventory(shell, dir)}`);
8
27
  return {
9
28
  repo_clone: tool({
10
- description: "Clone a git repository into the workspace. Returns the checkout path and the branch you landed on. If the repository is already checked out from an earlier task, it is fetched and reset to the remote instead — unless it has uncommitted changes, which are left alone for you to inspect.",
29
+ description: "Clone a git repository into the workspace. Returns the checkout path, the branch you landed on, and the skills the repository carries with how each one may be named in a prompt. If the repository is already checked out from an earlier task, it is fetched and reset to the remote instead. A checkout with uncommitted changes is left alone for you to inspect.",
11
30
  inputSchema: z.object({
12
31
  url: z.string().describe("HTTPS repository URL"),
13
32
  branch: z
@@ -125,7 +144,9 @@ export function cloneTools(ctx) {
125
144
  fresh: false
126
145
  });
127
146
  }
128
- return refreshed.message;
147
+ return refreshed.checkout
148
+ ? await withSkills(refreshed.message, dir)
149
+ : bounded(refreshed.message);
129
150
  }
130
151
  // `depth` is bounded here rather than trusted from the schema. It no
131
152
  // longer reaches a shell, so this is arithmetic rather than quoting: a
@@ -166,7 +187,7 @@ export function cloneTools(ctx) {
166
187
  branch: landed,
167
188
  fresh: true
168
189
  });
169
- return `cloned to ${dir} on branch ${landed}`;
190
+ return await withSkills(`cloned to ${dir} on branch ${landed}`, dir);
170
191
  }
171
192
  })
172
193
  };
@@ -48,3 +48,16 @@ export declare function readRunHeader(text: string): {
48
48
  };
49
49
  /** Whether `path` is `root` or inside it, with no `..` escaping it. */
50
50
  export declare function isUnder(path: string, root: string): boolean;
51
+ /**
52
+ * `path` with its dot segments and repeated separators resolved, or `undefined`
53
+ * for one that is not absolute or that climbs above `/`.
54
+ *
55
+ * Shared with the tools rather than kept here, because anything comparing a
56
+ * path against this root has to compare what the path *means*: `/workspace/.`
57
+ * and `/workspace/repo/..` are both this root, and a comparison that read them
58
+ * literally would answer no to a path the sandbox itself answers yes to.
59
+ *
60
+ * A `..` with nothing left to pop is refused rather than clamped at `/`: a
61
+ * clamp would turn an attempt to climb out into a valid root path.
62
+ */
63
+ export declare function normalizeAbsolute(path: string): string | undefined;
@@ -75,20 +75,35 @@ function assertRunHeader(value) {
75
75
  }
76
76
  /** Whether `path` is `root` or inside it, with no `..` escaping it. */
77
77
  export function isUnder(path, root) {
78
+ const normal = normalizeAbsolute(path);
79
+ return (normal !== undefined && (normal === root || normal.startsWith(`${root}/`)));
80
+ }
81
+ /**
82
+ * `path` with its dot segments and repeated separators resolved, or `undefined`
83
+ * for one that is not absolute or that climbs above `/`.
84
+ *
85
+ * Shared with the tools rather than kept here, because anything comparing a
86
+ * path against this root has to compare what the path *means*: `/workspace/.`
87
+ * and `/workspace/repo/..` are both this root, and a comparison that read them
88
+ * literally would answer no to a path the sandbox itself answers yes to.
89
+ *
90
+ * A `..` with nothing left to pop is refused rather than clamped at `/`: a
91
+ * clamp would turn an attempt to climb out into a valid root path.
92
+ */
93
+ export function normalizeAbsolute(path) {
78
94
  if (!path.startsWith("/"))
79
- return false;
95
+ return undefined;
80
96
  const parts = [];
81
97
  for (const part of path.split("/")) {
82
98
  if (part === "" || part === ".")
83
99
  continue;
84
100
  if (part === "..") {
85
101
  if (parts.length === 0)
86
- return false;
102
+ return undefined;
87
103
  parts.pop();
88
104
  continue;
89
105
  }
90
106
  parts.push(part);
91
107
  }
92
- const normal = `/${parts.join("/")}`;
93
- return normal === root || normal.startsWith(`${root}/`);
108
+ return `/${parts.join("/")}`;
94
109
  }
@@ -36,7 +36,7 @@ export interface InstallProbe {
36
36
  * Where the install has got to, as the host reports it and the tools read it.
37
37
  *
38
38
  * Declared here rather than in the host because both sides depend on it and
39
- * neither owns the other: the Durable Object runs the job, and `bash` refuses
39
+ * neither owns the other: the Durable Object runs the job, and the shell tool refuses
40
40
  * to run against a tree that is still being built. A shape they agreed on
41
41
  * informally would drift, and the drift would show up as a shell command running
42
42
  * halfway through an `npm ci`.
@@ -26,6 +26,7 @@
26
26
  * absent. So: an explicit override wins, then the repository's own
27
27
  * `packageManager` pin, then lockfiles in the order the plan lists them.
28
28
  */
29
+ import { sha256Hex } from "@dynamicagents/core";
29
30
  /**
30
31
  * A sensible starting table, meant to be copied into a host and edited.
31
32
  *
@@ -304,8 +305,5 @@ async function digestFiles(fs, dir, names, prefix) {
304
305
  }
305
306
  if (!found)
306
307
  return null;
307
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
308
- return [...new Uint8Array(digest)]
309
- .map((b) => b.toString(16).padStart(2, "0"))
310
- .join("");
308
+ return await sha256Hex(input);
311
309
  }
@@ -96,6 +96,7 @@ export declare abstract class SandboxObjectBase<Env extends object = Cloudflare.
96
96
  type: string;
97
97
  size: number;
98
98
  modifiedAt: number;
99
+ mode: number;
99
100
  } | null>;
100
101
  readDir(path: string): Promise<{
101
102
  name: string;
@@ -1,5 +1,6 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
  import { ContainerHarness, containerAgent } from "agents/harness/container";
3
+ import { sha256Hex } from "@dynamicagents/core";
3
4
  import { installScheduler, namedDeadline } from "@dynamicagents/core/alarm";
4
5
  import { DirectoryBackup, Files } from "@cloudflare/sandbox";
5
6
  import { asAgent, AGENT_GID, AGENT_UID, shellQuote } from "./agent-exec.js";
@@ -380,7 +381,11 @@ export class SandboxObjectBase extends DurableObject {
380
381
  return {
381
382
  type: stat.type,
382
383
  size: Number(stat.size),
383
- modifiedAt: stat.modifiedAt.getTime()
384
+ modifiedAt: stat.modifiedAt.getTime(),
385
+ // Reported because the file tools' own contract asks for one, and
386
+ // the container knows it. Nothing can set it back: the sandbox's
387
+ // write takes no mode.
388
+ mode: stat.mode
384
389
  };
385
390
  }
386
391
  catch (err) {
@@ -1105,14 +1110,16 @@ function markOf(dir) {
1105
1110
  function installMarkOf(dir) {
1106
1111
  return `${INSTALL_MARKS}/${encodeURIComponent(dir)}`;
1107
1112
  }
1108
- /** `env`, in key order, as a digest a fingerprint can carry. */
1113
+ /**
1114
+ * `env`, in key order, as a digest a fingerprint can carry.
1115
+ *
1116
+ * The width is load-bearing: this goes into an install mark stored on the
1117
+ * container's disk, so changing it makes every tree already marked look
1118
+ * uninstalled and costs each one an install it does not need.
1119
+ */
1109
1120
  async function digestEnv(env) {
1110
1121
  const sorted = Object.entries(env ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1111
- const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(sorted)));
1112
- return [...new Uint8Array(bytes)]
1113
- .map((b) => b.toString(16).padStart(2, "0"))
1114
- .join("")
1115
- .slice(0, 16);
1122
+ return (await sha256Hex(JSON.stringify(sorted))).slice(0, 16);
1116
1123
  }
1117
1124
  function isNotFound(err) {
1118
1125
  return (typeof err === "object" &&
@@ -1129,11 +1136,12 @@ function isAbort(err) {
1129
1136
  function sleep(ms) {
1130
1137
  return new Promise((resolve) => setTimeout(resolve, ms));
1131
1138
  }
1139
+ /**
1140
+ * `value`, as the digest a poll returns beside a message.
1141
+ *
1142
+ * Nothing stores it: a poll composes it again for every message it answers
1143
+ * with, so the width has only to tell two messages apart.
1144
+ */
1132
1145
  async function digest(value) {
1133
- const bytes = new TextEncoder().encode(JSON.stringify(value));
1134
- const hash = await crypto.subtle.digest("SHA-256", bytes);
1135
- return [...new Uint8Array(hash)]
1136
- .slice(0, 12)
1137
- .map((byte) => byte.toString(16).padStart(2, "0"))
1138
- .join("");
1146
+ return (await sha256Hex(JSON.stringify(value))).slice(0, 24);
1139
1147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamicagents/plugins",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Optional, composable capabilities for a Dynamic Agent: browser rendering, a Linux container, git and pull requests on GitHub, a scratchpad, and Claude Code sessions. One subpath per plugin, so a bundle grows only with what it imports.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -84,10 +84,11 @@
84
84
  "prepare": "husky || true && npm run build"
85
85
  },
86
86
  "peerDependencies": {
87
+ "@cloudflare/computer": "^0.5.1",
87
88
  "@cloudflare/sandbox": "^1.0.1",
88
- "@cloudflare/think": ">=0.20.2 <0.21.0",
89
- "@dynamicagents/core": ">=0.16.0 <0.17.0",
90
- "agents": ">=0.28.0 <0.29.0",
89
+ "@cloudflare/think": ">=0.21.0 <0.22.0",
90
+ "@dynamicagents/core": ">=0.17.0 <0.18.0",
91
+ "agents": ">=0.30.0 <0.31.0",
91
92
  "ai": "^7.0.88",
92
93
  "zod": "^4.4.3"
93
94
  },
@@ -97,12 +98,13 @@
97
98
  }
98
99
  },
99
100
  "devDependencies": {
101
+ "@cloudflare/computer": "^0.5.1",
100
102
  "@cloudflare/sandbox": "^1.0.1",
101
- "@cloudflare/think": "^0.20.2",
103
+ "@cloudflare/think": "^0.21.0",
102
104
  "@cloudflare/vitest-plugin": "^1.4.4",
103
- "@dynamicagents/core": "^0.16.0",
105
+ "@dynamicagents/core": "^0.17.1",
104
106
  "@types/node": "^26.5.1",
105
- "agents": "^0.28.0",
107
+ "agents": "^0.30.1",
106
108
  "ai": "^7.0.101",
107
109
  "eslint": "^10.10.0",
108
110
  "husky": "^9.1.7",