@dynamicagents/plugins 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -88,6 +88,7 @@ needing per-caller state takes it the same way.
88
88
  | [`/computer`](src/computer/) | A Linux container whose filesystem outlives it: shell, package manager, unrestricted network | `@cloudflare/computer` (paid) |
89
89
  | [`/recall`](src/recall/) | Episodic memory over Vectorize — search history that compaction folded away | `VECTORIZE` (1024-dim/cosine) |
90
90
  | [`/repo`](src/repo/) | Clone, commit, push a branch, open a pull request — over any container | `GITHUB_TOKEN` |
91
+ | [`/scratch`](src/scratch/) | A throwaway git repository with no remote, for work that needs a container but no checkout | — |
91
92
  | [`/triage`](src/triage/) | A pre-turn gate: is this message even for me? | — |
92
93
  | [`/workspace`](src/workspace/) | A durable file store for long subagent runs, plus tools over it | `@cloudflare/shell` |
93
94
 
@@ -45,7 +45,7 @@ export const CLAUDE_CODE_RECIPE = {
45
45
  "This recipe does not drive a model loop.",
46
46
  "",
47
47
  "A subtask of this type runs the Claude Code CLI inside the agent's",
48
- "workspace container, against the durable checkout. The system prompt, the",
48
+ "workspace container, against the durable working tree. The system prompt, the",
49
49
  "tool loop and the context management all belong to that process. Nothing",
50
50
  "reads this text — it exists because a recipe must declare a soul, and a",
51
51
  "placeholder that looked like a prompt would invite someone to tune it."
@@ -69,9 +69,9 @@ export const CLAUDE_CODE_CAPABILITY = [
69
69
  "## Writing code",
70
70
  "",
71
71
  "You can hand a coding task to a Claude Code session running in your",
72
- "workspace container, against the repository you have checked out. It has its",
73
- "own tools — it reads, edits, runs the test suite and iterates — and it",
74
- "reports back what it did.",
72
+ "workspace container, in whatever you have open there a repository you",
73
+ "checked out, or a scratchpad. It has its own tools — it reads, edits, runs the",
74
+ "test suite and iterates — and it reports back what it did.",
75
75
  "",
76
76
  "Give it **one coherent change**, described the way you would describe it to",
77
77
  "an engineer: what should be true when it is done, and how to tell. It is",
@@ -84,7 +84,7 @@ export const CLAUDE_CODE_CAPABILITY = [
84
84
  ].join("\n");
85
85
  export const CLAUDE_CODE_SPEC = {
86
86
  key: CLAUDE_CODE_TYPE,
87
- description: "Make a code change in the checked-out repository with a Claude Code session.",
87
+ description: "Run a coding task in the workspace with a Claude Code session — a checked-out repository, or a scratchpad.",
88
88
  /**
89
89
  * No params, and that is a decision rather than an omission.
90
90
  *
@@ -218,6 +218,16 @@ export interface RepoConfig {
218
218
  * what stops a git plugin growing an opinion about npm — the host wires this
219
219
  * to whatever its runtime does, or leaves it unset and nothing changes.
220
220
  *
221
+ * **Record {@link RepoCheckout.dir} here, and record it on its own.** Installing
222
+ * is the use this hook was written for, and it is not the only obligation:
223
+ * `dir` is reported nowhere else, so a host that does not persist it has no
224
+ * way to answer "where is the checkout" afterwards. Persisting it *as part of*
225
+ * an install is the trap, because an install is conditional and the checkout is
226
+ * not — a host that stored the path alongside its install state found that a
227
+ * repository the resolver had nothing to install in (no `package.json`) cloned
228
+ * perfectly, reported itself correctly, and could never be worked in, because
229
+ * the one path its subagents needed was written on a branch that never ran.
230
+ *
221
231
  * Awaited, so it can record intent durably, but it must **return quickly**:
222
232
  * it runs inside `repo_clone`, which runs inside a model turn. A host that
223
233
  * wants to install here should start a job and return, not wait for it.
@@ -256,7 +266,14 @@ export interface RepoConfig {
256
266
  }
257
267
  /** What {@link RepoConfig.afterCheckout} is told about a checkout. */
258
268
  export interface RepoCheckout {
259
- /** Absolute path of the working tree. */
269
+ /**
270
+ * Absolute path of the working tree.
271
+ *
272
+ * The only report of it there is. Nothing in this plugin holds state, so a
273
+ * host that does not persist this cannot find the checkout again — and must
274
+ * persist it unconditionally rather than as a side effect of whatever else the
275
+ * hook does. See {@link RepoConfig.afterCheckout}.
276
+ */
260
277
  dir: string;
261
278
  /** The clone URL, already allowlist-checked. */
262
279
  url: string;
@@ -0,0 +1,158 @@
1
+ import type { AgentPlugin } from "@dynamicagents/core";
2
+ /**
3
+ * `@dynamicagents/plugins/scratch` — a place to work that is not a repository.
4
+ *
5
+ * ## The gap this closes
6
+ *
7
+ * An agent with a container and a git plugin can do a great deal, and all of it
8
+ * starts with a clone. That is right for the work such an agent mostly does and
9
+ * wrong for the rest of it: "check what this actually returns", "write a script
10
+ * and run it", "try that regex against these twenty lines" each need a container
11
+ * and none of them needs a repository.
12
+ *
13
+ * With no way to say so, an agent asks for one. The deployment this was written
14
+ * for had a coding agent ask its user for an **empty repository to clone** so it
15
+ * could run a script in the checkout — which is a workaround for a missing verb,
16
+ * and a good sign the verb is missing.
17
+ *
18
+ * ## A scratchpad is a repository whose remote is nowhere
19
+ *
20
+ * That framing is the design, and it is why this plugin is small. Everything a
21
+ * host already does for a checkout applies unchanged: it is a git repository, so
22
+ * a cancelled task's edits can be reset out of it; it lives in the workspace, so
23
+ * it is durable and reclaimed the same way; and it goes through the host's own
24
+ * workspace selection, so it is keyed, routed and cleaned up by the machinery
25
+ * that already exists rather than by a second mechanism alongside it.
26
+ *
27
+ * It has no `origin`, which is the property that makes it safe to let a session
28
+ * do as it likes in: nothing here is ever pushed anywhere.
29
+ *
30
+ * ## What the host owns
31
+ *
32
+ * The same split [`/repo`](../repo/) makes, and for the same reason — this plugin
33
+ * knows what a scratchpad *is*, the host knows how it addresses one:
34
+ *
35
+ * - {@link ScratchConfig.beforeOpen} selects the workspace, before anything runs.
36
+ * - {@link ScratchConfig.afterOpen} records it, and says whether the host can
37
+ * actually see it.
38
+ *
39
+ * A host that wires neither still gets a working scratchpad, as long as its
40
+ * workspace is not keyed per repository.
41
+ */
42
+ /** Where a scratchpad lives, unless the host says otherwise. */
43
+ export declare const DEFAULT_SCRATCH_DIR = "/workspace/scratch";
44
+ /**
45
+ * Run one command in the host's container. Matches `computerExec`'s shape.
46
+ *
47
+ * Injected rather than imported, like `/repo`'s: `npm run verify:exports` fails
48
+ * any subpath whose module graph reaches a sibling's, and a scratchpad that
49
+ * dragged the whole container plugin into every consumer's bundle would cost far
50
+ * more than it is worth.
51
+ */
52
+ export type ScratchExec = (command: string, options?: {
53
+ cwd?: string;
54
+ env?: Record<string, string | undefined>;
55
+ timeout?: number;
56
+ /**
57
+ * The executing subtask's runtime state, forwarded **opaquely** — the same
58
+ * pass-through `/repo` does, for the same reason. This plugin never looks
59
+ * inside it.
60
+ */
61
+ runtime?: unknown;
62
+ }) => Promise<{
63
+ success: boolean;
64
+ stdout: string;
65
+ stderr: string;
66
+ exitCode: number;
67
+ }>;
68
+ /** What {@link ScratchConfig.afterOpen} is told. */
69
+ export interface Scratchpad {
70
+ /** Absolute path of the scratchpad's working tree. */
71
+ dir: string;
72
+ /**
73
+ * True when this call initialised the repository — either because there was
74
+ * none, or because the one there was incomplete.
75
+ *
76
+ * **Not a claim that the tree is empty.** `git init` leaves whatever is in the
77
+ * directory exactly where it is, so a scratchpad whose repository had to be
78
+ * established over existing files is `fresh` and full. Only a reset makes it
79
+ * empty, which is why nothing here reads this to decide what is in the tree.
80
+ */
81
+ fresh: boolean;
82
+ }
83
+ /**
84
+ * Whether the scratchpad is usable, as far as the host can tell.
85
+ *
86
+ * `ready: false` is reported to the model as something to retry, not as a
87
+ * failure of the open — the tree is on disk either way.
88
+ */
89
+ export interface ScratchReadiness {
90
+ ready: boolean;
91
+ /** One clause naming what is not ready, appended to the retry sentence. */
92
+ because?: string;
93
+ }
94
+ export interface ScratchConfig {
95
+ /**
96
+ * Runs commands in the container holding the workspace.
97
+ *
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.
100
+ */
101
+ exec: ScratchExec;
102
+ /** Where the scratchpad lives. Defaults to {@link DEFAULT_SCRATCH_DIR}. */
103
+ dir?: string;
104
+ /** Committer identity. Defaults to a generic agent identity. */
105
+ author?: {
106
+ name: string;
107
+ email: string;
108
+ };
109
+ /**
110
+ * Ceiling on what this tool returns to the model, in **characters**.
111
+ * Defaults to 16,000, matching `/computer` and `/repo`.
112
+ */
113
+ maxOutputChars?: number;
114
+ /**
115
+ * Called before anything runs — the host's chance to select its workspace.
116
+ *
117
+ * The mirror of `RepoConfig.beforeCheckout`, and the ordering is the same
118
+ * point: a host that keys its container or its filesystem per repository has
119
+ * to have switched **before** the first command, or `git init` lands in
120
+ * whichever workspace the last task left open.
121
+ *
122
+ * Synchronous on purpose. The only sensible thing to do here is record a
123
+ * selection; a host that needs I/O has the ordering wrong. A throw fails the
124
+ * open, because a host that could not choose a workspace has not chosen one.
125
+ */
126
+ beforeOpen?: () => void;
127
+ /**
128
+ * Called once the scratchpad is on disk, so the host can record where it is —
129
+ * and say whether it can see it.
130
+ *
131
+ * **This returns a value where `RepoConfig.afterCheckout` returns `void`, and
132
+ * the difference is deliberate.** This plugin knows `git init` exited 0.
133
+ * Whether the host's durable record agrees is host knowledge, and it is
134
+ * exactly the disagreement worth surfacing here: a tool that reports success
135
+ * followed by a delegation that refuses to start is the worst version of this
136
+ * failure, because the two are reported in different places and nothing
137
+ * connects them.
138
+ *
139
+ * **A throw reaches the model**, where `afterCheckout`'s is caught and logged.
140
+ * A clone is still useful to an agent whose follow-up hook failed; a
141
+ * scratchpad the host did not record cannot be delegated into at all, so
142
+ * silence would promise something that is not there.
143
+ *
144
+ * Returning nothing means "no opinion", which is treated as ready.
145
+ */
146
+ afterOpen?: (scratch: Scratchpad) => Promise<ScratchReadiness | void>;
147
+ }
148
+ /**
149
+ * Bound what reaches the model, keeping both ends.
150
+ *
151
+ * A copy rather than an import, for the reason {@link ScratchExec} is injected:
152
+ * `verify:exports` fails any subpath that reaches a sibling's files, and `/repo`
153
+ * carries the same copy for the same reason.
154
+ */
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. */
157
+ export declare const SCRATCH_OPEN_TOOL = "scratch_open";
158
+ export declare function scratch(config: ScratchConfig): AgentPlugin;
@@ -0,0 +1,326 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { definePlugin } from "@dynamicagents/core";
4
+ /**
5
+ * `@dynamicagents/plugins/scratch` — a place to work that is not a repository.
6
+ *
7
+ * ## The gap this closes
8
+ *
9
+ * An agent with a container and a git plugin can do a great deal, and all of it
10
+ * starts with a clone. That is right for the work such an agent mostly does and
11
+ * wrong for the rest of it: "check what this actually returns", "write a script
12
+ * and run it", "try that regex against these twenty lines" each need a container
13
+ * and none of them needs a repository.
14
+ *
15
+ * With no way to say so, an agent asks for one. The deployment this was written
16
+ * for had a coding agent ask its user for an **empty repository to clone** so it
17
+ * could run a script in the checkout — which is a workaround for a missing verb,
18
+ * and a good sign the verb is missing.
19
+ *
20
+ * ## A scratchpad is a repository whose remote is nowhere
21
+ *
22
+ * That framing is the design, and it is why this plugin is small. Everything a
23
+ * host already does for a checkout applies unchanged: it is a git repository, so
24
+ * a cancelled task's edits can be reset out of it; it lives in the workspace, so
25
+ * it is durable and reclaimed the same way; and it goes through the host's own
26
+ * workspace selection, so it is keyed, routed and cleaned up by the machinery
27
+ * that already exists rather than by a second mechanism alongside it.
28
+ *
29
+ * It has no `origin`, which is the property that makes it safe to let a session
30
+ * do as it likes in: nothing here is ever pushed anywhere.
31
+ *
32
+ * ## What the host owns
33
+ *
34
+ * The same split [`/repo`](../repo/) makes, and for the same reason — this plugin
35
+ * knows what a scratchpad *is*, the host knows how it addresses one:
36
+ *
37
+ * - {@link ScratchConfig.beforeOpen} selects the workspace, before anything runs.
38
+ * - {@link ScratchConfig.afterOpen} records it, and says whether the host can
39
+ * actually see it.
40
+ *
41
+ * A host that wires neither still gets a working scratchpad, as long as its
42
+ * workspace is not keyed per repository.
43
+ */
44
+ /** Where a scratchpad lives, unless the host says otherwise. */
45
+ export const DEFAULT_SCRATCH_DIR = "/workspace/scratch";
46
+ /** The generic identity a scratchpad's commits carry. Matches `/repo`'s. */
47
+ const DEFAULT_AUTHOR = {
48
+ name: "da-coder",
49
+ email: "coder@dynamicagents.invalid"
50
+ };
51
+ /** Ceiling on what this tool returns to the model, in characters. */
52
+ const DEFAULT_MAX_OUTPUT_CHARS = 16_000;
53
+ /** How many lines of a dirty tree are worth reciting back. */
54
+ const STATUS_MAX_LINES = 20;
55
+ /**
56
+ * Bound what reaches the model, keeping both ends.
57
+ *
58
+ * A copy rather than an import, for the reason {@link ScratchExec} is injected:
59
+ * `verify:exports` fails any subpath that reaches a sibling's files, and `/repo`
60
+ * carries the same copy for the same reason.
61
+ */
62
+ export function truncateOutput(text, max) {
63
+ if (text.length <= max)
64
+ return text;
65
+ const marker = (dropped) => `\n\n… [${dropped} characters omitted from the middle] …\n\n`;
66
+ const half = Math.floor((max - marker(text.length).length) / 2);
67
+ if (half < 1)
68
+ return text.slice(0, Math.max(0, max));
69
+ return (text.slice(0, half) + marker(text.length - half * 2) + text.slice(-half));
70
+ }
71
+ /** The tool name, exported so a host restricting its main agent can name it. */
72
+ export const SCRATCH_OPEN_TOOL = "scratch_open";
73
+ /**
74
+ * What the delegating agent is told.
75
+ *
76
+ * The mixing rule at the end is the line worth its tokens. A host that keys one
77
+ * workspace selection per caller — which is what makes a scratchpad routable at
78
+ * all — points *every* other workspace tool at whatever was selected last. So an
79
+ * agent that opens a scratchpad half-way through work on a checkout has quietly
80
+ * moved its own `repo_diff` and `repo_commit` with it. That is a property of the
81
+ * host's routing rather than something this plugin can prevent, so the model is
82
+ * told the rule instead of being left to discover it.
83
+ */
84
+ function capabilityFor(dir) {
85
+ return [
86
+ "## A scratchpad, for work that is not a repository",
87
+ "",
88
+ `\`${SCRATCH_OPEN_TOOL}\` gives you a container to work in without cloning anything: a git`,
89
+ `repository at \`${dir}\` with no remote. Reach for it when the request needs code`,
90
+ "to *run* rather than a repository to change — checking what something actually",
91
+ "returns, writing and running a throwaway script, trying an approach out before",
92
+ "committing to it.",
93
+ "",
94
+ "Open it, then delegate the work as usual; work happens in it exactly as it would",
95
+ "in a checkout. It is durable, so a later task finds whatever the last one left",
96
+ "there. Pass `reset: true` to start from an empty tree.",
97
+ "",
98
+ "**Nothing in it is ever pushed.** There is no remote, so there is no branch to",
99
+ "push and no pull request to open — the work itself, and what you learned from it,",
100
+ "is the deliverable. Say so plainly when you report back.",
101
+ "",
102
+ "**One task works in one place.** A task is either working in a cloned repository",
103
+ "or in the scratchpad. Opening the scratchpad points every other workspace tool you",
104
+ "hold at it, so do not open it part-way through work on a checkout — finish that",
105
+ "first."
106
+ ].join("\n");
107
+ }
108
+ /**
109
+ * Whether there is a usable scratchpad at `$SCRATCH_DIR`, asked from a directory
110
+ * that certainly exists.
111
+ *
112
+ * Three properties, and each rules out a state that reads as "ready" and is not:
113
+ *
114
+ * - **Run from `/`.** A command's own working directory has to exist before the
115
+ * command starts, so asking this from inside the scratchpad makes the very
116
+ * first open — the one where the directory is not there yet — fail before git
117
+ * runs, and be indistinguishable from a container that did not answer.
118
+ * - **`.git` in the scratchpad itself**, not `rev-parse` from within it: that
119
+ * walks upwards and answers yes for a scratchpad sitting anywhere inside
120
+ * another repository, whose tree a later reset would then discard.
121
+ * - **`HEAD` resolves.** A repository with no commit is not usable here — see
122
+ * {@link INIT_COMMAND} — and it is a reachable state, because `git init` and
123
+ * the commit after it are two commands and only the pair is meaningful.
124
+ *
125
+ * Anything less than all three sends the caller to {@link INIT_COMMAND}, which
126
+ * repairs each of them and leaves an existing tree alone.
127
+ */
128
+ const PROBE_COMMAND = 'test -e "$SCRATCH_DIR/.git" && ' +
129
+ 'git -C "$SCRATCH_DIR" rev-parse --verify -q HEAD >/dev/null';
130
+ /**
131
+ * Establish a scratchpad: a git repository with a commit in it.
132
+ *
133
+ * **The empty commit is load-bearing.** Without it the repository has no `HEAD`,
134
+ * and `git reset --hard` fails outright — which is what a host runs to discard a
135
+ * cancelled task's edits. That cleanup is best-effort in every host that has one,
136
+ * so the failure is a logged warning plus a cancelled run's files surviving into
137
+ * the next task as its starting point.
138
+ *
139
+ * **Safe to run against a directory that already holds something**, which is what
140
+ * makes it a repair as well as a creation: `git init` on an existing repository
141
+ * re-initialises without touching the tree, and `--allow-empty` commits nothing,
142
+ * so a scratchpad left half-built by an interrupted open gains the `HEAD` it was
143
+ * missing and keeps its files.
144
+ *
145
+ * Every value arrives through the environment rather than the command text —
146
+ * the identity so a configured name containing a quote stays a value, and the
147
+ * directory because double quotes still expand `$(…)`, backticks and variables,
148
+ * so a host path containing any of them would otherwise be shell rather than a
149
+ * path. The identity is set repo-locally for the reason `/repo` sets it there: a
150
+ * global identity in the container would attach itself to any other repository
151
+ * sharing it.
152
+ */
153
+ const INIT_COMMAND = [
154
+ 'mkdir -p "$SCRATCH_DIR"',
155
+ 'cd "$SCRATCH_DIR"',
156
+ "git init -q",
157
+ 'git config user.name "$GIT_NAME"',
158
+ 'git config user.email "$GIT_EMAIL"',
159
+ 'git commit -q --allow-empty -m "scratchpad"'
160
+ ].join(" && ");
161
+ /**
162
+ * Empty the scratchpad.
163
+ *
164
+ * **`-ff`, not `-f`.** A single force leaves untracked *nested repositories*
165
+ * where they are, so a task that cloned or initialised something inside the
166
+ * scratchpad would survive a reset this tool reports as having emptied it — and
167
+ * a scratchpad is exactly where that happens, since cloning something to look at
168
+ * it is one of the things it is for. The second force is what makes the promise
169
+ * true. It is safe to make here in a way it would not be in a checkout: nothing
170
+ * in a scratchpad is tracked by anything else, and nothing in it is ever pushed.
171
+ */
172
+ const RESET_COMMAND = "git reset --hard -q && git clean -ffdxq";
173
+ export function scratch(config) {
174
+ const dir = config.dir ?? DEFAULT_SCRATCH_DIR;
175
+ const author = config.author ?? DEFAULT_AUTHOR;
176
+ const maxChars = config.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
177
+ /**
178
+ * The seam where an unreachable container stops being an exception.
179
+ *
180
+ * Every branch below has to tell "the container did not answer" apart from
181
+ * "git answered no" — read as the latter, a lost connection would re-init over
182
+ * a healthy scratchpad and discard what an earlier task left in it. The same
183
+ * distinction `/repo` draws before it clones over a directory.
184
+ */
185
+ const run = async (command, options) => {
186
+ try {
187
+ return await config.exec(command, {
188
+ cwd: dir,
189
+ ...options,
190
+ // Always, and merged over whatever the caller passed: every command here
191
+ // names the scratchpad, and none of them may spell it in shell text.
192
+ env: { ...options?.env, SCRATCH_DIR: dir }
193
+ });
194
+ }
195
+ catch (err) {
196
+ console.warn("[scratch] the container could not be reached", {
197
+ command,
198
+ err: String(err)
199
+ });
200
+ return {
201
+ success: false,
202
+ stdout: "",
203
+ stderr: String(err),
204
+ unreachable: true
205
+ };
206
+ }
207
+ };
208
+ /**
209
+ * What is in the tree, in one sentence.
210
+ *
211
+ * Worth a command because the alternative is a model assuming an empty
212
+ * scratchpad and writing a brief for one — a durable workspace carries the
213
+ * same warning wherever it is described.
214
+ *
215
+ * **Only a reset is allowed to skip it.** Establishing the repository is not
216
+ * the same as establishing an empty tree: `git init` over a directory that
217
+ * already holds files leaves every one of them, so the one call that can
218
+ * answer this without asking is the one that just deleted everything.
219
+ */
220
+ const describeTree = async (knownEmpty, runtime) => {
221
+ if (knownEmpty)
222
+ return "It is empty.";
223
+ const listed = await run("git status --porcelain", { runtime });
224
+ // Silence rather than a guess: the scratchpad is open either way, and "it is
225
+ // empty" would be a claim this command did not support.
226
+ if (!listed.success)
227
+ return "";
228
+ const lines = listed.stdout.trim().split("\n").filter(Boolean);
229
+ if (lines.length === 0)
230
+ return "Its working tree is clean.";
231
+ const shown = lines.slice(0, STATUS_MAX_LINES).join("\n");
232
+ const rest = lines.length > STATUS_MAX_LINES
233
+ ? `\n… and ${lines.length - STATUS_MAX_LINES} more`
234
+ : "";
235
+ return `It currently holds:\n${shown}${rest}`;
236
+ };
237
+ const open = async (reset, runtime) => {
238
+ /**
239
+ * First, and before anything that resolves a workspace.
240
+ *
241
+ * The ordering `/repo` gets from `beforeCheckout`: `exec` runs wherever the
242
+ * host's selection points, so a command issued before this line runs in
243
+ * whichever workspace the last task left open.
244
+ */
245
+ config.beforeOpen?.();
246
+ const existing = await run(PROBE_COMMAND, { cwd: "/", runtime });
247
+ if (existing.unreachable) {
248
+ return truncateOutput("could not reach the container to open the scratchpad: " +
249
+ `${existing.stderr.trim() || "no answer"}\n` +
250
+ "Nothing was created or changed. Try again in a moment.", maxChars);
251
+ }
252
+ let fresh = false;
253
+ if (!existing.success) {
254
+ // From a directory that exists, since the first thing this command does is
255
+ // create the one the rest of it runs in.
256
+ const init = await run(INIT_COMMAND, {
257
+ cwd: "/",
258
+ env: { GIT_NAME: author.name, GIT_EMAIL: author.email },
259
+ runtime
260
+ });
261
+ if (!init.success) {
262
+ return truncateOutput(`could not create the scratchpad at ${dir}: ` +
263
+ `${init.stderr.trim() || init.stdout.trim() || "git init failed"}`, maxChars);
264
+ }
265
+ fresh = true;
266
+ }
267
+ else if (reset) {
268
+ const cleaned = await run(RESET_COMMAND, { runtime });
269
+ if (!cleaned.success) {
270
+ return truncateOutput(`the scratchpad at ${dir} could not be reset: ` +
271
+ `${cleaned.stderr.trim() || cleaned.stdout.trim() || "git failed"}\n` +
272
+ "It is still there and still usable, but it holds whatever it held before.", maxChars);
273
+ }
274
+ }
275
+ // Deliberately not caught — see `afterOpen`. A scratchpad the host did not
276
+ // record cannot be delegated into, so reporting it as open would promise
277
+ // something that is not there.
278
+ const readiness = await config.afterOpen?.({ dir, fresh });
279
+ if (readiness && !readiness.ready) {
280
+ return truncateOutput(`the scratchpad at ${dir} was ${fresh ? "created" : "opened"}, but it is ` +
281
+ `not usable yet${readiness.because ? `: ${readiness.because}` : ""}. ` +
282
+ `Call ${SCRATCH_OPEN_TOOL} again before delegating.`, maxChars);
283
+ }
284
+ const opened = reset
285
+ ? `Opened the scratchpad at ${dir} and emptied it.`
286
+ : fresh
287
+ ? `Opened a scratchpad at ${dir}.`
288
+ : `Reopened the scratchpad at ${dir}, which an earlier task may have left files in.`;
289
+ return truncateOutput([
290
+ opened,
291
+ "It is a git repository with no remote, so nothing in it is pushed anywhere.",
292
+ await describeTree(reset === true, runtime)
293
+ ]
294
+ .filter(Boolean)
295
+ .join(" "), maxChars);
296
+ };
297
+ return definePlugin({
298
+ key: "scratch",
299
+ /**
300
+ * The main agent's, and no tool family — which is a decision rather than an
301
+ * omission.
302
+ *
303
+ * 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
+ * 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.
308
+ */
309
+ mainAgentTools: () => ({
310
+ [SCRATCH_OPEN_TOOL]: tool({
311
+ description: "Open a scratchpad: a git repository with no remote, in your container, " +
312
+ "for work that does not need a cloned repository. Use it before " +
313
+ "delegating anything that needs to run code but has no repository to " +
314
+ "change. Nothing in it is ever pushed.",
315
+ inputSchema: z.object({
316
+ reset: z
317
+ .boolean()
318
+ .optional()
319
+ .describe("Discard everything in the scratchpad first, including files an earlier task left")
320
+ }),
321
+ execute: ({ reset }) => open(reset, undefined)
322
+ })
323
+ }),
324
+ capability: capabilityFor(dir)
325
+ });
326
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamicagents/plugins",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Optional, composable capabilities for a Dynamic Agent: ARC-AGI-3, browser rendering, a Linux container, git and pull requests, workspace, episodic recall, and pre-turn triage. One subpath per plugin, so a bundle grows only with what it imports.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -56,6 +56,10 @@
56
56
  "types": "./dist/repo/index.d.ts",
57
57
  "import": "./dist/repo/index.js"
58
58
  },
59
+ "./scratch": {
60
+ "types": "./dist/scratch/index.d.ts",
61
+ "import": "./dist/scratch/index.js"
62
+ },
59
63
  "./claude-code": {
60
64
  "types": "./dist/claude-code/index.d.ts",
61
65
  "import": "./dist/claude-code/index.js"