@warlock.js/ai-workspace 5.30.0 → 5.32.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 (120) hide show
  1. package/CHANGELOG.md +52 -2
  2. package/README.md +34 -21
  3. package/cjs/index.cjs +3134 -315
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts +1 -3
  6. package/esm/backends/local.d.mts.map +1 -1
  7. package/esm/backends/local.mjs +166 -28
  8. package/esm/backends/local.mjs.map +1 -1
  9. package/esm/backends/mock.d.mts +14 -9
  10. package/esm/backends/mock.d.mts.map +1 -1
  11. package/esm/backends/mock.mjs +34 -0
  12. package/esm/backends/mock.mjs.map +1 -1
  13. package/esm/contracts/index.d.mts +5 -4
  14. package/esm/contracts/tool-io.type.d.mts +216 -19
  15. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  16. package/esm/contracts/workspace-backend.contract.d.mts +62 -4
  17. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  18. package/esm/contracts/workspace-ops.contract.d.mts +33 -6
  19. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  20. package/esm/contracts/workspace-policy.type.d.mts +136 -16
  21. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  22. package/esm/contracts/workspace.contract.d.mts +65 -13
  23. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  24. package/esm/errors.d.mts +103 -17
  25. package/esm/errors.d.mts.map +1 -1
  26. package/esm/errors.mjs +22 -1
  27. package/esm/errors.mjs.map +1 -1
  28. package/esm/index.d.mts +14 -5
  29. package/esm/index.mjs +10 -2
  30. package/esm/ops.d.mts +21 -3
  31. package/esm/ops.d.mts.map +1 -1
  32. package/esm/ops.mjs +230 -64
  33. package/esm/ops.mjs.map +1 -1
  34. package/esm/policy/jail.mjs +73 -0
  35. package/esm/policy/jail.mjs.map +1 -0
  36. package/esm/policy/policy.d.mts +30 -20
  37. package/esm/policy/policy.d.mts.map +1 -1
  38. package/esm/policy/policy.mjs +64 -87
  39. package/esm/policy/policy.mjs.map +1 -1
  40. package/esm/policy/shell-check.d.mts +63 -0
  41. package/esm/policy/shell-check.d.mts.map +1 -0
  42. package/esm/policy/shell-check.mjs +638 -0
  43. package/esm/policy/shell-check.mjs.map +1 -0
  44. package/esm/policy/shell-parser.mjs +802 -0
  45. package/esm/policy/shell-parser.mjs.map +1 -0
  46. package/esm/policy/tokenize-command.d.mts +62 -0
  47. package/esm/policy/tokenize-command.d.mts.map +1 -0
  48. package/esm/policy/tokenize-command.mjs +76 -24
  49. package/esm/policy/tokenize-command.mjs.map +1 -1
  50. package/esm/shell-jobs.d.mts +45 -0
  51. package/esm/shell-jobs.d.mts.map +1 -0
  52. package/esm/shell-jobs.mjs +247 -0
  53. package/esm/shell-jobs.mjs.map +1 -0
  54. package/esm/todos.d.mts +22 -0
  55. package/esm/todos.d.mts.map +1 -0
  56. package/esm/todos.mjs +73 -0
  57. package/esm/todos.mjs.map +1 -0
  58. package/esm/tools/edit-file.d.mts +2 -3
  59. package/esm/tools/edit-file.d.mts.map +1 -1
  60. package/esm/tools/edit-file.mjs +2 -1
  61. package/esm/tools/edit-file.mjs.map +1 -1
  62. package/esm/tools/glob.d.mts +3 -4
  63. package/esm/tools/glob.d.mts.map +1 -1
  64. package/esm/tools/glob.mjs +2 -1
  65. package/esm/tools/glob.mjs.map +1 -1
  66. package/esm/tools/grep.d.mts +3 -4
  67. package/esm/tools/grep.d.mts.map +1 -1
  68. package/esm/tools/grep.mjs +2 -1
  69. package/esm/tools/grep.mjs.map +1 -1
  70. package/esm/tools/json-schemas.mjs +175 -0
  71. package/esm/tools/json-schemas.mjs.map +1 -0
  72. package/esm/tools/list-dir.d.mts +31 -0
  73. package/esm/tools/list-dir.d.mts.map +1 -0
  74. package/esm/tools/list-dir.mjs +50 -0
  75. package/esm/tools/list-dir.mjs.map +1 -0
  76. package/esm/tools/multi-edit.d.mts +37 -0
  77. package/esm/tools/multi-edit.d.mts.map +1 -0
  78. package/esm/tools/multi-edit.mjs +121 -0
  79. package/esm/tools/multi-edit.mjs.map +1 -0
  80. package/esm/tools/read-file.d.mts +2 -3
  81. package/esm/tools/read-file.d.mts.map +1 -1
  82. package/esm/tools/read-file.mjs +2 -1
  83. package/esm/tools/read-file.mjs.map +1 -1
  84. package/esm/tools/run-shell.d.mts +36 -15
  85. package/esm/tools/run-shell.d.mts.map +1 -1
  86. package/esm/tools/run-shell.mjs +59 -53
  87. package/esm/tools/run-shell.mjs.map +1 -1
  88. package/esm/tools/run-tests.d.mts +11 -4
  89. package/esm/tools/run-tests.d.mts.map +1 -1
  90. package/esm/tools/run-tests.mjs +10 -3
  91. package/esm/tools/run-tests.mjs.map +1 -1
  92. package/esm/tools/schema.mjs +23 -18
  93. package/esm/tools/schema.mjs.map +1 -1
  94. package/esm/tools/shell-kill.d.mts +24 -0
  95. package/esm/tools/shell-kill.d.mts.map +1 -0
  96. package/esm/tools/shell-kill.mjs +36 -0
  97. package/esm/tools/shell-kill.mjs.map +1 -0
  98. package/esm/tools/shell-output.d.mts +29 -0
  99. package/esm/tools/shell-output.d.mts.map +1 -0
  100. package/esm/tools/shell-output.mjs +49 -0
  101. package/esm/tools/shell-output.mjs.map +1 -0
  102. package/esm/tools/todo.d.mts +42 -0
  103. package/esm/tools/todo.d.mts.map +1 -0
  104. package/esm/tools/todo.mjs +126 -0
  105. package/esm/tools/todo.mjs.map +1 -0
  106. package/esm/tools/write-file.d.mts +2 -3
  107. package/esm/tools/write-file.d.mts.map +1 -1
  108. package/esm/tools/write-file.mjs +2 -1
  109. package/esm/tools/write-file.mjs.map +1 -1
  110. package/esm/workspace.d.mts +14 -11
  111. package/esm/workspace.d.mts.map +1 -1
  112. package/esm/workspace.mjs +108 -27
  113. package/esm/workspace.mjs.map +1 -1
  114. package/llms-full.txt +155 -34
  115. package/llms.txt +1 -1
  116. package/package.json +3 -3
  117. package/skills/README.md +2 -2
  118. package/skills/build-loop-agent/SKILL.md +7 -7
  119. package/skills/index.md +6 -1
  120. package/skills/use-a-workspace/SKILL.md +148 -27
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 — the `use-a-workspace` topic; the agent loop / maxTrips / events themselves — the `run-ai-agent` topic of the `warlock-js-ai` skill.'
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
@@ -27,8 +27,8 @@ const ws = ai.workspace({
27
27
 
28
28
  const dev = ai.agent({
29
29
  model,
30
- tools: ws.tools.all(), // read_file, edit_file, write_file, run_shell, run_tests, grep, glob
31
- maxTrips: 25, // give the loop room to iterate
30
+ tools: ws.tools.all(), // read/edit/multi_edit/write, list_dir, run_shell, run_tests, grep, glob, todo_write/todo_read
31
+ maxTrips: 25, // give the loop room to iterate
32
32
  });
33
33
 
34
34
  await dev.execute("`npm test` fails on the cart-total suite. Make it green.");
@@ -51,7 +51,7 @@ Override the base command per agent: `ws.tools.runTests({ command: "vitest run"
51
51
 
52
52
  ### A read-only reviewer
53
53
 
54
- A reviewer should never write or run a shell. Pick exactly the read tools, or hand it a `readonly()` projection (which omits the mutating tools *and* rejects the mutating direct methods):
54
+ A reviewer should never write or run a shell. Pick exactly the read tools, or hand it a `readonly()` projection (which omits the mutating tools _and_ rejects the mutating direct methods):
55
55
 
56
56
  ```ts
57
57
  const reviewer = ai.agent({
@@ -61,7 +61,7 @@ const reviewer = ai.agent({
61
61
  await reviewer.execute("Review src/cart for off-by-one bugs and summarize.");
62
62
 
63
63
  // Equivalent projection of the same jail:
64
- ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glob only
64
+ ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file, list_dir, grep, glob, todo tools
65
65
  ```
66
66
 
67
67
  ### Scope an agent to one package
@@ -69,8 +69,8 @@ ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glo
69
69
  `scope(subdir)` narrows the jail so a per-package agent can't reach outside it — one sub-agent per package, isolated blast radius:
70
70
 
71
71
  ```ts
72
- const apiDev = ai.agent({ model, tools: ws.scope("packages/api").tools.all() });
73
- const webDev = ai.agent({ model, tools: ws.scope("packages/web").tools.all() });
72
+ const apiDev = ai.agent({ model, tools: ws.scope("packages/api").tools.all() });
73
+ const webDev = ai.agent({ model, tools: ws.scope("packages/web").tools.all() });
74
74
  ```
75
75
 
76
76
  ## Scaffold from code, then hand off
@@ -112,7 +112,7 @@ ai.supervisor({
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`, `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.'
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`, `ws.multiEdit`, `ws.listDir`, `ws.startShell`, `ws.shellOutput`, `ws.shellKill`, `ws.dispose`, `ws.readTodos`, `ws.writeTodos`, `WorkspacePolicyError`, `WorkspaceEditError`, `WorkspaceJobError`, `WorkspaceShellRefusalReason`, `checkShellCommand`, `denyPaths`, `denyPatterns`, `allowCommand`, `interpreter`, `shell.background`, `list.ignore`, `inheritEnv`, `expectHash`, `read.maxBytes`, `bytesCapped`, `cancelled`, `ExecOptions`, `tokenizeCommand`, `read_file`/`edit_file`/`multi_edit`/`write_file`/`list_dir`/`run_shell`/`shell_output`/`shell_kill`/`run_tests`/`grep`/`todo_write`/`todo_read`; ''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'', ''let the agent use pipes and &&'', ''why was my shell command refused'', ''deny git push --force'', ''run a dev server in the background'', ''atomic multi-edit'', ''list a directory for an agent'', ''todo list tool 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 — 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
@@ -134,21 +134,29 @@ const ws = ai.workspace({
134
134
 
135
135
  ## A policy jail, NOT OS isolation
136
136
 
137
- This is least-privilege guardrails for a **trusted** agent — not a sandbox around hostile code. It enforces a `realpath`-resolved `cwd` jail, path allow/deny, a fail-closed shell allow/deny list, timeouts, output caps, and a non-inherited environment. It does **not** contain what an *allowed* process does once it runs. Real isolation (container / microVM) is a later phase.
137
+ This is least-privilege guardrails for a **trusted** agent — not a sandbox around hostile code. It enforces a `realpath`-resolved `cwd` jail, path allow/deny, a fail-closed shell allow/deny list, timeouts, output caps, and a non-inherited environment. It does **not** contain what an _allowed_ process does once it runs.
138
+
139
+ Paths are resolved against `cwd` with symlinks collapsed and accepted inside `cwd` or an `allowPaths` root, off `denyPaths`; absolute paths inside the jail, links that stay inside and not-yet-existing paths (for writes) are accepted. To read a folder you do not trust from app code (no links at all, size and entry caps), use `fs.confined(root)` from `@warlock.js/fs` (the `read-a-folder-confined` topic) instead of a workspace.
140
+
141
+ **Shell mode (`interpreter: "sh"`) is a policy layer (defence in depth), not an OS sandbox.** It parses the line before `/bin/sh` runs it and refuses what it cannot read, but `/bin/sh` differs between systems (bash, dash, busybox ash), an allowed program (`node`, `npm`, `make`, a git hook) can still start anything the OS user can, and a symlink can change between the jail re-check and the write. For an untrusted agent (one that reads untrusted repos, issues, web pages or tool output), run the workspace in a container or microVM with only the project mounted, or at least as a separate unprivileged user, and keep the policy as a second layer. Prefer argv mode (the default) when the agent does not need pipes or redirections.
138
142
 
139
143
  ## The policy surface
140
144
 
141
- | Field | What it does |
142
- |---|---|
143
- | `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
144
- | `allowPaths` | Extra readable roots outside `cwd`. |
145
- | `denyPaths` | Globs (`*`, `**`, `?`) blocked **even inside `cwd`** — e.g. `[".git/**", ".env*"]`. A bare dir name (`"node_modules"`) also blocks its contents. |
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
- | `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
- | `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
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
- | `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
145
+ | Field | What it does |
146
+ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
147
+ | `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
148
+ | `allowPaths` | Extra readable roots outside `cwd`. |
149
+ | `denyPaths` | Globs (`*`, `**`, `?`) blocked **even inside `cwd`** — e.g. `[".git/**", ".env*"]`. A bare dir name (`"node_modules"`) also blocks its contents. |
150
+ | `shell.allow` / `shell.deny` | Executable **basenames** matched against every command's argv (after tokenizing — see below). **Deny wins**, case-insensitively. An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
151
+ | `shell.denyPatterns` / `shell.allowCommand` | Argument rules on every command: string prefix patterns (`"git push --force"`) or RegExps, and a predicate `(argv, ctx) => true \| false \| "why"`. |
152
+ | `shell.interpreter` | `"none"` (default, argv) or `"sh"` (opt-in shell syntax, every command checked). |
153
+ | `shell.background` | `{ maxJobs?, timeoutMs? }` — enables background commands. Absent ⇒ refused. |
154
+ | `list.ignore` / `list.maxDepth` / `list.maxEntries` | `list_dir` bounds: hidden globs (default `node_modules`, `.git`), depth cap (5), entry cap (500). |
155
+ | `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`. |
156
+ | `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
157
+ | `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (process tree SIGKILLed on expiry) and stdout/stderr byte cap (clipped on a character boundary). |
158
+ | `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). |
159
+ | `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
152
160
 
153
161
  > **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
154
162
 
@@ -157,27 +165,64 @@ This is least-privilege guardrails for a **trusted** agent — not a sandbox aro
157
165
  Commands are tokenized (via `tokenizeCommand`) into an argv and spawned with **no shell semantics** — no pipes, redirection, chaining, backticks, or variable expansion. `npm test && rm -rf /` or `` `curl evil` `` doesn't chain or substitute; it's rejected outright rather than executed piecemeal:
158
166
 
159
167
  ```ts
160
- await ws.exec("npm test"); // OK — ["npm", "test"]
161
- await ws.exec('node -e "console.log(1)"'); // OK — quotes respected, one argv element
162
- await ws.exec("npm test; curl http://evil"); // rejected — unquoted `;` is a metacharacter
168
+ await ws.exec("npm test"); // OK — ["npm", "test"]
169
+ await ws.exec('node -e "console.log(1)"'); // OK — quotes respected, one argv element
170
+ await ws.exec("npm test; curl http://evil"); // rejected — unquoted `;` is a metacharacter
163
171
  ```
164
172
 
165
173
  - **Quoting is respected**; **unquoted** shell metacharacters — `;` `&` `|` `<` `>` `` ` `` `$` `(` `)` and newlines — are rejected as a `WorkspacePolicyError` `type: "denied-command"` before anything spawns. The `shell.allow`/`shell.deny` basename check runs against the tokenized argv's first element, not a raw string prefix, so a disguised leading token can't slip past the gate.
166
174
  - **On Windows**, the argv is run through a `cmd.exe /d /s /c` wrapper with every element individually quoted (needed because batch shims like `npm.cmd` can't be spawned shell-less); arguments containing `"`, `%`, or newlines are refused there rather than risked (guards the BatBadBut-class argument-smuggling bug class).
167
175
  - **`run_tests`'s `pattern`** (model-controlled test-name filter) is forwarded as a **single double-quoted argv token** to the runner, not concatenated into the command string — a pattern containing double quotes or newlines is rejected at input validation rather than passed through.
168
- - This is a **behavior change, not just a hardening detail**: shell conveniences your commands may have relied on (pipes, redirection, `&&` chaining, `$(...)` substitution) no longer work in `run_shell`/`exec`. Compose multi-step work as separate `ws.exec()` calls instead of a single shelled pipeline.
176
+ - Shell conveniences (pipes, redirection, `&&` chaining, `$(...)` substitution) don't work in argv mode. The refusal (`reason: "metacharacter"`) names the operator and suggests the split — e.g. "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m "wip"`, as separate commands." — so the model retries correctly.
177
+ - **Wrappers are followed**: `env`, `time`, `timeout`, `nice`, `nohup`, `command`, `builtin`, `exec`, `find -exec` — the inner command is checked too. Runners the policy can't see into (`sh -c`, `bash`, `eval`, `source`, `xargs`, `sudo`, `setsid`, …) are refused with `reason: "nested-command"` while deny rules are in force, unless `allow` names them.
178
+
179
+ ## Argument rules and shell mode
180
+
181
+ ```ts
182
+ import { workspace, WorkspacePolicyError } from "@warlock.js/ai-workspace";
183
+
184
+ export async function commitWork(cwd: string) {
185
+ const ws = workspace({
186
+ cwd,
187
+ shell: {
188
+ interpreter: "sh", // opt in: pipes, &&, redirections — every command still checked
189
+ allow: ["git", "npm", "grep"],
190
+ denyPatterns: ["git push --force", /^git reset --hard\b/],
191
+ allowCommand: (argv) => (argv[0] === "git" && argv[1] === "push" ? "CI pushes." : true),
192
+ inheritEnv: ["PATH"],
193
+ timeoutMs: 120_000,
194
+ },
195
+ });
196
+
197
+ try {
198
+ await ws.exec("git add -A && git commit -m 'wip'");
199
+ return await ws.exec("npm test 2>&1 | grep -i fail > logs/failures.txt");
200
+ } catch (error) {
201
+ if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
202
+ console.warn(error.reason, error.message); // e.g. "denied-pattern", "path-escape"
203
+ }
204
+
205
+ throw error;
206
+ }
207
+ }
208
+ ```
209
+
210
+ - Shell mode parses the line and checks every simple command — inside `;`, `&&`, `||`, pipes, `( )`, `$( )`, backticks, here-documents and `NAME=value cmd` prefixes. Redirection targets and `cd` directories must be literal paths inside the jail (`/dev/null` is fine), re-checked with symlinks resolved.
211
+ - It refuses what it can't parse with confidence (`reason: "unsupported-syntax"`): compound commands, arithmetic, brace expansion, unusual `${…}` forms, bash-only redirections, a command name decided at run time, `&` in a line (use `background: true`), assignments to `IFS`/`PS4`/`CDPATH`/`BASH_ENV`…; it drops shell start-up variables (`ENV`, `BASH_ENV`, `SHELLOPTS`, `BASH_FUNC_*`, …) from the environment.
212
+ - `reason` values: `"metacharacter"`, `"unbalanced-quotes"`, `"denied-executable"`, `"not-allowed"`, `"denied-pattern"`, `"nested-command"`, `"unsupported-syntax"`, `"path-escape"`. `checkShellCommand(policy, line)` returns the verdict without running anything.
213
+ - Pair shell mode with an exhaustive `allow`: `deny` alone is a best-effort filter.
169
214
 
170
215
  ## Two callers, one jail
171
216
 
172
217
  ### Agent-facing — `ws.tools.*`
173
218
 
174
- Each tool is a `ToolContract` ready for `ai.agent({ tools })`. The seven LLM-visible tool names are `read_file`, `edit_file`, `write_file`, `run_shell`, `run_tests`, `grep`, `glob`.
219
+ Each tool is a `ToolContract` (with a JSON Schema of its input) ready for `ai.agent({ tools })`. The LLM-visible tool names are `read_file`, `edit_file`, `multi_edit`, `write_file`, `list_dir`, `run_shell`, `shell_output`, `shell_kill`, `run_tests`, `grep`, `glob`, `todo_write`, `todo_read` — `shell_output` / `shell_kill` only in `all()` when `shell.background` is set.
175
220
 
176
221
  ```ts
177
- ws.tools.all(); // all seven, canonical order
178
- ws.tools.pick("readFile", "grep", "glob"); // a least-privilege subset
179
- ws.tools.readFile({ name: "open" }); // one tool, optionally renamed
180
- ws.tools.runTests({ command: "vitest run" }); // override the test base command
222
+ ws.tools.all(); // every tool, canonical order
223
+ ws.tools.pick("readFile", "grep", "glob"); // a least-privilege subset
224
+ ws.tools.readFile({ name: "open" }); // one tool, optionally renamed
225
+ ws.tools.runTests({ command: "vitest run" }); // override the test base command
181
226
  ```
182
227
 
183
228
  ### Human-facing — direct methods
@@ -185,7 +230,10 @@ ws.tools.runTests({ command: "vitest run" }); // override the test base command
185
230
  The same policy seam, callable from code (no LLM):
186
231
 
187
232
  ```ts
188
- const { content, hash, totalLines, endLine, truncated, bytesCapped } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
233
+ const { content, hash, totalLines, endLine, truncated, bytesCapped } = await ws.readFile(
234
+ "src/index.ts",
235
+ { offset: 1, limit: 200 },
236
+ );
189
237
  await ws.writeFile("src/new.ts", "export const x = 1;\n");
190
238
  await ws.editFile({ path: "src/index.ts", oldString: "old", newString: "new", expectHash: hash });
191
239
  const { exitCode, stdout, stderr, timedOut, cancelled, truncated } = await ws.exec("npm test");
@@ -196,6 +244,51 @@ await ws.mkdir("src/generated");
196
244
  await ws.remove("dist");
197
245
  ```
198
246
 
247
+ ## The coding-agent tool belt
248
+
249
+ ```ts
250
+ import { workspace } from "@warlock.js/ai-workspace";
251
+
252
+ export async function devLoop(cwd: string) {
253
+ const ws = workspace({
254
+ cwd,
255
+ list: { maxEntries: 300 },
256
+ shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], background: { maxJobs: 2 } },
257
+ });
258
+
259
+ try {
260
+ const { entries } = await ws.listDir("src", { depth: 2 }); // bounded, jailed, node_modules/.git hidden
261
+ console.info(entries.map((entry) => `${entry.type} ${entry.path}`));
262
+
263
+ const { hash } = await ws.readFile("src/cart.ts");
264
+ await ws.multiEdit({
265
+ path: "src/cart.ts",
266
+ expectHash: hash,
267
+ edits: [
268
+ { oldString: "let total = 0;", newString: "let total = 0n;" },
269
+ { oldString: "total += price;", newString: "total += BigInt(price);" },
270
+ ], // all apply, or none (WorkspaceEditError.editIndex names the failing one)
271
+ });
272
+
273
+ const server = await ws.startShell("npm run dev"); // run_shell({ command, background: true })
274
+ const { stdout, cursor } = await ws.shellOutput(server.id, { waitMs: 5_000 });
275
+ console.info(stdout, cursor); // pass `since: cursor` to read only what comes next
276
+ await ws.shellKill(server.id); // kills the whole process tree
277
+
278
+ ws.writeTodos([{ content: "Fix the rounding bug", status: "completed" }]); // todo_write
279
+ return ws.readTodos().counts; // todo_read
280
+ } finally {
281
+ await ws.dispose(); // kills every background command; the workspace refuses new commands
282
+ }
283
+ }
284
+ ```
285
+
286
+ - `list_dir({ path?, depth?, ignore? })` → `{ path, entries: [{ path, type, size?, depth }], truncated }`; `denyPaths` always hidden, `list.ignore` (default `node_modules`, `.git`) hidden from listings, symlinks reported but never followed.
287
+ - `multi_edit({ path, edits, expectHash? })` → `{ path, edits, replacements, hash }`; same uniqueness rules as `edit_file`, at most 100 edits.
288
+ - Background: `shell_output({ id, since?, waitMs? })` → `{ stdout, stderr, cursor, droppedBytes, truncated, status, exitCode }`; only the newest `shell.maxOutputBytes` per command are kept. States `"running"`, `"exited"`, `"killed"`, `"timed-out"`. Unknown id → `WorkspaceJobError`. Background commands outlive the agent run; `dispose()` (or process exit) kills them. Only processes the workspace spawned are ever signalled.
289
+ - Todos: one in-memory list per workspace, shared with `readonly()` / `scope()`; `todo_write` replaces the whole list (≤ 100 items, status `pending` / `in_progress` / `completed`).
290
+ - `run_shell`'s model-supplied `timeoutMs` can only lower `shell.timeoutMs`.
291
+
199
292
  ## `grep` is ReDoS-bounded
200
293
 
201
294
  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.
@@ -256,17 +349,18 @@ try {
256
349
  }
257
350
  ```
258
351
 
259
- - `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"` (not allow-listed, or rejected during tokenizing — unquoted metacharacters, disallowed Windows-wrapper characters) | `"unsafe-pattern"` (`grep` pattern too long or nested-quantifier ReDoS shape).
260
- - `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"`.
352
+ - `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"` (the shell policy refused the line — `reason` says why) | `"unsafe-pattern"` (`grep` pattern too long or nested-quantifier ReDoS shape).
353
+ - `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"` (+ `editIndex` for `multiEdit`).
354
+ - `WorkspaceJobError.type`: `"not-found"` (unknown background command id).
261
355
 
262
356
  ## Composition — `readonly()` and `scope()`
263
357
 
264
358
  ```ts
265
- const ro = ws.readonly(); // vends only read/grep/glob; mutating direct methods reject
359
+ const ro = ws.readonly(); // vends only read/grep/glob; mutating direct methods reject
266
360
  const api = ws.scope("packages/api"); // a sub-jailed workspace rooted at the subdir
267
361
  ```
268
362
 
269
- `readonly()` is the least-privilege reviewer projection — `tools.all()` on it returns `read_file` + `grep` + `glob` only, and `writeFile` / `editFile` / `exec` / `mkdir` / `remove` throw a `WorkspacePolicyError`. `scope(subdir)` narrows `cwd` to `join(cwd, subdir)` (same sub-policies, same backend), so a per-package agent gets a tighter blast radius.
363
+ `readonly()` is the least-privilege reviewer projection — `tools.all()` on it returns `read_file`, `list_dir`, `grep`, `glob` and the todo tools only, and `writeFile` / `editFile` / `multiEdit` / `exec` / `startShell` / `shellKill` / `mkdir` / `remove` throw a `WorkspacePolicyError`. Projections share the workspace's background commands and todo list. `scope(subdir)` narrows `cwd` to `join(cwd, subdir)` (same sub-policies, same backend), so a per-package agent gets a tighter blast radius.
270
364
 
271
365
  ## Hermetic tests with the mock backend
272
366
 
@@ -283,4 +377,31 @@ const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["np
283
377
  - The `run-ai-agent` topic of the `warlock-js-ai` skill — running the agent that consumes `ws.tools.all()`.
284
378
  - The `define-ai-tool` topic of the `warlock-js-ai` skill — the `tool()` / `ToolContract` seam every workspace tool is built on.
285
379
 
380
+ ## Complete bounded-read and cancellation example
381
+
382
+ ```ts
383
+ import { workspace } from "@warlock.js/ai-workspace";
384
+
385
+ export async function inspectWorkspace(cwd: string) {
386
+ const ws = workspace({
387
+ cwd,
388
+ read: { maxBytes: 64_000, defaultLines: 400 },
389
+ shell: { allow: ["node"], inheritEnv: ["PATH"] },
390
+ });
391
+ const page = await ws.readFile("package.json");
392
+ console.info(page.bytesCapped, page.endLine, page.hash);
393
+
394
+ const controller = new AbortController();
395
+ controller.abort();
396
+ const result = await ws.exec('node -e "console.log(1)"', { signal: controller.signal });
397
+ if (result.cancelled) console.info("Command cancelled before it started");
398
+ return page;
399
+ }
400
+ ```
401
+
402
+ The caller supplies an absolute existing workspace root. The already-aborted
403
+ signal demonstrates the no-spawn path; a later abort also kills a running
404
+ command's process tree. The byte ceiling limits returned content, while the
405
+ hash still describes the whole file.
406
+
286
407
 
package/llms.txt CHANGED
@@ -7,4 +7,4 @@
7
7
  ## Skills
8
8
 
9
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.
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`, `ws.multiEdit`, `ws.listDir`, `ws.startShell`, `ws.shellOutput`, `ws.shellKill`, `ws.dispose`, `ws.readTodos`, `ws.writeTodos`, `WorkspacePolicyError`, `WorkspaceEditError`, `WorkspaceJobError`, `WorkspaceShellRefusalReason`, `checkShellCommand`, `denyPaths`, `denyPatterns`, `allowCommand`, `interpreter`, `shell.background`, `list.ignore`, `inheritEnv`, `expectHash`, `read.maxBytes`, `bytesCapped`, `cancelled`, `ExecOptions`, `tokenizeCommand`, `read_file`/`edit_file`/`multi_edit`/`write_file`/`list_dir`/`run_shell`/`shell_output`/`shell_kill`/`run_tests`/`grep`/`todo_write`/`todo_read`; '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', 'let the agent use pipes and &&', 'why was my shell command refused', 'deny git push --force', 'run a dev server in the background', 'atomic multi-edit', 'list a directory for an agent', 'todo list tool 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 — 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
@@ -19,13 +19,13 @@
19
19
  "directory": "packages/ai-workspace"
20
20
  },
21
21
  "peerDependencies": {
22
- "@warlock.js/ai": "5.30.0"
22
+ "@warlock.js/ai": "5.32.0"
23
23
  },
24
24
  "dependencies": {
25
25
  "@standard-schema/spec": "^1.0.0",
26
- "@warlock.js/fs": "5.30.0"
26
+ "@warlock.js/fs": "5.32.0"
27
27
  },
28
- "version": "5.30.0",
28
+ "version": "5.32.0",
29
29
  "main": "./cjs/index.cjs",
30
30
  "module": "./esm/index.mjs",
31
31
  "types": "./esm/index.d.mts",
package/skills/README.md CHANGED
@@ -6,8 +6,8 @@ Per-task skills. Cross-references name the topic ("the `<topic>` topic"), and fo
6
6
 
7
7
  ### `use-a-workspace`
8
8
 
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.
9
+ Build a policy-jailed filesystem + shell workspace with `ai.workspace(policy)` and operate it from both ends — the agent-facing `ws.tools.*` (files, `list_dir`, `multi_edit`, `run_shell` with background commands, `shell_output` / `shell_kill`, `run_tests`, `grep`, `glob`, `todo_write` / `todo_read`, plus `all()` / `pick(...)`) and the human-facing direct methods (`readFile` / `writeFile` / `editFile` / `multiEdit` / `listDir` / `exec` / `startShell` / `shellOutput` / `shellKill` / `grep` / `glob` / `exists` / `mkdir` / `remove` / `readTodos` / `writeTodos` / `dispose`). Covers the policy surface (the `cwd` jail, `allowPaths` / `denyPaths`, the fail-closed shell allow/deny + `denyPatterns` / `allowCommand` + `inheritEnv`, the opt-in `interpreter: "sh"` shell mode and its refusal reasons, read caps, list bounds), 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
11
  ### `build-loop-agent`
12
12
 
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.
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 — the `use-a-workspace` topic; the agent loop / maxTrips / events themselves — the `run-ai-agent` topic of the `warlock-js-ai` skill.'
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
@@ -19,8 +19,8 @@ const ws = ai.workspace({
19
19
 
20
20
  const dev = ai.agent({
21
21
  model,
22
- tools: ws.tools.all(), // read_file, edit_file, write_file, run_shell, run_tests, grep, glob
23
- maxTrips: 25, // give the loop room to iterate
22
+ tools: ws.tools.all(), // read/edit/multi_edit/write, list_dir, run_shell, run_tests, grep, glob, todo_write/todo_read
23
+ maxTrips: 25, // give the loop room to iterate
24
24
  });
25
25
 
26
26
  await dev.execute("`npm test` fails on the cart-total suite. Make it green.");
@@ -43,7 +43,7 @@ Override the base command per agent: `ws.tools.runTests({ command: "vitest run"
43
43
 
44
44
  ### A read-only reviewer
45
45
 
46
- A reviewer should never write or run a shell. Pick exactly the read tools, or hand it a `readonly()` projection (which omits the mutating tools *and* rejects the mutating direct methods):
46
+ A reviewer should never write or run a shell. Pick exactly the read tools, or hand it a `readonly()` projection (which omits the mutating tools _and_ rejects the mutating direct methods):
47
47
 
48
48
  ```ts
49
49
  const reviewer = ai.agent({
@@ -53,7 +53,7 @@ const reviewer = ai.agent({
53
53
  await reviewer.execute("Review src/cart for off-by-one bugs and summarize.");
54
54
 
55
55
  // Equivalent projection of the same jail:
56
- ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glob only
56
+ ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file, list_dir, grep, glob, todo tools
57
57
  ```
58
58
 
59
59
  ### Scope an agent to one package
@@ -61,8 +61,8 @@ ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glo
61
61
  `scope(subdir)` narrows the jail so a per-package agent can't reach outside it — one sub-agent per package, isolated blast radius:
62
62
 
63
63
  ```ts
64
- const apiDev = ai.agent({ model, tools: ws.scope("packages/api").tools.all() });
65
- const webDev = ai.agent({ model, tools: ws.scope("packages/web").tools.all() });
64
+ const apiDev = ai.agent({ model, tools: ws.scope("packages/api").tools.all() });
65
+ const webDev = ai.agent({ model, tools: ws.scope("packages/web").tools.all() });
66
66
  ```
67
67
 
68
68
  ## Scaffold from code, then hand off
package/skills/index.md CHANGED
@@ -1,17 +1,22 @@
1
1
  ---
2
2
  description: "Policy-jailed filesystem and shell access for @warlock.js/ai agents, for coding and build-loop agents. Exports `workspace`, `makeEditFileTool`, `makeRunShellTool`, `makeRunTestsTool`, `createLocalBackend`, `createMockBackend`, `WorkspacePolicyError`. Use for: let an agent read, edit or write files, run shell commands or tests safely, sandbox an agent to a directory, build a coding agent that loops until tests pass, test file tools with a fake backend. Not this package: web, HTTP or MCP tools → @warlock.js/ai-tools; agents and custom tools → @warlock.js/ai; tracing runs → @warlock.js/ai-panoptic."
3
3
  ---
4
+
4
5
  # @warlock.js/ai-workspace
5
6
 
6
- A workspace is a policy jail around a directory: it decides which paths an agent may read or write and which shell commands may run, then exposes read, write, edit, glob, grep, shell and test tools bound to that policy. Importing the package registers `ai.workspace(...)` on the shared `ai` object.
7
+ A workspace is a policy jail around a directory: it decides which paths an agent may read or write and which shell commands may run, then exposes read, write, edit, multi-edit, list, glob, grep, shell (argv by default, opt-in `sh`, background commands), test and todo tools bound to that policy. Importing the package registers `ai.workspace(...)` on the shared `ai` object.
7
8
 
8
9
  ## The 80% path
10
+
9
11
  1. Create a workspace with a root and policy (`use-a-workspace.md`).
10
12
  2. Hand its tools to an `ai.agent()`.
11
13
  3. For a coding agent, follow the read → edit → run tests loop until green (`build-loop-agent.md`).
12
14
 
13
15
  ## Conventions and pitfalls
16
+
14
17
  - The policy is the security boundary: keep the root narrow and the shell allowlist explicit; violations throw `WorkspacePolicyError`.
15
18
  - Paths resolve inside the jail; escaping it via `..` or symlinks is rejected on purpose.
16
19
  - The local backend runs real processes. Use `createMockBackend` in tests so no disk or shell is touched.
20
+ - Shell refusals carry a `reason` and a message that says how to split the command; `interpreter: "sh"` is an explicit opt-in that still checks every command in the line.
21
+ - Call `ws.dispose()` when done: it kills background commands (whole process trees).
17
22
  - Edits fail loudly (`WorkspaceEditError`) when the target text is not found; re-read the file rather than retrying blindly.