@warlock.js/ai-workspace 5.28.0 → 5.30.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +6 -0
  3. package/cjs/index.cjs +201 -73
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts +1 -1
  6. package/esm/backends/local.d.mts.map +1 -1
  7. package/esm/backends/local.mjs +38 -8
  8. package/esm/backends/local.mjs.map +1 -1
  9. package/esm/backends/mock.d.mts +1 -1
  10. package/esm/backends/mock.d.mts.map +1 -1
  11. package/esm/backends/mock.mjs +11 -3
  12. package/esm/backends/mock.mjs.map +1 -1
  13. package/esm/contracts/index.d.mts +1 -1
  14. package/esm/contracts/tool-io.type.d.mts +47 -8
  15. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  16. package/esm/contracts/workspace-backend.contract.d.mts +17 -1
  17. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  18. package/esm/contracts/workspace-ops.contract.d.mts +10 -14
  19. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  20. package/esm/contracts/workspace-policy.type.d.mts +9 -2
  21. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  22. package/esm/contracts/workspace.contract.d.mts +13 -14
  23. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  24. package/esm/errors.d.mts +1 -1
  25. package/esm/errors.d.mts.map +1 -1
  26. package/esm/errors.mjs +1 -1
  27. package/esm/errors.mjs.map +1 -1
  28. package/esm/index.d.mts +2 -2
  29. package/esm/ops.d.mts +1 -1
  30. package/esm/ops.d.mts.map +1 -1
  31. package/esm/ops.mjs +121 -28
  32. package/esm/ops.mjs.map +1 -1
  33. package/esm/policy/policy.d.mts +1 -1
  34. package/esm/policy/policy.d.mts.map +1 -1
  35. package/esm/policy/policy.mjs +1 -1
  36. package/esm/policy/policy.mjs.map +1 -1
  37. package/esm/policy/tokenize-command.mjs +1 -1
  38. package/esm/policy/tokenize-command.mjs.map +1 -1
  39. package/esm/tools/edit-file.d.mts +1 -1
  40. package/esm/tools/edit-file.d.mts.map +1 -1
  41. package/esm/tools/edit-file.mjs +1 -1
  42. package/esm/tools/edit-file.mjs.map +1 -1
  43. package/esm/tools/glob.d.mts +1 -1
  44. package/esm/tools/glob.d.mts.map +1 -1
  45. package/esm/tools/glob.mjs +1 -1
  46. package/esm/tools/glob.mjs.map +1 -1
  47. package/esm/tools/grep.d.mts +1 -1
  48. package/esm/tools/grep.d.mts.map +1 -1
  49. package/esm/tools/grep.mjs +1 -1
  50. package/esm/tools/grep.mjs.map +1 -1
  51. package/esm/tools/read-file.d.mts +5 -5
  52. package/esm/tools/read-file.d.mts.map +1 -1
  53. package/esm/tools/read-file.mjs +8 -19
  54. package/esm/tools/read-file.mjs.map +1 -1
  55. package/esm/tools/run-shell.d.mts +5 -1
  56. package/esm/tools/run-shell.d.mts.map +1 -1
  57. package/esm/tools/run-shell.mjs +9 -2
  58. package/esm/tools/run-shell.mjs.map +1 -1
  59. package/esm/tools/run-tests.d.mts +3 -2
  60. package/esm/tools/run-tests.d.mts.map +1 -1
  61. package/esm/tools/run-tests.mjs +5 -4
  62. package/esm/tools/run-tests.mjs.map +1 -1
  63. package/esm/tools/schema.mjs +1 -1
  64. package/esm/tools/schema.mjs.map +1 -1
  65. package/esm/tools/write-file.d.mts +1 -1
  66. package/esm/tools/write-file.d.mts.map +1 -1
  67. package/esm/tools/write-file.mjs +1 -1
  68. package/esm/tools/write-file.mjs.map +1 -1
  69. package/esm/workspace.d.mts +1 -1
  70. package/esm/workspace.d.mts.map +1 -1
  71. package/esm/workspace.mjs +1 -1
  72. package/esm/workspace.mjs.map +1 -1
  73. package/llms-full.txt +48 -12
  74. package/llms.txt +2 -2
  75. package/package.json +5 -4
  76. package/skills/README.md +3 -3
  77. package/skills/build-loop-agent/SKILL.md +4 -4
  78. package/skills/use-a-workspace/SKILL.md +44 -8
