@bastani/atomic 0.9.19-alpha.4 → 0.9.19-alpha.6
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/CHANGELOG.md +33 -0
- package/dist/builtin/intercom/CHANGELOG.md +6 -0
- package/dist/builtin/intercom/broker/client.ts +19 -1
- package/dist/builtin/intercom/index.bundle.mjs +8 -1
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/CHANGELOG.md +12 -0
- package/dist/builtin/subagents/README.md +6 -4
- package/dist/builtin/subagents/agents/codebase-online-researcher.md +7 -7
- package/dist/builtin/subagents/agents/debugger.md +2 -2
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/subagents/skills/subagent/SKILL.md +16 -14
- package/dist/builtin/subagents/src/extension/index.bundle.mjs +2 -1
- package/dist/builtin/web-access/CHANGELOG.md +7 -0
- package/dist/builtin/web-access/README.md +31 -24
- package/dist/builtin/web-access/index.bundle.mjs +16099 -77
- package/dist/builtin/web-access/package.json +2 -1
- package/dist/builtin/workflows/CHANGELOG.md +13 -0
- package/dist/builtin/workflows/builtin/{chunk-9k3ywfgd.js → chunk-1nbq96fh.js} +18 -18
- package/dist/builtin/workflows/builtin/{chunk-ahbntmx2.js → chunk-vhmnzdj9.js} +36 -36
- package/dist/builtin/workflows/builtin/{chunk-62v3gg9f.js → chunk-yeee2jrc.js} +11 -11
- package/dist/builtin/workflows/builtin/goal.js +1 -1
- package/dist/builtin/workflows/builtin/index.js +3 -3
- package/dist/builtin/workflows/builtin/open-claude-design.js +1 -1
- package/dist/builtin/workflows/builtin/ralph.js +1 -1
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/src/extension/index.bundle.mjs +96 -67
- package/dist/core/agent-session-tool-registry.d.ts.map +1 -1
- package/dist/core/agent-session-tool-registry.js +10 -0
- package/dist/core/agent-session-tool-registry.js.map +1 -1
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/tools/bash.d.ts +1 -1
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +3 -2
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/edit.d.ts +3 -0
- package/dist/core/tools/edit.d.ts.map +1 -1
- package/dist/core/tools/edit.js +91 -12
- package/dist/core/tools/edit.js.map +1 -1
- package/dist/core/tools/file-mutation-coordinator.d.ts +255 -0
- package/dist/core/tools/file-mutation-coordinator.d.ts.map +1 -0
- package/dist/core/tools/file-mutation-coordinator.js +300 -0
- package/dist/core/tools/file-mutation-coordinator.js.map +1 -0
- package/dist/core/tools/file-mutation-queue.d.ts +14 -1
- package/dist/core/tools/file-mutation-queue.d.ts.map +1 -1
- package/dist/core/tools/file-mutation-queue.js +15 -3
- package/dist/core/tools/file-mutation-queue.js.map +1 -1
- package/dist/core/tools/hashline.d.ts +9 -0
- package/dist/core/tools/hashline.d.ts.map +1 -1
- package/dist/core/tools/hashline.js +12 -1
- package/dist/core/tools/hashline.js.map +1 -1
- package/dist/core/tools/index.d.ts +9 -1
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +67 -12
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/non-minting-snapshot-store.d.ts +49 -0
- package/dist/core/tools/non-minting-snapshot-store.d.ts.map +1 -0
- package/dist/core/tools/non-minting-snapshot-store.js +64 -0
- package/dist/core/tools/non-minting-snapshot-store.js.map +1 -0
- package/dist/core/tools/powershell.d.ts +2 -2
- package/dist/core/tools/powershell.d.ts.map +1 -1
- package/dist/core/tools/powershell.js +2 -5
- package/dist/core/tools/powershell.js.map +1 -1
- package/dist/core/tools/read-document-extract.js.map +1 -1
- package/dist/core/tools/render-utils.js.map +1 -1
- package/dist/core/tools/write.d.ts +30 -1
- package/dist/core/tools/write.d.ts.map +1 -1
- package/dist/core/tools/write.js +97 -8
- package/dist/core/tools/write.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/modes/interactive/components/bash-execution.js.map +1 -1
- package/dist/modes/interactive/interactive-mode-base.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode-base.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode-base.js +3 -1
- package/dist/modes/interactive/interactive-mode-base.js.map +1 -1
- package/dist/modes/interactive/interactive-mode-surface.d.ts +1 -1
- package/dist/modes/interactive/interactive-mode-surface.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode-surface.js.map +1 -1
- package/dist/modes/interactive/interactive-render-chat.js +28 -4
- package/dist/modes/interactive/interactive-render-chat.js.map +1 -1
- package/dist/modes/interactive/interactive-startup.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-startup.js +3 -0
- package/dist/modes/interactive/interactive-startup.js.map +1 -1
- package/dist/modes/interactive-engine/activity-watchdog.d.ts +2 -2
- package/dist/modes/interactive-engine/activity-watchdog.d.ts.map +1 -1
- package/dist/modes/interactive-engine/activity-watchdog.js.map +1 -1
- package/dist/modes/interactive-engine/engine-diagnostic-view.d.ts +3 -1
- package/dist/modes/interactive-engine/engine-diagnostic-view.d.ts.map +1 -1
- package/dist/modes/interactive-engine/engine-diagnostic-view.js +11 -0
- package/dist/modes/interactive-engine/engine-diagnostic-view.js.map +1 -1
- package/dist/modes/interactive-engine/isolated-runtime.js +2 -1
- package/dist/modes/interactive-engine/isolated-runtime.js.map +1 -1
- package/dist/modes/rpc/rpc-client-process.d.ts +2 -0
- package/dist/modes/rpc/rpc-client-process.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client-process.js +23 -1
- package/dist/modes/rpc/rpc-client-process.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +20 -3
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/utils/markit.d.ts +2 -0
- package/dist/utils/markit.d.ts.map +1 -1
- package/dist/utils/markit.js +65 -5
- package/dist/utils/markit.js.map +1 -1
- package/docs/background-tasks.md +14 -10
- package/docs/containerization.md +7 -5
- package/docs/development.md +11 -0
- package/docs/docs.json +1 -0
- package/docs/providers.md +1 -1
- package/docs/subagents.md +4 -2
- package/docs/tools.md +26 -0
- package/docs/web-access.md +38 -0
- package/docs/workflows/builtins.md +9 -6
- package/examples/extensions/gondolin/index.ts +116 -148
- package/examples/extensions/gondolin/package.json +1 -1
- package/examples/extensions/ssh.ts +56 -34
- package/npm-shrinkwrap.json +33 -33
- package/package.json +4 -4
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Fetching web content
|
|
2
|
+
|
|
3
|
+
Atomic's bundled `fetch_content` tool reads webpages, PDFs, GitHub repositories, YouTube videos, and local video files.
|
|
4
|
+
|
|
5
|
+
## Arguments
|
|
6
|
+
|
|
7
|
+
Always pass the lowercase `urls` field as a nonempty array of strings, even for one URL:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{"urls": ["https://example.com/article"]}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For multiple pages:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{"urls": ["https://example.com/one", "https://example.com/two"]}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For a video, include the question in `prompt`:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{"urls": ["/path/to/recording.mp4"], "prompt": "What error appears on screen?"}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Existing prompts or integrations using `{"url": "..."}` must change to `{"urls": ["..."]}`. The singular `url` field is no longer accepted by `fetch_content`. This does not change the `url` selector on `get_search_content`.
|
|
26
|
+
|
|
27
|
+
Atomic can normalize a scalar `urls` string into a one-item array. Always use the documented array form in prompts and integrations; the legacy `url` field is not normalized.
|
|
28
|
+
|
|
29
|
+
## Troubleshooting
|
|
30
|
+
|
|
31
|
+
If argument validation fails, check that:
|
|
32
|
+
|
|
33
|
+
- The field is `urls`, not `URLs` or `url`.
|
|
34
|
+
- Its value is an array, not a string or an object.
|
|
35
|
+
- The array contains at least one nonempty string.
|
|
36
|
+
- The call has no unrecognized fields.
|
|
37
|
+
|
|
38
|
+
When fetched content is truncated, follow the returned `get_search_content` call to retrieve the stored content.
|
|
@@ -102,14 +102,17 @@ The current `goal`, `ralph`, and `open-claude-design` defaults are:
|
|
|
102
102
|
|
|
103
103
|
| Role | Primary model |
|
|
104
104
|
|---|---|
|
|
105
|
-
| Goal and Ralph orchestrators | `openai-codex/gpt-6-astra:
|
|
106
|
-
| Goal reviewers | `openai-codex/gpt-6-astra:
|
|
107
|
-
| Ralph prompt engineer
|
|
105
|
+
| Goal and Ralph orchestrators | `openai-codex/gpt-6-astra:medium` |
|
|
106
|
+
| Goal reviewers | `openai-codex/gpt-6-astra:high` |
|
|
107
|
+
| Ralph prompt engineer | `openai-codex/gpt-6-astra:high` |
|
|
108
|
+
| Ralph research | `openai-codex/gpt-6-astra:medium` |
|
|
108
109
|
| Ralph reviewer A | `anthropic/claude-fable-5-1:high` |
|
|
109
|
-
| Ralph reviewer B | `openai-codex/gpt-6-astra:
|
|
110
|
-
| Open Claude Design model stages | `
|
|
110
|
+
| Ralph reviewer B | `openai-codex/gpt-6-astra:high` |
|
|
111
|
+
| Open Claude Design model stages | `anthropic/claude-fable-5-1:medium` |
|
|
111
112
|
|
|
112
|
-
Astra-led chains try GitHub Copilot Astra, OpenAI Astra, Anthropic Fable 5.1, then GitHub Copilot Fable 5.1 before older models. Ralph reviewer A starts with GitHub Copilot Fable 5.1, then Codex, Copilot, and OpenAI Astra at `
|
|
113
|
+
Astra-led chains try GitHub Copilot Astra, OpenAI Astra, Anthropic Fable 5.1, then GitHub Copilot Fable 5.1 before older models. Ralph reviewer A starts with GitHub Copilot Fable 5.1, then Codex, Copilot, and OpenAI Astra at `high`. Open Claude Design starts with Anthropic and Copilot Fable 5.1, then Codex, Copilot, and OpenAI Astra, all at `medium`; its OpenRouter group also puts Fable 5.1 before Astra.
|
|
114
|
+
|
|
115
|
+
Goal and Ralph orchestration, Ralph research, and design use Fable 5.1/Fable 5 at `medium` and Sol at `high` in their fallbacks. Goal and Ralph reviewers use Astra/Sol at `high`. Ralph prompt refinement keeps its Fable `high` and Sol `xhigh` fallbacks. Later fallback order remains role-specific: Ralph research puts Fable 5 before Sol, while the orchestrators put Sol before Fable 5. Reviewer A puts Kimi before Sol; Goal reviewers and Ralph reviewer B put Sol before Kimi. OpenRouter mirrors follow direct-provider candidates. These are configured preferences, not guarantees of provider or account availability.
|
|
113
116
|
|
|
114
117
|
### Six composable pattern builtins
|
|
115
118
|
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gondolin Tool Routing Example
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* directory is mounted at /workspace
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Routes read, write, edit, bash, find, ls and user ! commands into a local
|
|
5
|
+
* Gondolin micro-VM. The host working directory is mounted at /workspace;
|
|
6
|
+
* changes there write through to the host. Other guest changes stay in the VM.
|
|
7
|
+
* Search and other unoverridden tools still run on the host. For guest-only
|
|
8
|
+
* content searches use the routed shell; this is not whole-session isolation.
|
|
8
9
|
*
|
|
9
10
|
* Setup:
|
|
10
11
|
* cd packages/coding-agent/examples/extensions/gondolin
|
|
11
|
-
* npm
|
|
12
|
+
* npm ci --ignore-scripts
|
|
13
|
+
* npm run check # from this repository: full Gondolin + SSH adapter typecheck
|
|
12
14
|
*
|
|
13
15
|
* Usage:
|
|
14
16
|
* cd /path/to/project
|
|
@@ -24,33 +26,22 @@ import type { ExtensionAPI, ExtensionContext } from "@bastani/atomic";
|
|
|
24
26
|
import {
|
|
25
27
|
type BashOperations,
|
|
26
28
|
createBashTool,
|
|
29
|
+
createCodingTools,
|
|
27
30
|
createEditTool,
|
|
28
31
|
createFindTool,
|
|
29
|
-
createGrepTool,
|
|
30
32
|
createLsTool,
|
|
31
33
|
createReadTool,
|
|
32
34
|
createWriteTool,
|
|
33
|
-
DEFAULT_MAX_BYTES,
|
|
34
35
|
type EditOperations,
|
|
35
36
|
type FindOperations,
|
|
36
|
-
formatSize,
|
|
37
|
-
type GrepToolDetails,
|
|
38
|
-
type GrepToolInput,
|
|
39
37
|
type LsOperations,
|
|
40
38
|
type ReadOperations,
|
|
41
|
-
truncateHead,
|
|
42
|
-
truncateLine,
|
|
43
39
|
type WriteOperations,
|
|
44
40
|
} from "@bastani/atomic";
|
|
45
41
|
import { RealFSProvider, VM } from "@earendil-works/gondolin";
|
|
46
42
|
|
|
47
43
|
const GUEST_WORKSPACE = "/workspace";
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
type TextToolResult<TDetails> = {
|
|
51
|
-
content: Array<{ type: "text"; text: string }>;
|
|
52
|
-
details: TDetails | undefined;
|
|
53
|
-
};
|
|
44
|
+
const GUEST_FUSE_MOUNT = "/data";
|
|
54
45
|
|
|
55
46
|
function stripAtPrefix(value: string): string {
|
|
56
47
|
return value.startsWith("@") ? value.slice(1) : value;
|
|
@@ -98,20 +89,99 @@ function createGondolinReadOps(vm: VM, localCwd: string): ReadOperations {
|
|
|
98
89
|
};
|
|
99
90
|
}
|
|
100
91
|
|
|
101
|
-
function createGondolinWriteOps(vm: VM, localCwd: string): WriteOperations {
|
|
92
|
+
function createGondolinWriteOps(vm: VM, localCwd: string, workspace: RealFSProvider): WriteOperations {
|
|
93
|
+
const workspaceRelativePath = (target: string) => {
|
|
94
|
+
for (const mount of [GUEST_WORKSPACE, `${GUEST_FUSE_MOUNT}${GUEST_WORKSPACE}`]) {
|
|
95
|
+
if (target === mount || target.startsWith(`${mount}/`)) return target.slice(mount.length) || "/";
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
};
|
|
102
99
|
return {
|
|
103
|
-
writeFile: async (filePath, content) => {
|
|
104
|
-
|
|
100
|
+
writeFile: async (filePath, content, options) => {
|
|
101
|
+
const target = toGuestPath(localCwd, filePath);
|
|
102
|
+
if (!options?.exclusive) {
|
|
103
|
+
await vm.fs.writeFile(target, content, { encoding: "utf8" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Both fs.writeFile and the 0.12.0 FUSE bridge discard exclusive flags.
|
|
107
|
+
// Open through the exact provider mounted at /workspace, not unrelated host IO.
|
|
108
|
+
let relativePath = workspaceRelativePath(target);
|
|
109
|
+
if (relativePath === undefined) {
|
|
110
|
+
// A guest symlink can route an otherwise native-looking parent onto FUSE.
|
|
111
|
+
const parent = await vm.exec(["/bin/sh", "-c", 'cd "$1" && pwd -P', "sh", path.posix.dirname(target)]);
|
|
112
|
+
if (parent.exitCode !== 0) throw new Error(parent.stderr);
|
|
113
|
+
relativePath = workspaceRelativePath(
|
|
114
|
+
path.posix.join(parent.stdout.replace(/\n$/, ""), path.posix.basename(target)),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (relativePath !== undefined) {
|
|
118
|
+
const mountedPath = relativePath;
|
|
119
|
+
const handle = await workspace.open(mountedPath, "wx").catch(async (error: NodeJS.ErrnoException) => {
|
|
120
|
+
// The provider resolves links before open: a dangling collision becomes ENOENT.
|
|
121
|
+
// Check occupancy in that same mounted namespace, without following the link.
|
|
122
|
+
if (
|
|
123
|
+
error.code === "ENOENT" &&
|
|
124
|
+
(await workspace.lstat(mountedPath).catch(() => undefined))?.isSymbolicLink()
|
|
125
|
+
) {
|
|
126
|
+
throw Object.assign(new Error(`EEXIST: file already exists, open '${target}'`), { code: "EEXIST" });
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
await handle.writeFile(content, { encoding: "utf8" });
|
|
132
|
+
} finally {
|
|
133
|
+
await handle.close();
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Native guest paths do not cross FUSE. Noclobber is enforced by the guest OS.
|
|
138
|
+
const result = await vm.exec(
|
|
139
|
+
[
|
|
140
|
+
"/bin/sh",
|
|
141
|
+
"-c",
|
|
142
|
+
'(set -C; cat > "$1") || { if [ -e "$1" ] || [ -L "$1" ]; then exit 17; else exit 1; fi; }',
|
|
143
|
+
"sh",
|
|
144
|
+
target,
|
|
145
|
+
],
|
|
146
|
+
{ stdin: content },
|
|
147
|
+
);
|
|
148
|
+
if (result.exitCode !== 0) {
|
|
149
|
+
const error = new Error(`Failed to create guest file '${target}': ${result.stderr}`);
|
|
150
|
+
if (result.exitCode === 17) throw Object.assign(error, { code: "EEXIST" });
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
105
153
|
},
|
|
106
154
|
mkdir: async (dirPath) => {
|
|
107
155
|
await vm.fs.mkdir(toGuestPath(localCwd, dirPath), { recursive: true });
|
|
108
156
|
},
|
|
157
|
+
// Reads inside the guest, so the checks `write` runs before overwriting see the same
|
|
158
|
+
// filesystem the write lands on rather than the host's. Absence is `undefined`; a path
|
|
159
|
+
// that exists but cannot be read keeps throwing, since it is not a free path.
|
|
160
|
+
readFile: async (filePath) => {
|
|
161
|
+
const target = toGuestPath(localCwd, filePath);
|
|
162
|
+
try {
|
|
163
|
+
return await vm.fs.readFile(target, { encoding: "utf8" });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// Gondolin 0.12.0 wraps filesystem errors without preserving errno. Probe
|
|
166
|
+
// the guest namespace rather than parsing localized error messages.
|
|
167
|
+
const probe = await vm.exec([
|
|
168
|
+
"/bin/sh",
|
|
169
|
+
"-c",
|
|
170
|
+
'(cd "$2") && { if [ -e "$1" ] || [ -L "$1" ]; then exit 0; else exit 44; fi; }',
|
|
171
|
+
"sh",
|
|
172
|
+
target,
|
|
173
|
+
path.posix.dirname(target),
|
|
174
|
+
]);
|
|
175
|
+
if (probe.exitCode === 44) return undefined;
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
109
179
|
};
|
|
110
180
|
}
|
|
111
181
|
|
|
112
|
-
function createGondolinEditOps(vm: VM, localCwd: string): EditOperations {
|
|
182
|
+
function createGondolinEditOps(vm: VM, localCwd: string, workspace: RealFSProvider): EditOperations {
|
|
113
183
|
const readOps = createGondolinReadOps(vm, localCwd);
|
|
114
|
-
const writeOps = createGondolinWriteOps(vm, localCwd);
|
|
184
|
+
const writeOps = createGondolinWriteOps(vm, localCwd, workspace);
|
|
115
185
|
return {
|
|
116
186
|
readFile: readOps.readFile,
|
|
117
187
|
writeFile: writeOps.writeFile,
|
|
@@ -203,110 +273,6 @@ function createGondolinFindOps(vm: VM, localCwd: string): FindOperations {
|
|
|
203
273
|
};
|
|
204
274
|
}
|
|
205
275
|
|
|
206
|
-
function createLineMatcher(pattern: string, literal: boolean | undefined, ignoreCase: boolean | undefined) {
|
|
207
|
-
if (literal) {
|
|
208
|
-
const needle = ignoreCase ? pattern.toLowerCase() : pattern;
|
|
209
|
-
return (line: string) => (ignoreCase ? line.toLowerCase() : line).includes(needle);
|
|
210
|
-
}
|
|
211
|
-
const regex = new RegExp(pattern, ignoreCase ? "i" : undefined);
|
|
212
|
-
return (line: string) => regex.test(line);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function appendGrepBlock(params: {
|
|
216
|
-
outputLines: string[];
|
|
217
|
-
lines: string[];
|
|
218
|
-
relativePath: string;
|
|
219
|
-
lineIndex: number;
|
|
220
|
-
contextLines: number;
|
|
221
|
-
}): boolean {
|
|
222
|
-
let linesTruncated = false;
|
|
223
|
-
const start = params.contextLines > 0 ? Math.max(0, params.lineIndex - params.contextLines) : params.lineIndex;
|
|
224
|
-
const end =
|
|
225
|
-
params.contextLines > 0
|
|
226
|
-
? Math.min(params.lines.length - 1, params.lineIndex + params.contextLines)
|
|
227
|
-
: params.lineIndex;
|
|
228
|
-
|
|
229
|
-
for (let index = start; index <= end; index++) {
|
|
230
|
-
const rawLine = params.lines[index] ?? "";
|
|
231
|
-
const { text, wasTruncated } = truncateLine(rawLine.replace(/\r/g, ""));
|
|
232
|
-
if (wasTruncated) linesTruncated = true;
|
|
233
|
-
const separator = index === params.lineIndex ? ":" : "-";
|
|
234
|
-
params.outputLines.push(`${params.relativePath}${separator}${index + 1}${separator} ${text}`);
|
|
235
|
-
}
|
|
236
|
-
return linesTruncated;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function executeGondolinGrep(
|
|
240
|
-
vm: VM,
|
|
241
|
-
localCwd: string,
|
|
242
|
-
params: GrepToolInput,
|
|
243
|
-
signal?: AbortSignal,
|
|
244
|
-
): Promise<TextToolResult<GrepToolDetails>> {
|
|
245
|
-
const root = toGuestPath(localCwd, params.path ?? ".");
|
|
246
|
-
const rootStat = await vm.fs.stat(root, { signal });
|
|
247
|
-
const rootIsDirectory = rootStat.isDirectory();
|
|
248
|
-
const matcher = createLineMatcher(params.pattern, params.literal, params.ignoreCase);
|
|
249
|
-
const contextLines = params.context && params.context > 0 ? params.context : 0;
|
|
250
|
-
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
|
|
251
|
-
const outputLines: string[] = [];
|
|
252
|
-
const details: GrepToolDetails = {};
|
|
253
|
-
let matchCount = 0;
|
|
254
|
-
let matchLimitReached = false;
|
|
255
|
-
let linesTruncated = false;
|
|
256
|
-
|
|
257
|
-
await walkGuestFiles(
|
|
258
|
-
vm,
|
|
259
|
-
root,
|
|
260
|
-
async (guestPath, relativePath) => {
|
|
261
|
-
if (matchCount >= effectiveLimit) return false;
|
|
262
|
-
if (params.glob && !matchesToolGlob(relativePath, params.glob)) return true;
|
|
263
|
-
let content: string;
|
|
264
|
-
try {
|
|
265
|
-
content = await vm.fs.readFile(guestPath, { encoding: "utf8", signal });
|
|
266
|
-
} catch {
|
|
267
|
-
return true;
|
|
268
|
-
}
|
|
269
|
-
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
270
|
-
const displayPath = rootIsDirectory ? relativePath : path.posix.basename(guestPath);
|
|
271
|
-
for (let index = 0; index < lines.length; index++) {
|
|
272
|
-
if (signal?.aborted) throw new Error("Operation aborted");
|
|
273
|
-
if (!matcher(lines[index] ?? "")) continue;
|
|
274
|
-
matchCount++;
|
|
275
|
-
if (appendGrepBlock({ outputLines, lines, relativePath: displayPath, lineIndex: index, contextLines })) {
|
|
276
|
-
linesTruncated = true;
|
|
277
|
-
}
|
|
278
|
-
if (matchCount >= effectiveLimit) {
|
|
279
|
-
matchLimitReached = true;
|
|
280
|
-
return false;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
return true;
|
|
284
|
-
},
|
|
285
|
-
signal,
|
|
286
|
-
);
|
|
287
|
-
if (matchCount === 0) return { content: [{ type: "text", text: "No matches found" }], details: undefined };
|
|
288
|
-
const rawOutput = outputLines.join("\n");
|
|
289
|
-
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
290
|
-
const notices: string[] = [];
|
|
291
|
-
let output = truncation.content;
|
|
292
|
-
if (matchLimitReached) {
|
|
293
|
-
details.matchLimitReached = effectiveLimit;
|
|
294
|
-
notices.push(`${effectiveLimit} matches limit reached`);
|
|
295
|
-
}
|
|
296
|
-
if (linesTruncated) {
|
|
297
|
-
details.linesTruncated = true;
|
|
298
|
-
notices.push("long lines truncated");
|
|
299
|
-
}
|
|
300
|
-
if (truncation.truncated) {
|
|
301
|
-
details.truncation = truncation;
|
|
302
|
-
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
303
|
-
}
|
|
304
|
-
if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`;
|
|
305
|
-
return {
|
|
306
|
-
content: [{ type: "text", text: output }],
|
|
307
|
-
details: Object.keys(details).length > 0 ? details : undefined,
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
276
|
function createGondolinBashOps(vm: VM, localCwd: string, shellPath: string): BashOperations {
|
|
311
277
|
return {
|
|
312
278
|
exec: async (command, cwd, { onData, signal, timeout }) => {
|
|
@@ -348,23 +314,33 @@ function createGondolinBashOps(vm: VM, localCwd: string, shellPath: string): Bas
|
|
|
348
314
|
}
|
|
349
315
|
export default function (pi: ExtensionAPI) {
|
|
350
316
|
const localCwd = process.cwd();
|
|
317
|
+
const workspace = new RealFSProvider(localCwd);
|
|
351
318
|
const localRead = createReadTool(localCwd);
|
|
352
319
|
const localWrite = createWriteTool(localCwd);
|
|
353
320
|
const localEdit = createEditTool(localCwd);
|
|
354
321
|
const localBash = createBashTool(localCwd);
|
|
355
|
-
const localGrep = createGrepTool(localCwd);
|
|
356
322
|
const localFind = createFindTool(localCwd);
|
|
357
323
|
const localLs = createLsTool(localCwd);
|
|
358
324
|
let vm: VM | undefined;
|
|
359
325
|
let vmStarting: Promise<VM> | undefined;
|
|
360
326
|
let shellPath = "/bin/sh";
|
|
327
|
+
let fileTools: ReturnType<typeof createCodingTools> | undefined;
|
|
328
|
+
function getFileTools(activeVm: VM) {
|
|
329
|
+
fileTools ??= createCodingTools(GUEST_WORKSPACE, {
|
|
330
|
+
read: { operations: createGondolinReadOps(activeVm, localCwd) },
|
|
331
|
+
write: { operations: createGondolinWriteOps(activeVm, localCwd, workspace) },
|
|
332
|
+
edit: { operations: createGondolinEditOps(activeVm, localCwd, workspace) },
|
|
333
|
+
});
|
|
334
|
+
return fileTools;
|
|
335
|
+
}
|
|
361
336
|
async function startVm(ctx?: ExtensionContext): Promise<VM> {
|
|
362
337
|
ctx?.ui.setStatus("gondolin", ctx.ui.theme.fg("accent", `Gondolin: starting ${GUEST_WORKSPACE}`));
|
|
363
338
|
const created = await VM.create({
|
|
364
339
|
sessionLabel: `atomic ${path.basename(localCwd)}`,
|
|
365
340
|
vfs: {
|
|
341
|
+
fuseMount: GUEST_FUSE_MOUNT,
|
|
366
342
|
mounts: {
|
|
367
|
-
[GUEST_WORKSPACE]:
|
|
343
|
+
[GUEST_WORKSPACE]: workspace,
|
|
368
344
|
},
|
|
369
345
|
},
|
|
370
346
|
});
|
|
@@ -388,9 +364,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
388
364
|
return vmStarting;
|
|
389
365
|
}
|
|
390
366
|
pi.on("session_start", async (_event, ctx) => {
|
|
367
|
+
fileTools = undefined;
|
|
391
368
|
await ensureVm(ctx);
|
|
392
369
|
});
|
|
393
370
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
371
|
+
fileTools = undefined;
|
|
394
372
|
const activeVm = vm;
|
|
395
373
|
vm = undefined;
|
|
396
374
|
vmStarting = undefined;
|
|
@@ -421,30 +399,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
421
399
|
...localRead,
|
|
422
400
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
423
401
|
const activeVm = await ensureVm(ctx);
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return tool.execute(id, params, signal, onUpdate);
|
|
402
|
+
return getFileTools(activeVm)
|
|
403
|
+
.find((tool) => tool.name === "read")!
|
|
404
|
+
.execute(id, params, signal, onUpdate);
|
|
428
405
|
},
|
|
429
406
|
});
|
|
430
407
|
pi.registerTool({
|
|
431
408
|
...localWrite,
|
|
432
409
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
433
410
|
const activeVm = await ensureVm(ctx);
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
return tool.execute(id, params, signal, onUpdate);
|
|
411
|
+
return getFileTools(activeVm)
|
|
412
|
+
.find((tool) => tool.name === "write")!
|
|
413
|
+
.execute(id, params, signal, onUpdate);
|
|
438
414
|
},
|
|
439
415
|
});
|
|
440
416
|
pi.registerTool({
|
|
441
417
|
...localEdit,
|
|
442
418
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
443
419
|
const activeVm = await ensureVm(ctx);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
return tool.execute(id, params, signal, onUpdate);
|
|
420
|
+
return getFileTools(activeVm)
|
|
421
|
+
.find((tool) => tool.name === "edit")!
|
|
422
|
+
.execute(id, params, signal, onUpdate);
|
|
448
423
|
},
|
|
449
424
|
});
|
|
450
425
|
pi.registerTool({
|
|
@@ -477,13 +452,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
477
452
|
return tool.execute(id, params, signal, onUpdate);
|
|
478
453
|
},
|
|
479
454
|
});
|
|
480
|
-
pi.registerTool({
|
|
481
|
-
...localGrep,
|
|
482
|
-
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
483
|
-
const activeVm = await ensureVm(ctx);
|
|
484
|
-
return executeGondolinGrep(activeVm, localCwd, params, signal);
|
|
485
|
-
},
|
|
486
|
-
});
|
|
487
455
|
pi.on("user_bash", async (_event, ctx) => {
|
|
488
456
|
const activeVm = await ensureVm(ctx);
|
|
489
457
|
return { operations: createGondolinBashOps(activeVm, localCwd, shellPath) };
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { spawn } from "node:child_process";
|
|
17
|
+
import { dirname } from "node:path";
|
|
17
18
|
import type { ExtensionAPI } from "@bastani/atomic";
|
|
18
19
|
import {
|
|
19
20
|
type BashOperations,
|
|
20
21
|
createBashTool,
|
|
22
|
+
createCodingTools,
|
|
21
23
|
createEditTool,
|
|
22
24
|
createReadTool,
|
|
23
25
|
createWriteTool,
|
|
@@ -64,11 +66,48 @@ function createRemoteReadOps(remote: string, remoteCwd: string, localCwd: string
|
|
|
64
66
|
function createRemoteWriteOps(remote: string, remoteCwd: string, localCwd: string): WriteOperations {
|
|
65
67
|
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
|
|
66
68
|
return {
|
|
67
|
-
writeFile: async (p, content) => {
|
|
69
|
+
writeFile: async (p, content, options) => {
|
|
68
70
|
const b64 = Buffer.from(content).toString("base64");
|
|
69
|
-
|
|
71
|
+
const target = JSON.stringify(toRemote(p));
|
|
72
|
+
// `set -C` is the shell's noclobber, so `>` on an existing path fails instead of
|
|
73
|
+
// truncating it. That is the remote equivalent of `O_EXCL`, and it is what keeps the
|
|
74
|
+
// create path from silently taking a file that appeared since the read below.
|
|
75
|
+
const redirect = options?.exclusive ? `set -C; base64 -d > ${target}` : `base64 -d > ${target}`;
|
|
76
|
+
try {
|
|
77
|
+
await sshExec(remote, `echo ${JSON.stringify(b64)} | { ${redirect}; }`);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// A remote shell reports noclobber as a bare non-zero exit, carrying no `code` to read.
|
|
80
|
+
// `write` renders an exclusive-create failure as a `target_exists` conflict only when the
|
|
81
|
+
// rejection carries `EEXIST`, so the collision has to be named here; otherwise this backend
|
|
82
|
+
// alone surfaces an opaque SSH error where every other one reports a typed conflict.
|
|
83
|
+
if (!options?.exclusive) throw error;
|
|
84
|
+
// Re-probe rather than assume the failure was a collision. A permission error, a full disk,
|
|
85
|
+
// or a dropped connection fail the same write, and each of those stays itself.
|
|
86
|
+
const probe = await sshExec(
|
|
87
|
+
remote,
|
|
88
|
+
`{ test -e ${target} || test -L ${target}; } && echo yes || echo no`,
|
|
89
|
+
).catch(() => undefined);
|
|
90
|
+
if (probe?.toString().trim() !== "yes") throw error;
|
|
91
|
+
throw Object.assign(new Error(`EEXIST: file already exists, open '${toRemote(p)}'`), { code: "EEXIST" });
|
|
92
|
+
}
|
|
70
93
|
},
|
|
71
94
|
mkdir: (dir) => sshExec(remote, `mkdir -p ${JSON.stringify(toRemote(dir))}`).then(() => {}),
|
|
95
|
+
// Reads the same filesystem the write lands on, which is the point: checking the local
|
|
96
|
+
// disk would let `write` decide what to do about a remote file by looking at a different
|
|
97
|
+
// machine. A missing path is `undefined`; anything else, including an unreadable one,
|
|
98
|
+
// stays a failure.
|
|
99
|
+
readFile: async (p) => {
|
|
100
|
+
const target = JSON.stringify(toRemote(p));
|
|
101
|
+
// `write` has already created the parent, so failure to enter it must remain
|
|
102
|
+
// an error. A subshell preserves the cwd used to resolve relative targets.
|
|
103
|
+
const parent = JSON.stringify(toRemote(dirname(p)));
|
|
104
|
+
const probe = await sshExec(
|
|
105
|
+
remote,
|
|
106
|
+
`(cd ${parent}) && { { test -e ${target} || test -L ${target}; } && echo yes || echo no; }`,
|
|
107
|
+
);
|
|
108
|
+
if (probe.toString().trim() === "no") return undefined;
|
|
109
|
+
return (await sshExec(remote, `cat ${target}`)).toString("utf8");
|
|
110
|
+
},
|
|
72
111
|
};
|
|
73
112
|
}
|
|
74
113
|
|
|
@@ -122,66 +161,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
122
161
|
|
|
123
162
|
// Resolved lazily on session_start (CLI flags not available during factory)
|
|
124
163
|
let resolvedSsh: { remote: string; remoteCwd: string } | null = null;
|
|
164
|
+
// The bundle shares one snapshot store across read/edit/write until the next session.
|
|
165
|
+
let tools = createCodingTools(localCwd);
|
|
125
166
|
|
|
126
167
|
const getSsh = () => resolvedSsh;
|
|
127
168
|
|
|
128
169
|
pi.registerTool({
|
|
129
170
|
...localRead,
|
|
130
171
|
async execute(id, params, signal, onUpdate, _ctx) {
|
|
131
|
-
|
|
132
|
-
if (ssh) {
|
|
133
|
-
const tool = createReadTool(localCwd, {
|
|
134
|
-
operations: createRemoteReadOps(ssh.remote, ssh.remoteCwd, localCwd),
|
|
135
|
-
});
|
|
136
|
-
return tool.execute(id, params, signal, onUpdate);
|
|
137
|
-
}
|
|
138
|
-
return localRead.execute(id, params, signal, onUpdate);
|
|
172
|
+
return tools.find((tool) => tool.name === "read")!.execute(id, params, signal, onUpdate);
|
|
139
173
|
},
|
|
140
174
|
});
|
|
141
175
|
|
|
142
176
|
pi.registerTool({
|
|
143
177
|
...localWrite,
|
|
144
178
|
async execute(id, params, signal, onUpdate, _ctx) {
|
|
145
|
-
|
|
146
|
-
if (ssh) {
|
|
147
|
-
const tool = createWriteTool(localCwd, {
|
|
148
|
-
operations: createRemoteWriteOps(ssh.remote, ssh.remoteCwd, localCwd),
|
|
149
|
-
});
|
|
150
|
-
return tool.execute(id, params, signal, onUpdate);
|
|
151
|
-
}
|
|
152
|
-
return localWrite.execute(id, params, signal, onUpdate);
|
|
179
|
+
return tools.find((tool) => tool.name === "write")!.execute(id, params, signal, onUpdate);
|
|
153
180
|
},
|
|
154
181
|
});
|
|
155
182
|
|
|
156
183
|
pi.registerTool({
|
|
157
184
|
...localEdit,
|
|
158
185
|
async execute(id, params, signal, onUpdate, _ctx) {
|
|
159
|
-
|
|
160
|
-
if (ssh) {
|
|
161
|
-
const tool = createEditTool(localCwd, {
|
|
162
|
-
operations: createRemoteEditOps(ssh.remote, ssh.remoteCwd, localCwd),
|
|
163
|
-
});
|
|
164
|
-
return tool.execute(id, params, signal, onUpdate);
|
|
165
|
-
}
|
|
166
|
-
return localEdit.execute(id, params, signal, onUpdate);
|
|
186
|
+
return tools.find((tool) => tool.name === "edit")!.execute(id, params, signal, onUpdate);
|
|
167
187
|
},
|
|
168
188
|
});
|
|
169
189
|
|
|
170
190
|
pi.registerTool({
|
|
171
191
|
...localBash,
|
|
172
192
|
async execute(id, params, signal, onUpdate, _ctx) {
|
|
173
|
-
|
|
174
|
-
if (ssh) {
|
|
175
|
-
const tool = createBashTool(localCwd, {
|
|
176
|
-
operations: createRemoteBashOps(ssh.remote, ssh.remoteCwd, localCwd),
|
|
177
|
-
});
|
|
178
|
-
return tool.execute(id, params, signal, onUpdate);
|
|
179
|
-
}
|
|
180
|
-
return localBash.execute(id, params, signal, onUpdate);
|
|
193
|
+
return tools.find((tool) => tool.name === "bash")!.execute(id, params, signal, onUpdate);
|
|
181
194
|
},
|
|
182
195
|
});
|
|
183
196
|
|
|
184
197
|
pi.on("session_start", async (_event, ctx) => {
|
|
198
|
+
tools = createCodingTools(localCwd);
|
|
199
|
+
resolvedSsh = null;
|
|
185
200
|
// Resolve SSH config now that CLI flags are available
|
|
186
201
|
const arg = pi.getFlag("ssh") as string | undefined;
|
|
187
202
|
if (arg) {
|
|
@@ -194,6 +209,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
194
209
|
const pwd = (await sshExec(remote, "pwd")).toString().trim();
|
|
195
210
|
resolvedSsh = { remote, remoteCwd: pwd };
|
|
196
211
|
}
|
|
212
|
+
const { remote, remoteCwd } = resolvedSsh;
|
|
213
|
+
tools = createCodingTools(localCwd, {
|
|
214
|
+
read: { operations: createRemoteReadOps(remote, remoteCwd, localCwd) },
|
|
215
|
+
write: { operations: createRemoteWriteOps(remote, remoteCwd, localCwd) },
|
|
216
|
+
edit: { operations: createRemoteEditOps(remote, remoteCwd, localCwd) },
|
|
217
|
+
bash: { operations: createRemoteBashOps(remote, remoteCwd, localCwd) },
|
|
218
|
+
});
|
|
197
219
|
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("accent", `SSH: ${resolvedSsh.remote}:${resolvedSsh.remoteCwd}`));
|
|
198
220
|
ctx.ui.notify(`SSH mode: ${resolvedSsh.remote}:${resolvedSsh.remoteCwd}`, "info");
|
|
199
221
|
}
|