@parcha/pi-durable-disk 0.0.0-stage → 0.1.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +695 -2
  5. package/bin/archil-scoped +165 -0
  6. package/dist/app.d.ts +40 -0
  7. package/dist/app.js +47 -0
  8. package/dist/claim.d.ts +222 -0
  9. package/dist/claim.js +722 -0
  10. package/dist/cli.d.ts +64 -0
  11. package/dist/cli.js +664 -0
  12. package/dist/commands.d.ts +67 -0
  13. package/dist/commands.js +299 -0
  14. package/dist/confine.d.ts +91 -0
  15. package/dist/confine.js +341 -0
  16. package/dist/env.d.ts +129 -0
  17. package/dist/env.js +608 -0
  18. package/dist/errors.d.ts +47 -0
  19. package/dist/errors.js +55 -0
  20. package/dist/fork.d.ts +41 -0
  21. package/dist/fork.js +181 -0
  22. package/dist/hosts/daytona-launch.d.ts +52 -0
  23. package/dist/hosts/daytona-launch.js +270 -0
  24. package/dist/hosts/daytona.d.ts +147 -0
  25. package/dist/hosts/daytona.js +368 -0
  26. package/dist/hosts/docker.d.ts +103 -0
  27. package/dist/hosts/docker.js +362 -0
  28. package/dist/hosts/local-host.d.ts +71 -0
  29. package/dist/hosts/local-host.js +356 -0
  30. package/dist/index.d.ts +16 -0
  31. package/dist/index.js +17 -0
  32. package/dist/lease.d.ts +7 -0
  33. package/dist/lease.js +6 -0
  34. package/dist/local-run.d.ts +10 -0
  35. package/dist/local-run.js +63 -0
  36. package/dist/park.d.ts +92 -0
  37. package/dist/park.js +229 -0
  38. package/dist/run.d.ts +290 -0
  39. package/dist/run.js +785 -0
  40. package/dist/serve.d.ts +101 -0
  41. package/dist/serve.js +393 -0
  42. package/dist/status.d.ts +123 -0
  43. package/dist/status.js +264 -0
  44. package/dist/store.d.ts +103 -0
  45. package/dist/store.js +217 -0
  46. package/dist/supervise.d.ts +340 -0
  47. package/dist/supervise.js +528 -0
  48. package/dist/watchdog.d.ts +39 -0
  49. package/dist/watchdog.js +137 -0
  50. package/package.json +87 -4