package/llms-full.txt CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ---
10
10
  name: build-loop-agent
11
- description: 'Wire a @warlock.js/ai-workspace into a coding agent that closes the read → edit → run-tests loop on its own. Triggers: `ws.tools.all()`, `ai.agent({ tools: ws.tools.all() })`, `maxTrips`, `run_tests`, `run_shell`, ''agent that fixes a failing test'', ''make the suite green'', ''coding agent that edits a repo'', ''build-loop agent'', ''self-correcting agent'', ''read-only code reviewer'', ''scaffold then hand off to an agent'', ''share one workspace across a supervisor'', ''least-privilege agent tools'', ''errors as tool data''; typical wiring `const ws = ai.workspace({ cwd, shell }); ai.agent({ model, tools: ws.tools.all(), maxTrips })`. Skip: constructing the workspace / its policy / direct methods / readonly / scope — `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`; the agent loop / maxTrips / events themselves — `@warlock.js/ai/run-ai-agent/SKILL.md`.'
11
+ description: 'Wire a @warlock.js/ai-workspace into a coding agent that closes the read → edit → run-tests loop on its own. Triggers: `ws.tools.all()`, `ai.agent({ tools: ws.tools.all() })`, `maxTrips`, `run_tests`, `run_shell`, ''agent that fixes a failing test'', ''make the suite green'', ''coding agent that edits a repo'', ''build-loop agent'', ''self-correcting agent'', ''read-only code reviewer'', ''scaffold then hand off to an agent'', ''share one workspace across a supervisor'', ''least-privilege agent tools'', ''errors as tool data''; typical wiring `const ws = ai.workspace({ cwd, shell }); ai.agent({ model, tools: ws.tools.all(), maxTrips })`. Skip: constructing the workspace / its policy / direct methods / readonly / scope — the `use-a-workspace` topic; the agent loop / maxTrips / events themselves — the `run-ai-agent` topic of the `warlock-js-ai` skill.'
12
12
  ---
13
13
 
14
14
  # Build a build-loop agent — read → edit → run tests, until green
