@better-fs-tools/pi-durable 0.0.0-tegami-trusted-publish-setup → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gareth Andrew
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,98 @@
1
- # Placeholder package
2
-
3
- This empty package was published by [Tegami](https://tegami.fuma-nama.dev) to configure npm trusted publishing.
4
-
5
- The real package contents will be published via CI with OIDC.
6
-
1
+ # @better-fs-tools/pi-durable
2
+
3
+ Better FS Tools for [`@earendil-works/pi-durable`](https://www.npmjs.com/package/@earendil-works/pi-durable), the experimental durable harness of Pi. It replaces the `read`, `edit`, and `write` of `CodingTools`, and can add `apply_patch` and `bash`. Each call reads and writes through the call's execution environment, so the tools work on the local machine, over SSH with `@earendil-works/pi-env`, and in a container.
4
+
5
+ `@earendil-works/pi-durable` is experimental. Its API can change in each release. This package follows pi-durable 1.2.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @better-fs-tools/pi-durable @earendil-works/pi-durable @earendil-works/chord typebox
11
+ ```
12
+
13
+ `@earendil-works/pi-durable` (`~1.2.0`), `@earendil-works/chord` (`~1.2.0`), and `typebox` (`^1.3.16`) are required peers. The package imports no `node:` module.
14
+
15
+ ## Example
16
+
17
+ Install the extension after `CodingTools`. When two extensions have a tool with the same name, the one installed later wins. In the first registry, `read`, `edit`, and `write` come from Better FS Tools, and `bash` stays from `CodingTools`. The second registry has the recommended configuration:
18
+
19
+ ```ts
20
+ import { createRegistry } from "@earendil-works/pi-durable";
21
+ import { CodingTools } from "@earendil-works/pi-durable/tools";
22
+ import {
23
+ createDurableFsTools,
24
+ documentReadStore,
25
+ durableEnvFileSystem,
26
+ durableEnvRunner,
27
+ } from "@better-fs-tools/pi-durable";
28
+ import { shellEnv } from "@better-fs-tools/shell";
29
+
30
+ // Installed after CodingTools, so its read, edit, and write win. The bash of
31
+ // CodingTools stays.
32
+ export function fsToolsRegistry() {
33
+ const registry = createRegistry();
34
+ registry.install(CodingTools);
35
+ registry.install(
36
+ createDurableFsTools({
37
+ fs: durableEnvFileSystem(), // each call reads and writes through its ExecutionEnv
38
+ }),
39
+ );
40
+ return registry;
41
+ }
42
+
43
+ // The recommended configuration: read records in the conversation's
44
+ // documents, so they stay after a restart and follow a fork, and bash over the
45
+ // environment's exec() in place of the bash of CodingTools.
46
+ export function recommendedRegistry() {
47
+ const registry = createRegistry();
48
+ registry.install(CodingTools);
49
+ registry.install(
50
+ createDurableFsTools({
51
+ fs: durableEnvFileSystem({ allowedRoots: ["."] }),
52
+ state: documentReadStore(),
53
+ bash: { runner: durableEnvRunner(), env: shellEnv() },
54
+ }),
55
+ );
56
+ return registry;
57
+ }
58
+ ```
59
+
60
+ Give the registry to `Harness.open()` with an `env` option, as for `CodingTools`.
61
+
62
+ ## What the extension does
63
+
64
+ - `createDurableFsTools(options)` takes the options of `createFsTools()`, with the same defaults, plus a `signature` in each tool's options and the Harness settings `name` and `outputLimits`. `fs` is required. bash needs a `runner` and an `env`, and `bash: true` throws. `docs/hosts.md` in `@better-fs-tools/write` has the full table.
65
+ - The host of each call is `{ api, env, context }`: the tool API, the `ExecutionEnv` of the call, and its Chord context. Host functions, such as an authorizer, get it as `ctx.call.host`. A factory `fs`, `state`, or `runner` gets the call. A call without an environment fails with an error result.
66
+ - `read` is `replay: "safe"`, so the Harness runs it again after a crash. `edit`, `write`, and `apply_patch` keep the default `"unsafe"`: after a crash the model gets an interrupted result and reads the file again.
67
+ - `outputLimits` defaults to 128 KiB and 4,000 lines, above the 2,000-line and 50 KiB view of `read`, so the Harness does not cut the output a second time. `name` defaults to `"better-fs-tools"`.
68
+ - A signature with a `grammar` sets `constrainedSampling`, as in the Pi integrations. For the Codex patch grammar, pass `applyPatch: { signature: freeformPatchSignature() }`. `edit: { signature: multiEditSignature() }` gives the `edits: [{ oldText, newText }]` input of Durable's own `edit`.
69
+ - A tool error is a result with `isError: true`. A nonzero bash exit, a timeout, and an error are error results too. No tool declares a `structuredOutputSchema`, so programs get the text.
70
+ - `locks` defaults to one `memoryLocks()` for every call of the extension. The keys get the environment id, so the same path in two environments does not wait. The tools do not share the queue of the `CodingTools` `edit` and `write`: pi-durable does not export it.
71
+
72
+ ## Recommended configuration
73
+
74
+ These choices suit the durable harness. They are options, not defaults:
75
+
76
+ - `fs: durableEnvFileSystem()`: each call reads and writes through the call's `ExecutionEnv`, so the tools work on the local machine, over SSH with pi-env, and in a container. `allowedRoots` is relative to the environment's `cwd`, and defaults to `["."]`. It also takes `denyRoots` and `symlinks`.
77
+ - `state: documentReadStore()`: each read record is a `ReadStateDoc` document of the conversation. The records stay after a restart, so an `edit` in the next process needs no new read. A fork starts with the records its parent had at the fork entry. A `ReadStateStore`, such as `memoryStore()`, is shared by every conversation in place.
78
+ - `bash: { runner: durableEnvRunner(), env: shellEnv() }`: commands run through the environment's `exec()`. A `cwd` from the model is relative to the environment's `cwd`. By default a command keeps the environment's own variables, with `defaultShellEnv` over them: `durableEnvRunner({ inheritEnv: false })` makes `env` the whole environment.
79
+
80
+ ## The parts
81
+
82
+ | Export | What it is |
83
+ | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
84
+ | `createDurableFsTools(options)` | The extension |
85
+ | `durableEnvFileSystem(options?)` | A per-call `fs`: `executionEnvFileSystem()` over the call's environment |
86
+ | `durableEnvRunner(options?)` | A per-call bash `runner`: `executionEnvRunner()` over the call's environment |
87
+ | `documentReadStore()` | A per-call `state` in the conversation's `ReadStateDoc` documents |
88
+ | `executionEnvFileSystem(env, { context, allowedRoots })` | A `WritableFileSystem` over any `ExecutionEnv`, for any tool or bundle |
89
+ | `executionEnvRunner(env, { context })` | A bash `CommandRunner` over `env.exec()` |
90
+ | `ReadStateDoc` | The document family of the read records |
91
+
92
+ `executionEnvFileSystem()` passes the filesystem and write conformance suites of `@better-fs-tools/fs`. Paths are POSIX paths in the environment. A path is checked against the roots as written, and again after its symbolic links resolve. A write through a dangling symbolic link is refused. Reads stream through `openBinaryReader()`. Writes use the environment's `writeFile()`, which replaces the content in place: the file keeps its mode, but the write is not atomic. Versions are weak: size and modification time.
93
+
94
+ ## Links
95
+
96
+ - [`@better-fs-tools/write`](https://www.npmjs.com/package/@better-fs-tools/write): `createFsTools()`, the write options, and the guards
97
+ - [`@better-fs-tools/read`](https://www.npmjs.com/package/@better-fs-tools/read): every read option and the signatures
98
+ - [`@better-fs-tools/pi`](https://www.npmjs.com/package/@better-fs-tools/pi): the tools for the Pi coding agent and for pi-agent-core
@@ -0,0 +1,65 @@
1
+ import type { Extension, ToolRegistration } from "@earendil-works/pi-durable";
2
+ import type { ReadSignature } from "@better-fs-tools/read/signature";
3
+ import type { BashSignature } from "@better-fs-tools/shell/signature";
4
+ import type { FsToolsApplyPatchOptions, FsToolsBashOptions, FsToolsEditOptions, FsToolsOptions, FsToolsReadOptions, FsToolsWriteOptions } from "@better-fs-tools/write";
5
+ import type { EditSignature, PatchSignature, WriteSignature } from "@better-fs-tools/write/signature";
6
+ import type { DurableFsHost } from "./host.ts";
7
+ type Host = DurableFsHost;
8
+ export type DurableFsToolsReadOptions = FsToolsReadOptions<Host> & {
9
+ /** Default defaultReadSignature(). */
10
+ readonly signature?: ReadSignature;
11
+ };
12
+ export type DurableFsToolsEditOptions = FsToolsEditOptions<Host> & {
13
+ /** Default defaultEditSignature({ matchers }). */
14
+ readonly signature?: EditSignature;
15
+ };
16
+ export type DurableFsToolsWriteOptions = FsToolsWriteOptions<Host> & {
17
+ /** Default defaultWriteSignature(). */
18
+ readonly signature?: WriteSignature;
19
+ };
20
+ export type DurableFsToolsApplyPatchOptions = FsToolsApplyPatchOptions<Host> & {
21
+ /** Default defaultPatchSignature(). */
22
+ readonly signature?: PatchSignature;
23
+ };
24
+ /** bash options. `runner` and `env` are required: durableEnvRunner() runs over the environment's exec(). */
25
+ export type DurableFsToolsBashOptions = FsToolsBashOptions<Host> & {
26
+ /** Default defaultBashSignature() for the runner's id and the limits. */
27
+ readonly signature?: BashSignature;
28
+ };
29
+ /**
30
+ * The options of createFsTools(), with the call's `{ api, env, context }` as
31
+ * host and a `signature` in each tool's options, plus the Harness settings
32
+ * `name` and `outputLimits`. `fs` is required: durableEnvFileSystem() reads
33
+ * and writes through the call's environment.
34
+ */
35
+ export interface DurableFsToolsOptions extends Omit<FsToolsOptions<Host>, "read" | "edit" | "write" | "applyPatch" | "bash"> {
36
+ /** The extension name. Default "better-fs-tools". */
37
+ readonly name?: string;
38
+ /** The Harness output limits of read, edit, write, and apply_patch. Default 128 KiB and 4,000 lines. */
39
+ readonly outputLimits?: ToolRegistration["outputLimits"];
40
+ readonly read?: DurableFsToolsReadOptions;
41
+ readonly edit?: DurableFsToolsEditOptions;
42
+ readonly write?: DurableFsToolsWriteOptions;
43
+ /** Off by default. `true` or an options object adds apply_patch. */
44
+ readonly applyPatch?: boolean | DurableFsToolsApplyPatchOptions;
45
+ /**
46
+ * Off by default, so the bash of CodingTools stays. An options object with a
47
+ * runner and an env adds a bash tool.
48
+ */
49
+ readonly bash?: false | DurableFsToolsBashOptions;
50
+ }
51
+ /**
52
+ * A pi-durable extension with Better FS Tools read, edit, and write, and
53
+ * optionally apply_patch and bash. Install it after CodingTools: a later
54
+ * extension's tool with the same name replaces the earlier one. Every tool
55
+ * option and default is the one of createFsTools(). The lock keys get the
56
+ * environment id, so the same path in two environments does not wait.
57
+ *
58
+ * read is replay-safe. edit, write, and apply_patch are not: after a crash the
59
+ * model gets an interrupted result and reads the file again.
60
+ *
61
+ * Throws TypeError where createFsTools() does, with createDurableFsTools in the
62
+ * message. A call without an environment fails with an error result.
63
+ */
64
+ export declare function createDurableFsTools(options: DurableFsToolsOptions): Extension;
65
+ export {};
@@ -0,0 +1,146 @@
1
+ import { defineExtension, defineTool } from "@earendil-works/pi-durable";
2
+ import { Type } from "typebox";
3
+ import { fsToolDefinitions } from "@better-fs-tools/write/signature";
4
+ const LABEL = "createDurableFsTools";
5
+ /** Above the default view of 2,000 lines and 50 KiB, so the Harness does not cut it again. */
6
+ const DEFAULT_OUTPUT_LIMITS = Object.freeze({ maxBytes: 128 * 1024, maxLines: 4_000 });
7
+ /**
8
+ * A pi-durable extension with Better FS Tools read, edit, and write, and
9
+ * optionally apply_patch and bash. Install it after CodingTools: a later
10
+ * extension's tool with the same name replaces the earlier one. Every tool
11
+ * option and default is the one of createFsTools(). The lock keys get the
12
+ * environment id, so the same path in two environments does not wait.
13
+ *
14
+ * read is replay-safe. edit, write, and apply_patch are not: after a crash the
15
+ * model gets an interrupted result and reads the file again.
16
+ *
17
+ * Throws TypeError where createFsTools() does, with createDurableFsTools in the
18
+ * message. A call without an environment fails with an error result.
19
+ */
20
+ export function createDurableFsTools(options) {
21
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
22
+ throw new TypeError(`${LABEL} options must be an object`);
23
+ }
24
+ const { name = "better-fs-tools", outputLimits = DEFAULT_OUTPUT_LIMITS, ...rest } = options;
25
+ // Built once to check the options and to declare the tools. Each call builds its own.
26
+ const declared = fsToolDefinitions(rest, LABEL);
27
+ const perCall = perCallOptions(rest, declared);
28
+ /** The tool definitions and the call of one tool call, over the call's environment. */
29
+ const definitionsFor = (api, context) => {
30
+ const env = api.env;
31
+ if (env === undefined)
32
+ throw new Error("Better FS Tools needs an execution environment");
33
+ const definitions = fsToolDefinitions({ ...perCall, locks: keyedLocks(declared.locks, env.id) }, LABEL);
34
+ const call = {
35
+ ...(context.abortSignal === undefined ? {} : { signal: context.abortSignal }),
36
+ callId: api.callId,
37
+ host: { api, env, context },
38
+ };
39
+ return { definitions, call };
40
+ };
41
+ const tools = [
42
+ defineTool({
43
+ ...declaration(declared.read),
44
+ replay: "safe",
45
+ outputLimits,
46
+ async execute(args, api, context) {
47
+ const { definitions, call } = definitionsFor(api, context);
48
+ const result = await definitions.read.execute(args, call);
49
+ return { output: toOutput(result.content), isError: result.status === "error" };
50
+ },
51
+ }),
52
+ mutationTool(declared.edit, outputLimits, (d) => d.edit, definitionsFor),
53
+ mutationTool(declared.write, outputLimits, (d) => d.write, definitionsFor),
54
+ ];
55
+ if (declared.applyPatch !== null) {
56
+ tools.push(mutationTool(declared.applyPatch, outputLimits, (d) => d.applyPatch, definitionsFor));
57
+ }
58
+ if (declared.bash !== null) {
59
+ tools.push(defineTool({
60
+ ...declaration(declared.bash),
61
+ outputLimits: { ...outputLimits, retain: "tail" },
62
+ async execute(args, api, context) {
63
+ const { definitions, call } = definitionsFor(api, context);
64
+ if (definitions.bash === null)
65
+ throw new Error("bash is off");
66
+ return bashResult(await definitions.bash.execute(args, call));
67
+ },
68
+ }));
69
+ }
70
+ return defineExtension({ name, tools });
71
+ }
72
+ /** The options of each call: the given options, with the signatures, digest, and clock of the declared tools. */
73
+ function perCallOptions(options, declared) {
74
+ const applyPatch = options.applyPatch === true ? {} : options.applyPatch;
75
+ return {
76
+ ...options,
77
+ digest: declared.digest,
78
+ clock: declared.clock,
79
+ read: { ...options.read, signature: declared.read.signature },
80
+ edit: { ...options.edit, signature: declared.edit.signature },
81
+ write: { ...options.write, signature: declared.write.signature },
82
+ ...(declared.applyPatch === null || applyPatch === false || applyPatch === undefined
83
+ ? {}
84
+ : { applyPatch: { ...applyPatch, signature: declared.applyPatch.signature } }),
85
+ ...(declared.bash === null || options.bash === false || options.bash === undefined
86
+ ? {}
87
+ : { bash: { ...options.bash, signature: declared.bash.signature } }),
88
+ };
89
+ }
90
+ /** A signature with a grammar sets constrainedSampling, as in the Pi integrations. */
91
+ function declaration(definition) {
92
+ return {
93
+ name: definition.name,
94
+ description: definition.description,
95
+ parameters: Type.Unsafe(definition.inputSchema),
96
+ ...(definition.grammar === undefined
97
+ ? {}
98
+ : {
99
+ constrainedSampling: {
100
+ type: "grammar",
101
+ variants: { openai_lark: definition.grammar.lark },
102
+ },
103
+ }),
104
+ };
105
+ }
106
+ function mutationTool(declared, outputLimits, pick, definitionsFor) {
107
+ return defineTool({
108
+ ...declaration(declared),
109
+ ...(outputLimits === undefined ? {} : { outputLimits }),
110
+ async execute(args, api, context) {
111
+ const { definitions, call } = definitionsFor(api, context);
112
+ const definition = pick(definitions);
113
+ if (definition === null)
114
+ throw new Error(`${declared.name} is off`);
115
+ const result = await definition.execute(args, call);
116
+ return { output: toOutput(result.content), isError: result.status === "error" };
117
+ },
118
+ });
119
+ }
120
+ /** A nonzero exit, a timeout, an abort, and an error are error results, as in the Pi integration. */
121
+ function bashResult(result) {
122
+ return { output: toOutput(result.content), isError: result.status !== "ok" };
123
+ }
124
+ /** Lock keys with the environment id: one path on two machines is two files. */
125
+ function keyedLocks(locks, envId) {
126
+ return Object.freeze({
127
+ id: locks.id,
128
+ acquire: (keys, options) => locks.acquire(keys.map((key) => `${envId}\0${key}`), options),
129
+ });
130
+ }
131
+ // Keeps each String.fromCharCode spread under engine argument-count limits.
132
+ const CHUNK = 0x8000;
133
+ function toOutput(parts) {
134
+ return parts.map((part) => {
135
+ if (part.type === "text")
136
+ return { type: "text", text: part.text };
137
+ if (!part.mediaType.startsWith("image/")) {
138
+ return { type: "text", text: `[media omitted] ${part.mediaType}` };
139
+ }
140
+ let binary = "";
141
+ for (let offset = 0; offset < part.data.byteLength; offset += CHUNK) {
142
+ binary += String.fromCharCode(...part.data.subarray(offset, offset + CHUNK));
143
+ }
144
+ return { type: "image", data: btoa(binary), mimeType: part.mediaType };
145
+ });
146
+ }
@@ -0,0 +1,38 @@
1
+ import type { Context } from "@earendil-works/chord/context";
2
+ import type { ExecutionEnv, FileInfo as EnvFileInfo } from "@earendil-works/pi-durable/env";
3
+ import type { FileSystemRootOptions, FileSystemRootSettings, ListOptions, ListOutcome, MutateOptions, MutationOutcome, WritableFileSystem } from "@better-fs-tools/fs";
4
+ import type { EnvSymlinkPolicy } from "./policy.ts";
5
+ /**
6
+ * The root options, with the environment's paths. `cwd` defaults to `env.cwd`,
7
+ * and relative roots resolve against it. `symlinks` defaults to
8
+ * "follow-within-roots": a link is followed, and its real path must stay inside
9
+ * the real allowed roots. `identity` can only be "none". `id` defaults to
10
+ * `pi-durable:<env.id>`.
11
+ */
12
+ export interface ExecutionEnvFileSystemOptions extends FileSystemRootOptions<EnvSymlinkPolicy, "none"> {
13
+ /**
14
+ * The Chord context of the tool call. Every environment call runs in it, so
15
+ * cancelling the call stops the filesystem too. An operation's own signal
16
+ * also stops it.
17
+ */
18
+ readonly context: Context;
19
+ }
20
+ /** A writable filesystem over a pi-durable ExecutionEnv. No compare-and-swap, no stage(). */
21
+ export interface ExecutionEnvFileSystem extends WritableFileSystem, FileSystemRootSettings<EnvSymlinkPolicy, "none"> {
22
+ list(path: string, options: ListOptions): Promise<ListOutcome>;
23
+ remove(path: string, options: MutateOptions): Promise<MutationOutcome>;
24
+ }
25
+ /**
26
+ * Adapts the files of a pi-durable ExecutionEnv (a NodeExecutionEnv, pi-env's
27
+ * RemoteExecutionEnv over SSH, or a container) to the Better FS Tools
28
+ * filesystem contract. Paths are POSIX paths in the environment.
29
+ *
30
+ * Paths outside the allowed roots or inside a deny root are refused, both as
31
+ * written and after symbolic links resolve. Reads stream through
32
+ * openBinaryReader() without following a final symbolic link. Writes use the
33
+ * environment's writeFile(), which replaces the content in place: a write keeps
34
+ * the file's mode and hard links, but it is not atomic. Versions are weak: size
35
+ * and modification time.
36
+ */
37
+ export declare function executionEnvFileSystem(env: ExecutionEnv, options: ExecutionEnvFileSystemOptions): ExecutionEnvFileSystem;
38
+ export declare function fileInfo(env: ExecutionEnv, target: string, ctx: Context): Promise<EnvFileInfo>;
@@ -0,0 +1,209 @@
1
+ import { posixPaths } from "@better-fs-tools/fs";
2
+ import { contextFor, fromFileError, locate, notAFile, realRoots, refuse, resolveRoots, toFileSystemError, } from "./policy.js";
3
+ import { envVersion, envWrites } from "./write.js";
4
+ /** Bytes that one positional read of an opened file asks for. */
5
+ const CHUNK_BYTES = 64 * 1024;
6
+ /**
7
+ * Adapts the files of a pi-durable ExecutionEnv (a NodeExecutionEnv, pi-env's
8
+ * RemoteExecutionEnv over SSH, or a container) to the Better FS Tools
9
+ * filesystem contract. Paths are POSIX paths in the environment.
10
+ *
11
+ * Paths outside the allowed roots or inside a deny root are refused, both as
12
+ * written and after symbolic links resolve. Reads stream through
13
+ * openBinaryReader() without following a final symbolic link. Writes use the
14
+ * environment's writeFile(), which replaces the content in place: a write keeps
15
+ * the file's mode and hard links, but it is not atomic. Versions are weak: size
16
+ * and modification time.
17
+ */
18
+ export function executionEnvFileSystem(env, options) {
19
+ if (env === null || typeof env !== "object" || typeof env.canonicalPath !== "function") {
20
+ throw new TypeError("executionEnvFileSystem needs a pi-durable ExecutionEnv");
21
+ }
22
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
23
+ throw new TypeError("executionEnvFileSystem options must be an object");
24
+ }
25
+ const { context } = options;
26
+ if (context === null || typeof context !== "object" || typeof context.value !== "function") {
27
+ throw new TypeError("executionEnvFileSystem needs the Chord context of the call");
28
+ }
29
+ const roots = resolveRoots(options, env.cwd);
30
+ const id = options.id ?? `pi-durable:${env.id}`;
31
+ if (typeof id !== "string" || id === "")
32
+ throw new TypeError("id must be a non-empty string");
33
+ // The real roots are found once, on the first call that needs them.
34
+ let real = null;
35
+ const realOnce = (ctx) => {
36
+ if (real === null) {
37
+ real = realRoots(env, roots, ctx);
38
+ real.catch(() => {
39
+ real = null;
40
+ });
41
+ }
42
+ return real;
43
+ };
44
+ const resolve = async (requested, ctx) => locate(env, roots, await realOnce(ctx), requested, ctx);
45
+ return Object.freeze({
46
+ id,
47
+ capabilities: Object.freeze({ identity: false }),
48
+ paths: posixPaths,
49
+ cwd: roots.cwd,
50
+ allowedRoots: roots.allowedRoots,
51
+ denyRoots: roots.denyRoots,
52
+ symlinks: roots.symlinks,
53
+ identity: "none",
54
+ ...envWrites(env, (signal) => contextFor(context, signal), resolve),
55
+ async open(requested, callOptions = {}) {
56
+ if (callOptions.signal?.aborted)
57
+ return { ok: false, error: { reason: "aborted" } };
58
+ const ctx = contextFor(context, callOptions.signal);
59
+ try {
60
+ const located = await resolve(requested, ctx);
61
+ if (!located.exists)
62
+ throw refuse({ reason: "not-found" });
63
+ const entry = await fileInfo(env, located.target, ctx);
64
+ if (entry.kind !== "file") {
65
+ throw refuse(notAFile(entry.kind === "directory" ? "directory" : "other", located));
66
+ }
67
+ // The target is a real path, so a symbolic link here was swapped in after locate().
68
+ const opened = await env.openBinaryReader(located.target, { noFollow: true }, ctx);
69
+ if (!opened.ok) {
70
+ if (opened.error.code === "invalid") {
71
+ throw refuse({ reason: "denied", detail: "the file changed into a symbolic link" });
72
+ }
73
+ throw refuse(fromFileError(opened.error, "openBinaryReader"));
74
+ }
75
+ const reader = opened.value;
76
+ const info = await reader.info(ctx);
77
+ if (!info.ok) {
78
+ await reader.close(ctx);
79
+ throw refuse(fromFileError(info.error, "info"));
80
+ }
81
+ return { ok: true, file: envOpenFile(env, ctx, located, info.value, reader) };
82
+ }
83
+ catch (error) {
84
+ return { ok: false, error: toFileSystemError(error) };
85
+ }
86
+ },
87
+ async list(requested, listOptions) {
88
+ if (listOptions.signal?.aborted)
89
+ return { ok: false, error: { reason: "aborted" } };
90
+ const limit = listOptions.limit;
91
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
92
+ return {
93
+ ok: false,
94
+ error: { reason: "io", detail: "list limit must be a positive safe integer" },
95
+ };
96
+ }
97
+ const ctx = contextFor(context, listOptions.signal);
98
+ try {
99
+ const located = await resolve(requested, ctx);
100
+ if (!located.exists)
101
+ throw refuse({ reason: "not-found" });
102
+ const opened = await env.openDirReader(located.target, ctx);
103
+ if (!opened.ok)
104
+ throw refuse(fromFileError(opened.error, "openDirReader"));
105
+ const reader = opened.value;
106
+ const entries = [];
107
+ let done = false;
108
+ try {
109
+ // One entry past the limit tells whether the listing was cut.
110
+ while (!done && entries.length <= limit) {
111
+ const page = await reader.next(limit + 1 - entries.length, ctx);
112
+ if (!page.ok)
113
+ throw refuse(fromFileError(page.error, "openDirReader"));
114
+ done = page.value.done;
115
+ for (const entry of page.value.entries) {
116
+ entries.push({
117
+ name: entry.name,
118
+ type: entry.kind === "symlink" ? "other" : entry.kind,
119
+ });
120
+ }
121
+ }
122
+ }
123
+ finally {
124
+ await reader.close(ctx);
125
+ }
126
+ return { ok: true, entries: entries.slice(0, limit), truncated: entries.length > limit };
127
+ }
128
+ catch (error) {
129
+ return { ok: false, error: toFileSystemError(error) };
130
+ }
131
+ },
132
+ });
133
+ }
134
+ export async function fileInfo(env, target, ctx) {
135
+ const result = await env.fileInfo(target, ctx);
136
+ if (!result.ok)
137
+ throw refuse(fromFileError(result.error, "fileInfo"));
138
+ return result.value;
139
+ }
140
+ function envOpenFile(env, ctx, located, info, reader) {
141
+ let consumed = false;
142
+ let closed = false;
143
+ return {
144
+ info: {
145
+ resolvedPath: located.target,
146
+ displayPath: located.displayPath,
147
+ size: info.size,
148
+ mtimeMs: info.mtimeMs,
149
+ identity: null,
150
+ mimeType: null,
151
+ version: envVersion(info),
152
+ },
153
+ bytes() {
154
+ if (consumed)
155
+ throw new TypeError("execution environment byte source is single-use");
156
+ consumed = true;
157
+ let offset = 0;
158
+ let ended = closed;
159
+ return {
160
+ [Symbol.asyncIterator]() {
161
+ return {
162
+ async next() {
163
+ if (ended || closed)
164
+ return { done: true, value: undefined };
165
+ const chunk = await reader.read(offset, CHUNK_BYTES, ctx);
166
+ if (!chunk.ok) {
167
+ ended = true;
168
+ throw refuse(fromFileError(chunk.error, "read"));
169
+ }
170
+ offset += chunk.value.byteLength;
171
+ if (chunk.value.byteLength < CHUNK_BYTES)
172
+ ended = true;
173
+ if (chunk.value.byteLength === 0)
174
+ return { done: true, value: undefined };
175
+ return { done: false, value: chunk.value };
176
+ },
177
+ async return() {
178
+ ended = true;
179
+ return { done: true, value: undefined };
180
+ },
181
+ };
182
+ },
183
+ };
184
+ },
185
+ async verify() {
186
+ try {
187
+ const current = await env.fileInfo(located.target, ctx);
188
+ if (!current.ok) {
189
+ if (current.error.code === "not_found")
190
+ return { ok: true, changed: true };
191
+ return { ok: false, error: fromFileError(current.error, "fileInfo") };
192
+ }
193
+ const now = current.value;
194
+ const changed = now.kind !== info.kind || now.size !== info.size || now.mtimeMs !== info.mtimeMs;
195
+ return { ok: true, changed };
196
+ }
197
+ catch (error) {
198
+ return { ok: false, error: toFileSystemError(error) };
199
+ }
200
+ },
201
+ async close() {
202
+ if (closed)
203
+ return;
204
+ closed = true;
205
+ consumed = true;
206
+ await reader.close(ctx);
207
+ },
208
+ };
209
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { Context } from "@earendil-works/chord/context";
2
+ import type { ToolExecutionApi } from "@earendil-works/pi-durable";
3
+ import type { ExecutionEnv } from "@earendil-works/pi-durable/env";
4
+ import type { ToolCallContext } from "@better-fs-tools/read";
5
+ import type { CommandRunner } from "@better-fs-tools/shell";
6
+ import type { ExecutionEnvFileSystem, ExecutionEnvFileSystemOptions } from "./filesystem.ts";
7
+ import type { ExecutionEnvRunnerOptions } from "./runner.ts";
8
+ /** The host of each call. Host functions, such as an authorizer, get it as ctx.call.host. */
9
+ export interface DurableFsHost {
10
+ readonly api: ToolExecutionApi;
11
+ readonly env: ExecutionEnv;
12
+ readonly context: Context;
13
+ }
14
+ /** A factory of the call, as the `fs`, `state`, and `runner` options of a tool take. */
15
+ export type DurableCallFactory<T> = (call: ToolCallContext<DurableFsHost>) => T;
16
+ /**
17
+ * The options of executionEnvFileSystem() without `context`, which comes from
18
+ * the call. `allowedRoots` is optional here.
19
+ */
20
+ export type DurableEnvFileSystemOptions = Omit<ExecutionEnvFileSystemOptions, "context" | "allowedRoots"> & {
21
+ /** Relative to the environment's cwd. Default ["."]: only the working directory of the call. */
22
+ readonly allowedRoots?: readonly string[];
23
+ };
24
+ /** The options of executionEnvRunner() without `context`, which comes from the call. */
25
+ export type DurableEnvRunnerOptions = Omit<ExecutionEnvRunnerOptions, "context">;
26
+ /**
27
+ * A per-call filesystem: executionEnvFileSystem() over the call's environment
28
+ * and Chord context. Pass it as `fs` to createDurableFsTools().
29
+ */
30
+ export declare function durableEnvFileSystem(options?: DurableEnvFileSystemOptions): DurableCallFactory<ExecutionEnvFileSystem>;
31
+ /**
32
+ * A per-call runner: executionEnvRunner() over the call's environment and
33
+ * Chord context. Pass it as the bash `runner` of createDurableFsTools().
34
+ */
35
+ export declare function durableEnvRunner(options?: DurableEnvRunnerOptions): DurableCallFactory<CommandRunner>;
package/dist/host.js ADDED
@@ -0,0 +1,31 @@
1
+ import { executionEnvFileSystem } from "./filesystem.js";
2
+ import { executionEnvRunner } from "./runner.js";
3
+ /**
4
+ * A per-call filesystem: executionEnvFileSystem() over the call's environment
5
+ * and Chord context. Pass it as `fs` to createDurableFsTools().
6
+ */
7
+ export function durableEnvFileSystem(options = {}) {
8
+ checkOptions(options, "durableEnvFileSystem");
9
+ const { allowedRoots = ["."], ...rest } = options;
10
+ return (call) => executionEnvFileSystem(call.host.env, {
11
+ ...rest,
12
+ allowedRoots,
13
+ context: call.host.context,
14
+ });
15
+ }
16
+ /**
17
+ * A per-call runner: executionEnvRunner() over the call's environment and
18
+ * Chord context. Pass it as the bash `runner` of createDurableFsTools().
19
+ */
20
+ export function durableEnvRunner(options = {}) {
21
+ checkOptions(options, "durableEnvRunner");
22
+ return (call) => executionEnvRunner(call.host.env, { ...options, context: call.host.context });
23
+ }
24
+ function checkOptions(options, label) {
25
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
26
+ throw new TypeError(`${label} options must be an object`);
27
+ }
28
+ if (Object.hasOwn(options, "context")) {
29
+ throw new TypeError(`${label} options cannot set context: it comes from the call`);
30
+ }
31
+ }