@@ -0,0 +1,341 @@
1
+ // Containment of the in-process file operations. pi's read, write and edit run in the loop's process with host
2
+ // fs calls and no containment, so an agent can name `../store/run.sqlite`, `../run.json` or `../owner.lock` and corrupt
3
+ // its own durable store or claim; a sandbox that confines commands (an app's own) does not cover them. Every path an operation names is
4
+ // resolved to the file the kernel would reach (symlinks followed, a missing tail kept) and refused unless it lies in the
5
+ // workspace, or, for reads, in a read-only root.
6
+ //
7
+ // The check alone is a check-then-use: a command running next to the tool can swap a component for a symlink between the
8
+ // check and the open. So the operation never walks the path again. The resolved path is opened one component at a time
9
+ // from a pinned directory handle of its root, each step relative to the previous handle and with O_NOFOLLOW, which is
10
+ // openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS) done in user space. Node has no openat, so a handle's directory is named
11
+ // `/proc/self/fd/<fd>`: the kernel jumps straight to the held inode there and never re-walks the directory's own path. A
12
+ // swapped component fails the open (ELOOP or ENOTDIR) and the call is refused as changed; nothing is retried.
13
+ import { constants, existsSync, realpathSync } from "node:fs";
14
+ import { lstat, mkdir, open, readlink, realpath } from "node:fs/promises";
15
+ import { basename, dirname, join } from "node:path";
16
+ import { err, FileError, ok } from "@earendil-works/pi-durable/env";
17
+ /**
18
+ * A file operation named a path that is not under the workspace (`outside_work`), or one whose components changed
19
+ * while it was being resolved (`changed`). pi turns it into a failed tool result that names the path. Its `code` is pi's
20
+ * `permission_denied`.
21
+ */
22
+ export class PathOutsideWorkError extends FileError {
23
+ reason;
24
+ constructor(path, reason, message) {
25
+ super("permission_denied", message, path);
26
+ this.name = "PathOutsideWorkError";
27
+ this.reason = reason;
28
+ }
29
+ }
30
+ const MAX_LINKS = 40;
31
+ const DIRECTORY = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
32
+ // Nonblocking, so opening a FIFO does not wait for a peer; a regular file is unaffected.
33
+ const FILE = constants.O_NOFOLLOW | constants.O_NONBLOCK;
34
+ /** Test seam: runs after a path was resolved and checked, before anything is opened. */
35
+ export const confineHooks = {};
36
+ function errno(error) {
37
+ const code = error instanceof Error ? error.code : undefined;
38
+ return typeof code === "string" ? code : undefined;
39
+ }
40
+ const isMissing = (error) => errno(error) === "ENOENT" || errno(error) === "ENOTDIR";
41
+ const FILE_ERRORS = {
42
+ ABORT_ERR: "aborted",
43
+ ENOENT: "not_found",
44
+ EACCES: "permission_denied",
45
+ EPERM: "permission_denied",
46
+ ENOTDIR: "not_directory",
47
+ EISDIR: "is_directory",
48
+ EINVAL: "invalid",
49
+ ELOOP: "invalid",
50
+ };
51
+ /** pi's mapping from a Node error to its `FileError` codes (pi does not export it). */
52
+ export function fileError(error, path) {
53
+ if (error instanceof FileError)
54
+ return error;
55
+ const cause = error instanceof Error ? error : new Error(String(error));
56
+ const code = errno(cause);
57
+ return new FileError((code !== undefined && FILE_ERRORS[code]) || "unknown", cause.message, path, cause);
58
+ }
59
+ /**
60
+ * The path the kernel would reach for `path` (absolute, lexically normalized): every existing component resolved,
61
+ * symlinks included, dangling ones followed to where they would create; a missing tail is appended as it is.
62
+ */
63
+ export async function canonicalize(path) {
64
+ try {
65
+ return await realpath(path);
66
+ }
67
+ catch (error) {
68
+ if (!isMissing(error))
69
+ throw error;
70
+ }
71
+ let resolved = "/";
72
+ let links = 0;
73
+ const pending = path.split("/").filter((part) => part !== "");
74
+ while (pending.length > 0) {
75
+ const name = pending.shift();
76
+ if (name === ".")
77
+ continue;
78
+ if (name === "..") {
79
+ resolved = dirname(resolved);
80
+ continue;
81
+ }
82
+ const next = join(resolved, name);
83
+ let stats;
84
+ try {
85
+ stats = await lstat(next);
86
+ }
87
+ catch (error) {
88
+ if (isMissing(error))
89
+ return join(next, ...pending);
90
+ throw error;
91
+ }
92
+ if (!stats.isSymbolicLink()) {
93
+ resolved = next;
94
+ continue;
95
+ }
96
+ if (++links > MAX_LINKS)
97
+ throw Object.assign(new Error(`ELOOP: too many symbolic links, '${path}'`), { code: "ELOOP" });
98
+ const target = await readlink(next);
99
+ if (target.startsWith("/"))
100
+ resolved = "/";
101
+ pending.unshift(...target.split("/").filter((part) => part !== ""));
102
+ }
103
+ return resolved;
104
+ }
105
+ /** `canonicalize` for a directory that may not exist yet, at construction. */
106
+ export function canonicalizeSync(path) {
107
+ let existing = path;
108
+ const tail = [];
109
+ while (!existsSync(existing)) {
110
+ const up = dirname(existing);
111
+ if (up === existing)
112
+ break;
113
+ tail.unshift(basename(existing));
114
+ existing = up;
115
+ }
116
+ return join(realpathSync(existing), ...tail);
117
+ }
118
+ export const inside = (path, root) => path === root || path.startsWith(root.endsWith("/") ? root : `${root}/`);
119
+ const FILE_INFO_KEYS = ["name", "path", "kind", "size", "mtimeMs"];
120
+ const isFileInfo = (value) => typeof value === "object" && value !== null && FILE_INFO_KEYS.every((key) => key in value) && typeof value.path === "string";
121
+ /**
122
+ * A resolved target held open. `dir` and `entry` are `/proc/self/fd/...` paths to hand to pi's own implementation; every
123
+ * path and message it returns is mapped back to the real one with `restore`. Close it when the operation is done.
124
+ */
125
+ export class Pin {
126
+ canonical;
127
+ /** The last component, or "" when the target is the root itself. */
128
+ name;
129
+ /** The held directory: the target itself for a directory pin, the directory holding the target for an entry pin. */
130
+ dir;
131
+ /** The target below `dir`, for operations that do not follow a last symlink. */
132
+ entry;
133
+ #handle;
134
+ /** What `dir` stands for: the directory itself, or the directory holding the entry. */
135
+ #real;
136
+ #closed = false;
137
+ constructor(handle, canonical, name, dirMode) {
138
+ this.#handle = handle;
139
+ this.canonical = canonical;
140
+ this.name = name;
141
+ this.dir = `/proc/self/fd/${handle.fd}`;
142
+ this.entry = dirMode ? this.dir : name === "" ? `${this.dir}/.` : `${this.dir}/${name}`;
143
+ this.#real = dirMode || name === "" ? canonical : dirname(canonical);
144
+ }
145
+ /** `text` with the held paths replaced by the real ones. */
146
+ translate(text) {
147
+ return text.split(this.entry).join(this.canonical).split(this.dir).join(this.#real);
148
+ }
149
+ /** An error pi or Node raised on a held path, as a FileError that names the real one. */
150
+ error(error, path) {
151
+ if (error instanceof PathOutsideWorkError)
152
+ return error;
153
+ const mapped = fileError(error, path);
154
+ return new FileError(mapped.code, this.translate(mapped.message), mapped.path === undefined ? path : this.translate(mapped.path), mapped.cause instanceof Error ? mapped.cause : undefined);
155
+ }
156
+ restore(result) {
157
+ if (!result.ok)
158
+ return err(this.error(result.error, result.error.path ?? this.canonical));
159
+ return ok(this.#value(result.value));
160
+ }
161
+ #value(value) {
162
+ if (Array.isArray(value))
163
+ return value.map((each) => this.#value(each));
164
+ if (isFileInfo(value))
165
+ return { ...value, path: this.translate(value.path) };
166
+ // A page of a directory reader.
167
+ if (typeof value === "object" && value !== null && "entries" in value && Array.isArray(value.entries)) {
168
+ return { ...value, entries: this.#value(value.entries) };
169
+ }
170
+ return value;
171
+ }
172
+ /** Opens the target's last component, which is never followed, and returns its handle; the caller closes it. */
173
+ async openFile(flags, mode) {
174
+ try {
175
+ return await open(this.entry, flags | FILE, mode);
176
+ }
177
+ catch (error) {
178
+ if (errno(error) === "ELOOP")
179
+ throw changed(this.canonical);
180
+ throw error;
181
+ }
182
+ }
183
+ binaryReader(reader) {
184
+ return {
185
+ info: async (context) => this.restore(await reader.info(context)),
186
+ read: (offset, length, context) => reader.read(offset, length, context),
187
+ scanLines: (options, context) => reader.scanLines(options, context),
188
+ close: (context) => reader.close(context),
189
+ };
190
+ }
191
+ /** The reader keeps the pin open: pi's reader names each entry through `dir` as it pages. */
192
+ dirReader(reader) {
193
+ return {
194
+ next: async (maxEntries, context) => this.restore(await reader.next(maxEntries, context)),
195
+ close: async (context) => {
196
+ try {
197
+ await reader.close(context);
198
+ }
199
+ finally {
200
+ await this.close();
201
+ }
202
+ },
203
+ };
204
+ }
205
+ async close() {
206
+ if (this.#closed)
207
+ return;
208
+ this.#closed = true;
209
+ await this.#handle.close().catch(() => undefined);
210
+ }
211
+ }
212
+ const changed = (path) => new PathOutsideWorkError(path, "changed", `${path}: a component of the path changed while it was being resolved, so the operation was refused`);
213
+ export class Confinement {
214
+ work;
215
+ readOnly;
216
+ /** Directories handed out for writing after construction (the environment's temp dirs), canonical. */
217
+ #handed = new Set();
218
+ /** Canonical directories: `work` may be read and written, `readOnly` only read. */
219
+ constructor(work, readOnly) {
220
+ this.work = work;
221
+ this.readOnly = readOnly;
222
+ }
223
+ /**
224
+ * Lets file operations write inside `dir` too: a directory this process just created (`createTempDir`) directly in a
225
+ * read-only root, for the tool it is handed to. `dir` is that root joined with the new name, never resolved: if the
226
+ * directory is later swapped for a link, a path through it resolves elsewhere and is refused.
227
+ */
228
+ allowWrites(dir) {
229
+ if (this.readOnly.some((root) => dirname(dir) === root))
230
+ this.#handed.add(dir);
231
+ }
232
+ /** `dir` was removed: writes inside it, or inside any handed directory below it, are no longer allowed. */
233
+ forgetWrites(dir) {
234
+ for (const handed of this.#handed)
235
+ if (inside(handed, dir))
236
+ this.#handed.delete(handed);
237
+ }
238
+ /** Resolves `abs` and refuses it unless it lies under an allowed root; returns the root that holds it. */
239
+ async locate(abs, original, access, follow) {
240
+ const canonical = follow ? await canonicalize(abs) : join(await canonicalize(dirname(abs)), basename(abs));
241
+ const roots = access === "write" ? [this.work, ...this.#handed] : [this.work, ...this.#handed, ...this.readOnly];
242
+ const root = roots.find((candidate) => inside(canonical, candidate));
243
+ if (root === undefined) {
244
+ const where = canonical === abs ? "" : ` (it resolves to ${canonical})`;
245
+ throw new PathOutsideWorkError(original, "outside_work", `${original}: outside the workspace ${this.work}${where}`);
246
+ }
247
+ return { canonical, root };
248
+ }
249
+ /** Pins the directory holding the target (or the root, when it is the target). */
250
+ async pinEntry(abs, original, spec) {
251
+ const { canonical, root } = await this.locate(abs, original, spec.access, spec.follow);
252
+ await confineHooks.afterCheck?.(canonical);
253
+ const names = relative(root, canonical);
254
+ if (names.length === 0 && spec.strict === true) {
255
+ // a handed directory itself (a tool removing the temp dir it was given): pinned as an entry of the read-only root that
256
+ // holds it, so it can go; the workspace root never can
257
+ const holder = this.#handed.has(root) ? this.readOnly.find((candidate) => dirname(root) === candidate) : undefined;
258
+ if (holder === undefined)
259
+ throw new PathOutsideWorkError(original, "outside_work", `${original}: the workspace root itself cannot be removed or renamed`);
260
+ return new Pin(await walk(holder, [], "none", canonical), canonical, basename(root), false);
261
+ }
262
+ const name = names.pop() ?? "";
263
+ const handle = await walk(root, names, spec.parents === true ? "all" : "none", canonical);
264
+ return new Pin(handle, canonical, name, false);
265
+ }
266
+ /** Pins the target directory itself; `create` makes it and the directories above it when they are missing. */
267
+ async pinDir(abs, original, spec) {
268
+ const { canonical, root } = await this.locate(abs, original, spec.access, true);
269
+ await confineHooks.afterCheck?.(canonical);
270
+ const handle = await walk(root, relative(root, canonical), spec.create === true ? "all" : "none", canonical);
271
+ return new Pin(handle, canonical, basename(canonical), true);
272
+ }
273
+ }
274
+ const relative = (root, path) => (path === root ? [] : path.slice(root.length + (root.endsWith("/") ? 0 : 1)).split("/"));
275
+ /** The error with the held path `from` in its message replaced by the real one; the errno is kept. */
276
+ function retarget(error, from, to) {
277
+ if (!(error instanceof Error))
278
+ return error;
279
+ return Object.assign(new Error(error.message.split(from).join(to), { cause: error }), { code: errno(error) });
280
+ }
281
+ /** Whether a failed directory open found a symlink: O_DIRECTORY with O_NOFOLLOW reports it as ENOTDIR, as it does a file. */
282
+ async function swapped(error, path) {
283
+ if (errno(error) === "ELOOP")
284
+ return true;
285
+ if (errno(error) !== "ENOTDIR")
286
+ return false;
287
+ return (await lstat(path).catch(() => undefined))?.isSymbolicLink() === true;
288
+ }
289
+ /** Opens `root`, then each name below it, every step relative to the handle before it and without following links. */
290
+ async function walk(root, names, create, canonical) {
291
+ let dir;
292
+ try {
293
+ dir = await open(root, DIRECTORY);
294
+ }
295
+ catch (error) {
296
+ if (await swapped(error, root))
297
+ throw changed(canonical);
298
+ throw error;
299
+ }
300
+ try {
301
+ // The root was opened by name: it must be the directory that name denotes now, not one swapped in on the way.
302
+ const actual = await readlink(`/proc/self/fd/${dir.fd}`);
303
+ if (actual !== root)
304
+ throw changed(canonical);
305
+ let real = root;
306
+ for (const name of names) {
307
+ if (name === "" || name === "." || name === ".." || name.includes("\0"))
308
+ throw changed(canonical);
309
+ const step = `/proc/self/fd/${dir.fd}/${name}`;
310
+ real = join(real, name);
311
+ let next;
312
+ try {
313
+ next = await open(step, DIRECTORY);
314
+ }
315
+ catch (error) {
316
+ if (create === "all" && errno(error) === "ENOENT") {
317
+ await mkdir(step).catch((made) => {
318
+ if (errno(made) !== "EEXIST")
319
+ throw retarget(made, step, real);
320
+ });
321
+ next = await open(step, DIRECTORY).catch((opened) => {
322
+ throw retarget(opened, step, real);
323
+ });
324
+ }
325
+ else if (await swapped(error, step)) {
326
+ throw changed(canonical);
327
+ }
328
+ else {
329
+ throw retarget(error, step, real);
330
+ }
331
+ }
332
+ await dir.close();
333
+ dir = next;
334
+ }
335
+ return dir;
336
+ }
337
+ catch (error) {
338
+ await dir.close().catch(() => undefined);
339
+ throw error;
340
+ }
341
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,129 @@
1
+ import type { Context } from "@earendil-works/chord";
2
+ import type { Extension, ToolHooks, ToolRegistration } from "@earendil-works/pi-durable";
3
+ import { ExecutionError } from "@earendil-works/pi-durable/env";
4
+ import { NodeExecutionEnv, type NodeWatchOptions } from "@earendil-works/pi-durable/env/node";
5
+ import { PdaError } from "./errors.js";
6
+ type ToolCall = Parameters<ToolHooks["afterTool"]>[0];
7
+ /** What the environment needs of a claim; the claim satisfies it structurally. */
8
+ export interface ArchilEnvClaim {
9
+ /** Absolute mount path of the run directory; the same on every host that claims the run. */
10
+ readonly root: string;
11
+ /** Absolute path of the workspace the tools write: `<root>/work`. */
12
+ readonly work: string;
13
+ /** The disk's id; part of the environment id when present. */
14
+ readonly disk?: string;
15
+ /**
16
+ * Where the commands this environment starts are recorded, so that the next open of the claim ends what a killed
17
+ * incarnation left running (`reapCommands`). Set by a claim that nothing else cleans up after: a local run's.
18
+ */
19
+ readonly commands?: string;
20
+ }
21
+ export interface ArchilEnvOptions {
22
+ /** How `watch` runs. Polling is the default because a FUSE mount does not report changes made elsewhere. */
23
+ readonly watch?: NodeWatchOptions;
24
+ readonly shellPath?: string;
25
+ readonly shellEnv?: NodeJS.ProcessEnv;
26
+ /**
27
+ * Run every command with no_new_privs (`setpriv --no-new-privs --`): setuid programs and file capabilities stop
28
+ * granting privilege, so a command cannot `sudo` even when the run user has a sudo rule (the host needs one for the
29
+ * archil verbs). Default true. Set false on a host that already confines commands, or where util-linux is absent.
30
+ */
31
+ readonly noNewPrivs?: boolean;
32
+ /** The util-linux `setpriv` that `noNewPrivs` and `runAs` run; default `/usr/bin/setpriv`. */
33
+ readonly setprivPath?: string;
34
+ /**
35
+ * Confine every file operation to the workspace (`PathOutsideWorkError` otherwise): read, write, edit and every other
36
+ * path pi's file system takes. Default true. Set false only where the paths are confined some other way, or where
37
+ * `/proc/self/fd` is unavailable (the confinement walks paths through it).
38
+ */
39
+ readonly confineFiles?: boolean;
40
+ /**
41
+ * Directories file operations may read but never write, besides the workspace and the claim's `tmp/` (where a bash
42
+ * command's spilled output lives and which the bash tool names to the model; a directory `createTempDir` returned is
43
+ * writable too, for the tool it was handed to). For an app's own skills or reference material.
44
+ */
45
+ readonly readRoots?: readonly string[];
46
+ /**
47
+ * Run every command as this user and group with no capabilities (`setpriv --reuid --regid --clear-groups
48
+ * --inh-caps=-all --bounding-set=-all`), and give it `work/` and every file and directory pi's in-process file tools
49
+ * create under `work/`, so a command can change what the agent wrote.
50
+ * For an instance that runs as root, as in a container: the run's root, store, `run.json` and owner lock stay root's
51
+ * (the mount enforces their modes), and the archil daemon's environment, which holds the mount token, is unreadable to
52
+ * the commands. Needs root (to switch users and to chown).
53
+ */
54
+ readonly runAs?: {
55
+ readonly uid: number;
56
+ readonly gid: number;
57
+ };
58
+ }
59
+ export type EnvErrorCode = "SETPRIV_UNAVAILABLE" | "PROC_UNAVAILABLE" | "INVALID_RUN_AS";
60
+ /**
61
+ * What `exec` returns once the factory's `cleanup()` ran: no command was started. Its `code` is pi's `spawn_error`
62
+ * (the command could not start), `reason` tells it apart from a program that failed to spawn.
63
+ */
64
+ export declare class EnvClosedError extends ExecutionError {
65
+ readonly reason = "env_closed";
66
+ constructor();
67
+ }
68
+ /** The environment cannot be built as asked. Exit 1. */
69
+ export declare class EnvError extends PdaError {
70
+ constructor(code: EnvErrorCode, message: string, options?: {
71
+ cause?: unknown;
72
+ });
73
+ }
74
+ /**
75
+ * A `HarnessOptions.env` that builds a fresh environment per use, plus the identity and shutdown of the run's
76
+ * environments. The environment id is `archil:<disk>:<root>`, so file-mutation serialization keys on the run and a
77
+ * resumed run on another host has the same identity. Temporary files and directories (a bash command's spilled
78
+ * `pi-output-*.log` among them) live under `<root>/tmp/`, inside the claim and outside `work/`, so a path a result
79
+ * names still exists after a takeover on another host. Commands run under no_new_privs unless `noNewPrivs` is false, and
80
+ * every file operation is confined to the workspace (reads also to `tmp/` and `readRoots`, writes also inside a directory
81
+ * `createTempDir` returned) unless `confineFiles` is false.
82
+ */
83
+ export interface ArchilEnvFactory {
84
+ /** Rooted at the claim's `work/`; an agent `cwd` is resolved against it. */
85
+ (target: {
86
+ readonly cwd?: string;
87
+ }): NodeExecutionEnv;
88
+ readonly id: string;
89
+ /**
90
+ * Closes the factory and kills every command still running in an environment it built. pi starts each command as
91
+ * the leader of its own process group, so killing the instance's group does not reach it: a fenced or released
92
+ * instance calls this before it exits. From the first moment of the call, `exec` on the factory's environments, those
93
+ * built before and after it, returns `EnvClosedError` without spawning, so nothing starts between the kill and the
94
+ * exit. A command that had passed that check but not yet spawned is killed at spawn, and ends `aborted` like the
95
+ * commands that were running; calling it again does nothing.
96
+ */
97
+ cleanup(context: Context): Promise<void>;
98
+ }
99
+ export declare function archilEnv(claim: ArchilEnvClaim, options?: ArchilEnvOptions): ArchilEnvFactory;
100
+ /** Built-in tools that only read. A tool in this set is declared `replay: "safe"`. */
101
+ export declare const READ_ONLY_TOOLS: ReadonlySet<string>;
102
+ /** Built-in tools that can change the workspace; their results follow the workspace barrier. */
103
+ export declare const WRITING_TOOLS: ReadonlySet<string>;
104
+ /** The tool declared `replay: "safe"`: an interrupted call reruns on recovery instead of reporting `interrupted`. */
105
+ export declare function replaySafe<T extends ToolRegistration>(tool: T): T;
106
+ /** `tools` with every read-only one declared replay-safe; the others, and the input array, are untouched. */
107
+ export declare function markReadOnlySafe<T extends ToolRegistration>(tools: readonly T[], readOnly?: ReadonlySet<string>): T[];
108
+ /**
109
+ * pi's `CodingTools` with `read` declared replay-safe. It keeps pi's extension name, so installing it replaces
110
+ * `CodingTools` in place. `write`, `edit` and `bash` stay as pi ships them (unsafe). pi 1.1.0 ships no ls, find or
111
+ * grep; an app that adds them declares them safe with `markReadOnlySafe`.
112
+ */
113
+ export declare const ArchilCodingTools: Extension;
114
+ export interface WorkspaceBarrierOptions {
115
+ /** Tools whose result follows a barrier; default `WRITING_TOOLS`. An app adds its own writing tools here. */
116
+ readonly tools?: Iterable<string>;
117
+ }
118
+ /**
119
+ * The hook point for the rule that the workspace changes a tool made are durable before its result is committed.
120
+ * Install this extension only when the result commit's own fsync does not already satisfy the rule; with it
121
+ * not installed nothing runs. `barrier` is the claim's (`claim.barrier()`), called after a writing tool ran and before
122
+ * pi appends its result entry, for the calls whose tool name is in `options.tools`.
123
+ *
124
+ * pi reports and ignores an `afterTool` hook that throws, so a failed barrier cannot hold the result back by throwing.
125
+ * It replaces the result with an error result carrying the diagnostic `workspace_not_durable`: a success never commits
126
+ * for a workspace that is not durable. The host decides separately whether a failed barrier means the claim is fenced.
127
+ */
128
+ export declare function workspaceBarrier(barrier: (call: ToolCall, context: Context) => Promise<void>, options?: WorkspaceBarrierOptions): Extension;
129
+ export {};