@@ -103,16 +103,16 @@ ai.supervisor({
103
103
 
104
104
  ## See also
105
105
 
106
- - [`@warlock.js/ai-workspace/use-a-workspace/SKILL.md`](@warlock.js/ai-workspace/use-a-workspace/SKILL.md) — building the workspace, the policy surface, the direct methods, `readonly` / `scope`, and the typed errors.
107
- - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — the agent loop, `maxTrips`, and the run report.
108
- - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — coordinating the members that share one workspace.
106
+ - The `use-a-workspace` topic — building the workspace, the policy surface, the direct methods, `readonly` / `scope`, and the typed errors.
107
+ - The `run-ai-agent` topic of the `warlock-js-ai` skill — the agent loop, `maxTrips`, and the run report.
108
+ - The `run-supervisor` topic of the `warlock-js-ai` skill — coordinating the members that share one workspace.
109
109
 
110
110
 
111
111
  ## use-a-workspace `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`
112
112
 
113
113
  ---
114
114
  name: use-a-workspace
115
- description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `tokenizeCommand`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`/`grep`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard'', ''command injection guard for an agent shell tool'', ''ReDoS-safe grep for an agent''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.'
115
+ description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `read.maxBytes`, `bytesCapped`, `cancelled`, `ExecOptions`, `tokenizeCommand`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`/`grep`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard'', ''command injection guard for an agent shell tool'', ''ReDoS-safe grep for an agent'', ''cap how many bytes a read returns'', ''cancel a running workspace command''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — the `build-loop-agent` topic; defining a non-filesystem custom tool — the `define-ai-tool` topic of the `warlock-js-ai` skill.'
116
116
  ---
117
117
 
118
118
  # Use a workspace — a policy jail for an agent's filesystem + shell
@@ -146,8 +146,8 @@ This is least-privilege guardrails for a **trusted** agent — not a sandbox aro
146
146
  | `shell.allow` / `shell.deny` | Executable **basenames** matched against the command's leading argv token (after tokenizing — see below). **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
147
147
  | `shell.inheritEnv` | Opt-in `process.env` keys to pass through (e.g. `["PATH"]`). **Nothing leaks in otherwise** — a command can't find `node`/`npm` without `PATH`. |
148
148
  | `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
149
- | `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (SIGKILL on expiry) and stdout/stderr byte cap. |
150
- | `read.defaultLines` / `read.maxBytes` | Default read window + a byte ceiling. |
149
+ | `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (process tree SIGKILLed on expiry) and stdout/stderr byte cap (clipped on a character boundary). |
150
+ | `read.defaultLines` / `read.maxBytes` | Default read window + a ceiling on the UTF-8 bytes of the returned `content` (line numbers included). See [Read windows and the byte ceiling](#read-windows-and-the-byte-ceiling). |
151
151
  | `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
152
152
 
153
153
  > **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
@@ -185,10 +185,10 @@ ws.tools.runTests({ command: "vitest run" }); // override the test base command
185
185
  The same policy seam, callable from code (no LLM):
186
186
 
187
187
  ```ts
188
- const { content, hash, totalLines } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
188
+ const { content, hash, totalLines, endLine, truncated, bytesCapped } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
189
189
  await ws.writeFile("src/new.ts", "export const x = 1;\n");
190
190
  await ws.editFile({ path: "src/index.ts", oldString: "old", newString: "new", expectHash: hash });
191
- const { exitCode, stdout, stderr, timedOut, truncated } = await ws.exec("npm test");
191
+ const { exitCode, stdout, stderr, timedOut, cancelled, truncated } = await ws.exec("npm test");
192
192
  const { matches } = await ws.grep("TODO", { glob: "src/**/*.ts", ignoreCase: true });
193
193
  const paths = await ws.glob("src/models/**/*.ts");
194
194
  await ws.exists("package.json");
@@ -200,10 +200,46 @@ await ws.remove("dist");
200
200
 
201
201
  The model supplies `pattern` freely, so `grep` treats it as untrusted input before compiling it into a `RegExp`: patterns over 200 characters and patterns matching a nested-quantifier shape (`(x+)+`, `(x*)*`, `(x+)*`, `(x*)+`-style groups — the classic catastrophic-backtracking shape) are rejected up front as a `WorkspacePolicyError` `type: "unsafe-pattern"`, before any regex is compiled. Any scanned line longer than 2000 characters is skipped (not tested), bounding the worst-case backtracking cost of any single call regardless of pattern shape.
202
202
 
203
+ ## Read windows and the byte ceiling
204
+
205
+ `readFile()` and the `read_file` tool return the same result for the same arguments: a `cat -n` numbered window plus `{ startLine, endLine, totalLines, totalBytes, truncated, bytesCapped, hash }`.
206
+
207
+ - The window starts at `offset` (`startLine` on the tool) and holds at most `limit` lines (default `read.defaultLines`, else 2000).
208
+ - `read.maxBytes` caps the UTF-8 bytes of the returned `content` itself — line-number prefixes and newlines count. Whole lines are kept while they fit; the first line that doesn't is cut on a **character boundary** (never mid-character), so a single very long line still comes back bounded. `bytesCapped` is `true` whenever the cap cut anything, and `endLine` is the last line included, possibly partially.
209
+ - `truncated` is `true` when anything after `content` was withheld — later lines (by `limit`, the default window or the cap) or the tail of `endLine`.
210
+ - `hash` is always the SHA-256 of the **whole file**, not the window, so it is a valid `expectHash` whatever was returned. `totalBytes` is the whole file's size.
211
+ - No `maxBytes` ⇒ no byte cap (only the line window applies).
212
+
213
+ ```ts
214
+ const ws = ai.workspace({ cwd, read: { maxBytes: 64_000, defaultLines: 400 } });
215
+ const page = await ws.readFile("dist/bundle.js");
216
+ if (page.bytesCapped) console.warn(`Only part of line ${page.endLine} fit in the read budget.`);
217
+ ```
218
+
219
+ ## Cancelling a command
220
+
221
+ `exec()` accepts a per-call `signal`; `run_shell` and `run_tests` pass the agent run's signal (the dispatch `ctx.signal`) automatically, so cancelling the agent run stops the command it is waiting on.
222
+
223
+ - Already aborted ⇒ the command is **not started** (`exitCode: 130`).
224
+ - Aborted mid-run ⇒ the whole process tree is SIGKILLed, as on a timeout.
225
+ - Either way the result says `cancelled: true` — it resolves, it doesn't throw — and `cancelled` and `timedOut` are never both `true`. Branch on those flags, not on the exit code.
226
+ - The signal belongs to that one call; the workspace never stores it, so one workspace can serve many runs. The abort listener is removed once the command settles.
227
+
228
+ ```ts
229
+ const controller = new AbortController();
230
+ const pending = ws.exec("npm test", { signal: controller.signal, timeoutMs: 120_000 });
231
+ controller.abort();
232
+ const { cancelled } = await pending; // true
233
+ ```
234
+
235
+ A custom `WorkspaceBackend` receives the signal as `exec(command, { signal })` and should follow the same rules, reporting `cancelled: true` in its result.
236
+
203
237
  ## Read-before-edit (the stale-hash guard)
204
238
 
205
239
  `readFile` returns a SHA-256 `hash` of the full file. Pass it to `editFile`'s `expectHash`; if the file changed since you read it, the edit is rejected as stale so you re-read before clobbering. `editFile` also requires the `oldString` to be **exact and unique** — pass `replaceAll: true` to replace every occurrence, or include more surrounding context to disambiguate.
206
240
 
241
+ Writes, edits and removes of the same path run one after another, even when the agent dispatches tool calls concurrently: of two edits pinned to the same `expectHash`, the second is rejected as stale instead of silently overwriting the first. `read.maxBytes` bounds what a read returns, not memory — the whole file is still read and hashed.
242
+
207
243
  ## Errors flow as data, not throws
208
244
 
209
245
  Policy and edit failures are typed errors extending the `@warlock.js/ai` `AIError` base. Inside a tool the `tool()` wrapper catches them and surfaces them in `{ error }`, so the agent reads the failure and self-corrects. Branch on `.type`:
@@ -243,8 +279,8 @@ const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["np
243
279
 
244
280
  ## See also
245
281
 
246
- - [`@warlock.js/ai-workspace/build-loop-agent/SKILL.md`](@warlock.js/ai-workspace/build-loop-agent/SKILL.md) — wiring these tools into a read → edit → test agent loop.
247
- - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — running the agent that consumes `ws.tools.all()`.
248
- - [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — the `tool()` / `ToolContract` seam every workspace tool is built on.
282
+ - The `build-loop-agent` topic — wiring these tools into a read → edit → test agent loop.
283
+ - The `run-ai-agent` topic of the `warlock-js-ai` skill — running the agent that consumes `ws.tools.all()`.
284
+ - The `define-ai-tool` topic of the `warlock-js-ai` skill — the `tool()` / `ToolContract` seam every workspace tool is built on.
249
285
 
250
286
 
package/llms.txt CHANGED
@@ -6,5 +6,5 @@
6
6
 
7
7
  ## Skills
8
8
 
9
- - [build-loop-agent](@warlock.js/ai-workspace/build-loop-agent/SKILL.md): Wire a @warlock.js/ai-workspace into a coding agent that closes the read → edit → run-tests loop on its own. Triggers: `ws.tools.all()`, `ai.agent({ tools: ws.tools.all() })`, `maxTrips`, `run_tests`, `run_shell`, 'agent that fixes a failing test', 'make the suite green', 'coding agent that edits a repo', 'build-loop agent', 'self-correcting agent', 'read-only code reviewer', 'scaffold then hand off to an agent', 'share one workspace across a supervisor', 'least-privilege agent tools', 'errors as tool data'; typical wiring `const ws = ai.workspace({ cwd, shell }); ai.agent({ model, tools: ws.tools.all(), maxTrips })`. Skip: constructing the workspace / its policy / direct methods / readonly / scope — `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`; the agent loop / maxTrips / events themselves — `@warlock.js/ai/run-ai-agent/SKILL.md`.
10
- - [use-a-workspace](@warlock.js/ai-workspace/use-a-workspace/SKILL.md): Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `tokenizeCommand`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`/`grep`; 'give an agent file tools', 'jail an agent to a directory', 'let an agent read/write/grep a repo', 'read-only workspace for a reviewer', 'scope an agent to a subdirectory', 'run a shell command under policy', 'edit a file with a stale-hash guard', 'command injection guard for an agent shell tool', 'ReDoS-safe grep for an agent'; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.
9
+ - [build-loop-agent](@warlock.js/ai-workspace/build-loop-agent/SKILL.md): Wire a @warlock.js/ai-workspace into a coding agent that closes the read → edit → run-tests loop on its own. Triggers: `ws.tools.all()`, `ai.agent({ tools: ws.tools.all() })`, `maxTrips`, `run_tests`, `run_shell`, 'agent that fixes a failing test', 'make the suite green', 'coding agent that edits a repo', 'build-loop agent', 'self-correcting agent', 'read-only code reviewer', 'scaffold then hand off to an agent', 'share one workspace across a supervisor', 'least-privilege agent tools', 'errors as tool data'; typical wiring `const ws = ai.workspace({ cwd, shell }); ai.agent({ model, tools: ws.tools.all(), maxTrips })`. Skip: constructing the workspace / its policy / direct methods / readonly / scope — the `use-a-workspace` topic; the agent loop / maxTrips / events themselves — the `run-ai-agent` topic of the `warlock-js-ai` skill.
10
+ - [use-a-workspace](@warlock.js/ai-workspace/use-a-workspace/SKILL.md): Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `read.maxBytes`, `bytesCapped`, `cancelled`, `ExecOptions`, `tokenizeCommand`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`/`grep`; 'give an agent file tools', 'jail an agent to a directory', 'let an agent read/write/grep a repo', 'read-only workspace for a reviewer', 'scope an agent to a subdirectory', 'run a shell command under policy', 'edit a file with a stale-hash guard', 'command injection guard for an agent shell tool', 'ReDoS-safe grep for an agent', 'cap how many bytes a read returns', 'cancel a running workspace command'; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — the `build-loop-agent` topic; defining a non-filesystem custom tool — the `define-ai-tool` topic of the `warlock-js-ai` skill.
package/package.json CHANGED
@@ -15,16 +15,17 @@
15
15
  "license": "MIT",
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "https://github.com/warlockjs/ai-workspace"
18
+ "url": "git+https://github.com/warlockjs/framework.git",
19
+ "directory": "packages/ai-workspace"
19
20
  },
20
21
  "peerDependencies": {
21
- "@warlock.js/ai": "5.28.0"
22
+ "@warlock.js/ai": "5.30.0"
22
23
  },
23
24
  "dependencies": {
24
25
  "@standard-schema/spec": "^1.0.0",
25
- "@warlock.js/fs": "5.28.0"
26
+ "@warlock.js/fs": "5.30.0"
26
27
  },
27
- "version": "5.28.0",
28
+ "version": "5.30.0",
28
29
  "main": "./cjs/index.cjs",
29
30
  "module": "./esm/index.mjs",
30
31
  "types": "./esm/index.d.mts",
package/skills/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # `@warlock.js/ai-workspace` — skills index
2
2
 
3
- Per-task skills. All cross-references use the form `@warlock.js/<pkg>/<skill>/SKILL.md`.
3
+ Per-task skills. Cross-references name the topic ("the `<topic>` topic"), and for another package add its skill ("the `<topic>` topic of the `warlock-js-<pkg>` skill").
4
4
 
5
5
  ## Skills
6
6
 
7
- ### [`use-a-workspace/`](./use-a-workspace/SKILL.md)
7
+ ### `use-a-workspace`
8
8
 
9
9
  Build a policy-jailed filesystem + shell workspace with `ai.workspace(policy)` and operate it from both ends — the agent-facing `ws.tools.*` (the seven tools + `all()` / `pick(...)`) and the human-facing direct methods (`readFile` / `writeFile` / `editFile` / `exec` / `grep` / `glob` / `exists` / `mkdir` / `remove`). Covers the policy surface (the `cwd` jail, `allowPaths` / `denyPaths`, the fail-closed shell allow/deny + `inheritEnv`, read caps), the local vs mock backend, the read-before-edit stale-hash guard, the typed `WorkspacePolicyError` / `WorkspaceEditError`, and the `readonly()` / `scope(subdir)` projections. Load when constructing a workspace, choosing a policy, vending tools to an agent, or driving the filesystem programmatically.
10
10
 
11
- ### [`build-loop-agent/`](./build-loop-agent/SKILL.md)
11
+ ### `build-loop-agent`
12
12
 
13
13
  Wire a workspace into a coding agent that closes the read → edit → run-tests loop on its own — `ai.agent({ tools: ws.tools.all() })` so a failing suite comes back as tool *data* and the model self-corrects until green. Covers the minimal build-loop wiring, why errors-as-data drives the loop, the shell policy a test command needs (`shell.allow` + `inheritEnv: ["PATH"]`), the read-only reviewer via `tools.pick` / `readonly()`, scaffolding from code before handing off, and sharing one jail across a supervisor's members. Load when building an agent that edits a repo, runs tests, reviews code, or scaffolds then hands off.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: build-loop-agent
3
- description: 'Wire a @warlock.js/ai-workspace into a coding agent that closes the read → edit → run-tests loop on its own. Triggers: `ws.tools.all()`, `ai.agent({ tools: ws.tools.all() })`, `maxTrips`, `run_tests`, `run_shell`, ''agent that fixes a failing test'', ''make the suite green'', ''coding agent that edits a repo'', ''build-loop agent'', ''self-correcting agent'', ''read-only code reviewer'', ''scaffold then hand off to an agent'', ''share one workspace across a supervisor'', ''least-privilege agent tools'', ''errors as tool data''; typical wiring `const ws = ai.workspace({ cwd, shell }); ai.agent({ model, tools: ws.tools.all(), maxTrips })`. Skip: constructing the workspace / its policy / direct methods / readonly / scope — `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`; the agent loop / maxTrips / events themselves — `@warlock.js/ai/run-ai-agent/SKILL.md`.'
3
+ description: 'Wire a @warlock.js/ai-workspace into a coding agent that closes the read → edit → run-tests loop on its own. Triggers: `ws.tools.all()`, `ai.agent({ tools: ws.tools.all() })`, `maxTrips`, `run_tests`, `run_shell`, ''agent that fixes a failing test'', ''make the suite green'', ''coding agent that edits a repo'', ''build-loop agent'', ''self-correcting agent'', ''read-only code reviewer'', ''scaffold then hand off to an agent'', ''share one workspace across a supervisor'', ''least-privilege agent tools'', ''errors as tool data''; typical wiring `const ws = ai.workspace({ cwd, shell }); ai.agent({ model, tools: ws.tools.all(), maxTrips })`. Skip: constructing the workspace / its policy / direct methods / readonly / scope — the `use-a-workspace` topic; the agent loop / maxTrips / events themselves — the `run-ai-agent` topic of the `warlock-js-ai` skill.'
4
4
  ---
5
5
 
6
6
  # Build a build-loop agent — read → edit → run tests, until green
@@ -95,6 +95,6 @@ ai.supervisor({
95
95
 
96
96
  ## See also
97
97
 
98
- - [`@warlock.js/ai-workspace/use-a-workspace/SKILL.md`](@warlock.js/ai-workspace/use-a-workspace/SKILL.md) — building the workspace, the policy surface, the direct methods, `readonly` / `scope`, and the typed errors.
99
- - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — the agent loop, `maxTrips`, and the run report.
100
- - [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — coordinating the members that share one workspace.
98
+ - The `use-a-workspace` topic — building the workspace, the policy surface, the direct methods, `readonly` / `scope`, and the typed errors.
99
+ - The `run-ai-agent` topic of the `warlock-js-ai` skill — the agent loop, `maxTrips`, and the run report.
100
+ - The `run-supervisor` topic of the `warlock-js-ai` skill — coordinating the members that share one workspace.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: use-a-workspace
3
- description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `tokenizeCommand`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`/`grep`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard'', ''command injection guard for an agent shell tool'', ''ReDoS-safe grep for an agent''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.'
3
+ description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `read.maxBytes`, `bytesCapped`, `cancelled`, `ExecOptions`, `tokenizeCommand`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`/`grep`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard'', ''command injection guard for an agent shell tool'', ''ReDoS-safe grep for an agent'', ''cap how many bytes a read returns'', ''cancel a running workspace command''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — the `build-loop-agent` topic; defining a non-filesystem custom tool — the `define-ai-tool` topic of the `warlock-js-ai` skill.'
4
4
  ---
5
5
 
6
6
  # Use a workspace — a policy jail for an agent's filesystem + shell
@@ -34,8 +34,8 @@ This is least-privilege guardrails for a **trusted** agent — not a sandbox aro
34
34
  | `shell.allow` / `shell.deny` | Executable **basenames** matched against the command's leading argv token (after tokenizing — see below). **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
35
35
  | `shell.inheritEnv` | Opt-in `process.env` keys to pass through (e.g. `["PATH"]`). **Nothing leaks in otherwise** — a command can't find `node`/`npm` without `PATH`. |
36
36
  | `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
37
- | `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (SIGKILL on expiry) and stdout/stderr byte cap. |
38
- | `read.defaultLines` / `read.maxBytes` | Default read window + a byte ceiling. |
37
+ | `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (process tree SIGKILLed on expiry) and stdout/stderr byte cap (clipped on a character boundary). |
38
+ | `read.defaultLines` / `read.maxBytes` | Default read window + a ceiling on the UTF-8 bytes of the returned `content` (line numbers included). See [Read windows and the byte ceiling](#read-windows-and-the-byte-ceiling). |
39
39
  | `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
40
40
 
41
41
  > **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
@@ -73,10 +73,10 @@ ws.tools.runTests({ command: "vitest run" }); // override the test base command
73
73
  The same policy seam, callable from code (no LLM):
74
74
 
75
75
  ```ts
76
- const { content, hash, totalLines } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
76
+ const { content, hash, totalLines, endLine, truncated, bytesCapped } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
77
77
  await ws.writeFile("src/new.ts", "export const x = 1;\n");
78
78
  await ws.editFile({ path: "src/index.ts", oldString: "old", newString: "new", expectHash: hash });
79
- const { exitCode, stdout, stderr, timedOut, truncated } = await ws.exec("npm test");
79
+ const { exitCode, stdout, stderr, timedOut, cancelled, truncated } = await ws.exec("npm test");
80
80
  const { matches } = await ws.grep("TODO", { glob: "src/**/*.ts", ignoreCase: true });
81
81
  const paths = await ws.glob("src/models/**/*.ts");
82
82
  await ws.exists("package.json");
@@ -88,10 +88,46 @@ await ws.remove("dist");
88
88
 
89
89
  The model supplies `pattern` freely, so `grep` treats it as untrusted input before compiling it into a `RegExp`: patterns over 200 characters and patterns matching a nested-quantifier shape (`(x+)+`, `(x*)*`, `(x+)*`, `(x*)+`-style groups — the classic catastrophic-backtracking shape) are rejected up front as a `WorkspacePolicyError` `type: "unsafe-pattern"`, before any regex is compiled. Any scanned line longer than 2000 characters is skipped (not tested), bounding the worst-case backtracking cost of any single call regardless of pattern shape.
90
90
 
91
+ ## Read windows and the byte ceiling
92
+
93
+ `readFile()` and the `read_file` tool return the same result for the same arguments: a `cat -n` numbered window plus `{ startLine, endLine, totalLines, totalBytes, truncated, bytesCapped, hash }`.
94
+
95
+ - The window starts at `offset` (`startLine` on the tool) and holds at most `limit` lines (default `read.defaultLines`, else 2000).
96
+ - `read.maxBytes` caps the UTF-8 bytes of the returned `content` itself — line-number prefixes and newlines count. Whole lines are kept while they fit; the first line that doesn't is cut on a **character boundary** (never mid-character), so a single very long line still comes back bounded. `bytesCapped` is `true` whenever the cap cut anything, and `endLine` is the last line included, possibly partially.
97
+ - `truncated` is `true` when anything after `content` was withheld — later lines (by `limit`, the default window or the cap) or the tail of `endLine`.
98
+ - `hash` is always the SHA-256 of the **whole file**, not the window, so it is a valid `expectHash` whatever was returned. `totalBytes` is the whole file's size.
99
+ - No `maxBytes` ⇒ no byte cap (only the line window applies).
100
+
101
+ ```ts
102
+ const ws = ai.workspace({ cwd, read: { maxBytes: 64_000, defaultLines: 400 } });
103
+ const page = await ws.readFile("dist/bundle.js");
104
+ if (page.bytesCapped) console.warn(`Only part of line ${page.endLine} fit in the read budget.`);
105
+ ```
106
+
107
+ ## Cancelling a command
108
+
109
+ `exec()` accepts a per-call `signal`; `run_shell` and `run_tests` pass the agent run's signal (the dispatch `ctx.signal`) automatically, so cancelling the agent run stops the command it is waiting on.
110
+
111
+ - Already aborted ⇒ the command is **not started** (`exitCode: 130`).
112
+ - Aborted mid-run ⇒ the whole process tree is SIGKILLed, as on a timeout.
113
+ - Either way the result says `cancelled: true` — it resolves, it doesn't throw — and `cancelled` and `timedOut` are never both `true`. Branch on those flags, not on the exit code.
114
+ - The signal belongs to that one call; the workspace never stores it, so one workspace can serve many runs. The abort listener is removed once the command settles.
115
+
116
+ ```ts
117
+ const controller = new AbortController();
118
+ const pending = ws.exec("npm test", { signal: controller.signal, timeoutMs: 120_000 });
119
+ controller.abort();
120
+ const { cancelled } = await pending; // true
121
+ ```
122
+
123
+ A custom `WorkspaceBackend` receives the signal as `exec(command, { signal })` and should follow the same rules, reporting `cancelled: true` in its result.
124
+
91
125
  ## Read-before-edit (the stale-hash guard)
92
126
 
93
127
  `readFile` returns a SHA-256 `hash` of the full file. Pass it to `editFile`'s `expectHash`; if the file changed since you read it, the edit is rejected as stale so you re-read before clobbering. `editFile` also requires the `oldString` to be **exact and unique** — pass `replaceAll: true` to replace every occurrence, or include more surrounding context to disambiguate.
94
128
 
129
+ Writes, edits and removes of the same path run one after another, even when the agent dispatches tool calls concurrently: of two edits pinned to the same `expectHash`, the second is rejected as stale instead of silently overwriting the first. `read.maxBytes` bounds what a read returns, not memory — the whole file is still read and hashed.
130
+
95
131
  ## Errors flow as data, not throws
96
132
 
97
133
  Policy and edit failures are typed errors extending the `@warlock.js/ai` `AIError` base. Inside a tool the `tool()` wrapper catches them and surfaces them in `{ error }`, so the agent reads the failure and self-corrects. Branch on `.type`:
@@ -131,6 +167,6 @@ const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["np
131
167
 
132
168
  ## See also
133
169
 
134
- - [`@warlock.js/ai-workspace/build-loop-agent/SKILL.md`](@warlock.js/ai-workspace/build-loop-agent/SKILL.md) — wiring these tools into a read → edit → test agent loop.
135
- - [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — running the agent that consumes `ws.tools.all()`.
136
- - [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — the `tool()` / `ToolContract` seam every workspace tool is built on.
170
+ - The `build-loop-agent` topic — wiring these tools into a read → edit → test agent loop.
171
+ - The `run-ai-agent` topic of the `warlock-js-ai` skill — running the agent that consumes `ws.tools.all()`.
172
+ - The `define-ai-tool` topic of the `warlock-js-ai` skill — the `tool()` / `ToolContract` seam every workspace tool is built on.