@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 +1 -0
- package/dist/claude-code/recipe.js +5 -5
- package/dist/repo/index.d.ts +18 -1
- package/dist/scratch/index.d.ts +158 -0
- package/dist/scratch/index.js +326 -0
- package/package.json +5 -1
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
|
|
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,
|
|
73
|
-
"own tools — it reads, edits, runs the
|
|
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: "
|
|
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
|
*
|
package/dist/repo/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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"
|