@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/cjs/index.cjs CHANGED
@@ -20,7 +20,7 @@ var __copyProps = (to, from, except, desc) => {
20
20
  }
21
21
  return to;
22
22
  };
23
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
24
24
  value: mod,
25
25
  enumerable: true
26
26
  }) : target, mod));
@@ -33,6 +33,7 @@ let node_fs_promises = require("node:fs/promises");
33
33
  let _warlock_js_fs = require("@warlock.js/fs");
34
34
  let node_child_process = require("node:child_process");
35
35
  let node_process = require("node:process");
36
+ let node_string_decoder = require("node:string_decoder");
36
37
 
37
38
  //#region ../../packages/ai-workspace/src/errors.ts
38
39
  /**
@@ -60,6 +61,8 @@ var WorkspacePolicyError = class extends _warlock_js_ai.AIError {
60
61
  this.path = options.path;
61
62
  this.command = options.command;
62
63
  this.pattern = options.pattern;
64
+ this.reason = options.reason;
65
+ this.executable = options.executable;
63
66
  }
64
67
  };
65
68
  /**
@@ -85,8 +88,896 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
85
88
  this.matches = options.matches;
86
89
  this.expectedHash = options.expectedHash;
87
90
  this.actualHash = options.actualHash;
91
+ this.editIndex = options.editIndex;
88
92
  }
89
93
  };
94
+ /**
95
+ * A `shell_output` / `shell_kill` call (or `shellOutput()` / `shellKill()`)
96
+ * named a background command this workspace does not know. Returned to the
97
+ * agent as tool-error data like the other workspace errors.
98
+ *
99
+ * @example
100
+ * if (error instanceof WorkspaceJobError && error.type === "not-found") {
101
+ * console.warn(`No background command ${error.jobId}`);
102
+ * }
103
+ */
104
+ var WorkspaceJobError = class extends _warlock_js_ai.AIError {
105
+ constructor(message, options) {
106
+ super("TOOL_EXEC_FAILED", message, options);
107
+ this.name = "WorkspaceJobError";
108
+ this.type = options.type;
109
+ this.jobId = options.jobId;
110
+ }
111
+ };
112
+
113
+ //#endregion
114
+ //#region ../../packages/ai-workspace/src/policy/jail.ts
115
+ /**
116
+ * Whether `child` is contained within `root` (or equals it), comparing
117
+ * absolute paths. Guards against the `/srv/app-evil` vs `/srv/app`
118
+ * prefix-collision by comparing path segments, not string prefixes.
119
+ */
120
+ function isInside(child, root) {
121
+ const relative = node_path.default.relative(root, child);
122
+ return relative === "" || !relative.startsWith("..") && !node_path.default.isAbsolute(relative);
123
+ }
124
+ /**
125
+ * Translate a glob (the small subset used by `denyPaths` and
126
+ * `list.ignore` — `*`, `**`, `?`) into an anchored `RegExp` over a
127
+ * `/`-separated relative path. `**` spans path separators; a single `*`
128
+ * does not.
129
+ */
130
+ function globToRegExp(glob) {
131
+ let source = "";
132
+ for (let index = 0; index < glob.length; index++) {
133
+ const char = glob.charAt(index);
134
+ if (char === "*") {
135
+ if (glob.charAt(index + 1) === "*") {
136
+ source += ".*";
137
+ index++;
138
+ if (glob.charAt(index + 1) === "/") index++;
139
+ } else source += "[^/]*";
140
+ continue;
141
+ }
142
+ if (char === "?") {
143
+ source += "[^/]";
144
+ continue;
145
+ }
146
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
147
+ }
148
+ return new RegExp(`^${source}$`);
149
+ }
150
+ /**
151
+ * Whether a workspace-relative (`/`-separated) path matches any of the
152
+ * globs. A glob also covers everything beneath a matched directory
153
+ * (`".git/**"` covers `.git/config`), and a bare name without wildcards
154
+ * (`".git"`, `"node_modules"`) covers its contents.
155
+ */
156
+ function matchesAnyGlob(relativePath, globs) {
157
+ return globs.some((glob) => {
158
+ if (globToRegExp(glob).test(relativePath)) return true;
159
+ if (!glob.includes("*") && !glob.includes("?")) {
160
+ const prefix = glob.endsWith("/") ? glob : `${glob}/`;
161
+ return relativePath.startsWith(prefix);
162
+ }
163
+ return false;
164
+ });
165
+ }
166
+ /**
167
+ * The jail check without touching the filesystem: is the absolute path
168
+ * `target` (already resolved lexically) inside `cwd` and off every
169
+ * `denyPaths` glob, or inside an `allowPaths` root? Symlinks are NOT
170
+ * resolved here — the shell policy uses it to refuse obvious escapes
171
+ * synchronously, and `exec` re-checks every path with `resolveInJail`.
172
+ */
173
+ function isInsideJailLexically(policy, target) {
174
+ const root = node_path.default.resolve(policy.cwd);
175
+ if (isInside(target, root)) {
176
+ const relative = node_path.default.relative(root, target).split(node_path.default.sep).join("/");
177
+ return !(policy.denyPaths && policy.denyPaths.length > 0 && matchesAnyGlob(relative, policy.denyPaths));
178
+ }
179
+ return (policy.allowPaths ?? []).some((allowRoot) => isInside(target, node_path.default.resolve(allowRoot)));
180
+ }
181
+
182
+ //#endregion
183
+ //#region ../../packages/ai-workspace/src/policy/shell-parser.ts
184
+ /** Deepest nesting of subshells and substitutions the parser accepts. */
185
+ const MAX_NESTING = 16;
186
+ /**
187
+ * Words that start a compound command (or negate a pipeline) when they
188
+ * appear unquoted in command position. The parser does not read compound
189
+ * commands, so a line using one is refused.
190
+ */
191
+ const RESERVED_WORDS = /* @__PURE__ */ new Set([
192
+ "!",
193
+ "{",
194
+ "}",
195
+ "[[",
196
+ "]]",
197
+ "case",
198
+ "coproc",
199
+ "do",
200
+ "done",
201
+ "elif",
202
+ "else",
203
+ "esac",
204
+ "fi",
205
+ "for",
206
+ "function",
207
+ "if",
208
+ "in",
209
+ "select",
210
+ "then",
211
+ "until",
212
+ "while"
213
+ ]);
214
+ /** cmd.exe keywords the `"cmd"` dialect refuses in command position. */
215
+ const CMD_RESERVED_WORDS = /* @__PURE__ */ new Set([
216
+ "if",
217
+ "for",
218
+ "goto",
219
+ "setlocal",
220
+ "endlocal"
221
+ ]);
222
+ /** A parameter name inside `${…}`: a variable, a positional or a special parameter. */
223
+ const PARAMETER_NAME = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[@*#?$!-])`;
224
+ /**
225
+ * Allowed `${…}` forms: `${name}`, `${#name}`, `${name-word}`,
226
+ * `${name:-word}` (also `=`, `+`, `?`), `${name%word}`, `${name%%word}`,
227
+ * `${name#word}`, `${name##word}`. Everything else — substrings and
228
+ * subscripts (arithmetic in bash), `${!name}`, `${name@op}`, pattern
229
+ * substitution — is refused.
230
+ */
231
+ const PARAMETER_EXPANSION = new RegExp(`^(?:#${PARAMETER_NAME}|${PARAMETER_NAME}(?:(?::?[-=+?]|%%?|##?)[^\`\$'"\\\\{}()@\\n]*)?)$`);
232
+ /** A refusal raised while parsing; caught by {@link parseShellLine}. */
233
+ var ShellSyntaxRefusal = class extends Error {
234
+ constructor(reason, message) {
235
+ super(message);
236
+ this.reason = reason;
237
+ }
238
+ };
239
+ /** Refuse a construct the parser does not read with confidence. */
240
+ function unsupported(message) {
241
+ return new ShellSyntaxRefusal("unsupported-syntax", message);
242
+ }
243
+ /** Refuse an unterminated quote / substitution. */
244
+ function unbalanced(message) {
245
+ return new ShellSyntaxRefusal("unbalanced-quotes", message);
246
+ }
247
+ /** Parser for the POSIX `sh` dialect. */
248
+ var PosixParser = class PosixParser {
249
+ constructor(src, state) {
250
+ this.src = src;
251
+ this.state = state;
252
+ this.pos = 0;
253
+ this.pendingHeredocs = [];
254
+ }
255
+ /** Parse the whole line as a command list. */
256
+ parseProgram(segments) {
257
+ this.parseList(null, segments);
258
+ this.assertNoPendingHeredocs();
259
+ }
260
+ /** Parse the contents of a backtick substitution (a full list until EOF). */
261
+ parseNestedProgram() {
262
+ this.parseList(null, void 0);
263
+ this.assertNoPendingHeredocs();
264
+ }
265
+ /**
266
+ * Scan an unquoted-delimiter here-document body: `$…`, `${…}`, `$(…)` and
267
+ * backticks expand there exactly as inside double quotes (but `"` is an
268
+ * ordinary character), so any command substitution in it runs.
269
+ */
270
+ parseExpandingText() {
271
+ while (!this.atEnd()) {
272
+ const char = this.peek();
273
+ if (char === "\\") {
274
+ this.pos += 2;
275
+ continue;
276
+ }
277
+ if (char === "$") {
278
+ this.parseDollar("heredoc");
279
+ continue;
280
+ }
281
+ if (char === "`") {
282
+ this.parseBackticks(false);
283
+ continue;
284
+ }
285
+ this.pos++;
286
+ }
287
+ }
288
+ atEnd() {
289
+ return this.pos >= this.src.length;
290
+ }
291
+ peek(offset = 0) {
292
+ return this.src.charAt(this.pos + offset);
293
+ }
294
+ assertNoPendingHeredocs() {
295
+ if (this.pendingHeredocs.length > 0) throw unsupported(`The here-document delimited by \`${this.pendingHeredocs[0]?.delimiter ?? ""}\` is never terminated.`);
296
+ }
297
+ enterNesting() {
298
+ this.state.nesting++;
299
+ if (this.state.nesting > MAX_NESTING) throw unsupported("The line nests subshells or substitutions too deeply.");
300
+ }
301
+ exitNesting() {
302
+ this.state.nesting--;
303
+ }
304
+ /** Skip spaces, tabs and backslash-newline continuations. */
305
+ skipBlanks() {
306
+ while (!this.atEnd()) {
307
+ const char = this.peek();
308
+ if (char === " " || char === " ") {
309
+ this.pos++;
310
+ continue;
311
+ }
312
+ if (char === "\\" && this.peek(1) === "\n") {
313
+ this.pos += 2;
314
+ continue;
315
+ }
316
+ break;
317
+ }
318
+ }
319
+ /** Skip a `#` comment up to (not including) the newline. */
320
+ skipComment() {
321
+ const newline = this.src.indexOf("\n", this.pos);
322
+ this.pos = newline === -1 ? this.src.length : newline;
323
+ }
324
+ /** Consume one newline and read any here-document bodies queued before it. */
325
+ consumeNewline() {
326
+ this.pos++;
327
+ if (this.pendingHeredocs.length === 0) return;
328
+ if (this.state.nesting > 0) throw unsupported("Here-documents inside subshells or substitutions are not supported.");
329
+ const pending = this.pendingHeredocs;
330
+ this.pendingHeredocs = [];
331
+ for (const heredoc of pending) this.readHeredocBody(heredoc);
332
+ }
333
+ /** Skip blanks, comments and newlines (separators between commands). */
334
+ skipBlanksAndNewlines() {
335
+ while (!this.atEnd()) {
336
+ this.skipBlanks();
337
+ if (this.peek() === "#") {
338
+ this.skipComment();
339
+ continue;
340
+ }
341
+ if (this.peek() === "\n") {
342
+ this.consumeNewline();
343
+ continue;
344
+ }
345
+ break;
346
+ }
347
+ }
348
+ readHeredocBody(heredoc) {
349
+ const bodyLines = [];
350
+ while (true) {
351
+ if (this.atEnd()) throw unsupported(`The here-document delimited by \`${heredoc.delimiter}\` is never terminated.`);
352
+ const lineEnd = this.src.indexOf("\n", this.pos);
353
+ const line = lineEnd === -1 ? this.src.slice(this.pos) : this.src.slice(this.pos, lineEnd);
354
+ const compared = heredoc.stripTabs ? line.replace(/^\t+/, "") : line;
355
+ this.pos = lineEnd === -1 ? this.src.length : lineEnd + 1;
356
+ if (compared === heredoc.delimiter) break;
357
+ bodyLines.push(compared);
358
+ }
359
+ if (heredoc.expand) new PosixParser(bodyLines.join("\n"), this.state).parseExpandingText();
360
+ }
361
+ /**
362
+ * Parse a command list until `closing` (`")"`) or the end of the input.
363
+ * `segments`, when given, collects the top-level pipelines.
364
+ */
365
+ parseList(closing, segments) {
366
+ let sawCommand = false;
367
+ while (true) {
368
+ this.skipBlanksAndNewlines();
369
+ if (this.atEnd()) {
370
+ if (closing !== null) throw unbalanced(`A \`(\` or \`$(\` is never closed with \`${closing}\`.`);
371
+ break;
372
+ }
373
+ if (closing !== null && this.peek() === closing) break;
374
+ const start = this.pos;
375
+ this.parsePipeline(closing);
376
+ const end = this.pos;
377
+ sawCommand = true;
378
+ this.skipBlanks();
379
+ if (this.peek() === "#") this.skipComment();
380
+ const operator = this.readListOperator();
381
+ segments?.push({
382
+ source: this.src.slice(start, end).trim(),
383
+ ...operator !== null ? { operator } : {}
384
+ });
385
+ if (operator === "&&" || operator === "||") {
386
+ this.skipBlanksAndNewlines();
387
+ if (this.atEnd() || closing !== null && this.peek() === closing) throw unsupported(`The line ends with \`${operator}\` but no command follows it.`);
388
+ continue;
389
+ }
390
+ if (operator === "&") this.state.background = true;
391
+ if (operator === null) {
392
+ if (this.atEnd() || closing !== null && this.peek() === closing) break;
393
+ throw unsupported(`Unexpected \`${this.peek()}\`.`);
394
+ }
395
+ }
396
+ if (!sawCommand) throw unsupported(closing === null ? "The command is empty." : "A `( )` or `$( )` is empty.");
397
+ }
398
+ /** Read `&&`, `||`, `;`, `&` or a newline after a pipeline. */
399
+ readListOperator() {
400
+ const char = this.peek();
401
+ const next = this.peek(1);
402
+ if (char === "&") {
403
+ if (next === "&") {
404
+ this.pos += 2;
405
+ return "&&";
406
+ }
407
+ if (next === ">") throw unsupported("`&>` (redirect both streams) is not portable; write `> file 2>&1` instead.");
408
+ this.pos++;
409
+ return "&";
410
+ }
411
+ if (char === "|" && next === "|") {
412
+ this.pos += 2;
413
+ return "||";
414
+ }
415
+ if (char === ";") {
416
+ if (next === ";") throw unsupported("`;;` (a `case` clause) is not supported.");
417
+ this.pos++;
418
+ return ";";
419
+ }
420
+ if (char === "\n") {
421
+ this.consumeNewline();
422
+ return "\n";
423
+ }
424
+ return null;
425
+ }
426
+ parsePipeline(closing) {
427
+ this.parseCommand(closing);
428
+ while (true) {
429
+ this.skipBlanks();
430
+ if (this.peek() !== "|" || this.peek(1) === "|") return;
431
+ if (this.peek(1) === "&") throw unsupported("`|&` is not portable; write `2>&1 |` instead.");
432
+ this.pos++;
433
+ this.skipBlanksAndNewlines();
434
+ if (this.atEnd() || closing !== null && this.peek() === closing) throw unsupported("The line ends with `|` but no command follows it.");
435
+ this.parseCommand(closing);
436
+ }
437
+ }
438
+ parseCommand(closing) {
439
+ this.skipBlanks();
440
+ if (this.peek() === "(") {
441
+ if (this.peek(1) === "(") throw unsupported("`((…))` (arithmetic) is not supported.");
442
+ this.parseSubshell();
443
+ return;
444
+ }
445
+ if (this.peek() === ")") throw unsupported(closing === null ? "Unexpected `)`." : "A `( )` or `$( )` is empty.");
446
+ this.parseSimpleCommand();
447
+ }
448
+ parseSubshell() {
449
+ const start = this.pos;
450
+ this.pos++;
451
+ this.enterNesting();
452
+ this.parseList(")", void 0);
453
+ if (this.peek() !== ")") throw unbalanced("A `(` is never closed with `)`.");
454
+ this.pos++;
455
+ this.exitNesting();
456
+ const redirects = [];
457
+ while (true) {
458
+ this.skipBlanks();
459
+ if (!this.atRedirectStart()) break;
460
+ redirects.push(this.parseRedirect());
461
+ }
462
+ if (redirects.length > 0) this.state.commands.push({
463
+ assignments: [],
464
+ words: [],
465
+ redirects,
466
+ source: this.src.slice(start, this.pos).trim()
467
+ });
468
+ if (!this.atEnd() && !this.atOperatorBoundary()) throw unsupported("Only an operator or a redirection may follow `)`.");
469
+ }
470
+ /** Whether the next character ends a simple command. */
471
+ atOperatorBoundary() {
472
+ const char = this.peek();
473
+ return char === "\n" || char === ";" || char === "&" || char === "|" || char === ")" || char === "#" || char === " " || char === " ";
474
+ }
475
+ /** Whether a redirection starts here (`>`, `<`, or digits glued to one). */
476
+ atRedirectStart() {
477
+ let index = this.pos;
478
+ while (index < this.src.length && /[0-9]/.test(this.src.charAt(index))) index++;
479
+ const char = this.src.charAt(index);
480
+ return char === "<" || char === ">";
481
+ }
482
+ parseSimpleCommand() {
483
+ const start = this.pos;
484
+ const command = {
485
+ assignments: [],
486
+ words: [],
487
+ redirects: [],
488
+ source: ""
489
+ };
490
+ while (true) {
491
+ this.skipBlanks();
492
+ if (this.atEnd()) break;
493
+ const char = this.peek();
494
+ if (char === "\n" || char === ";" || char === "&" || char === "|" || char === ")") break;
495
+ if (char === "(") throw unsupported("`(` is only supported at the start of a command (a subshell); quote it to pass it as text.");
496
+ if (char === "#") {
497
+ this.skipComment();
498
+ break;
499
+ }
500
+ if (this.atRedirectStart()) {
501
+ command.redirects.push(this.parseRedirect());
502
+ continue;
503
+ }
504
+ const word = this.parseWord();
505
+ if (command.words.length === 0 && word.assignmentName !== void 0) {
506
+ command.assignments.push(word);
507
+ continue;
508
+ }
509
+ command.words.push(word);
510
+ if (command.words.length === 1 && !word.quoted && RESERVED_WORDS.has(word.value)) throw unsupported(`\`${word.value}\` starts a shell compound command, which is not supported; run the commands one at a time.`);
511
+ }
512
+ if (command.words.length === 0 && command.assignments.length === 0 && command.redirects.length === 0) throw unsupported("The line has an empty command (two operators in a row?).");
513
+ command.source = this.src.slice(start, this.pos).trim();
514
+ this.state.commands.push(command);
515
+ }
516
+ parseRedirect() {
517
+ let digits = "";
518
+ while (/[0-9]/.test(this.peek())) {
519
+ digits += this.peek();
520
+ this.pos++;
521
+ }
522
+ const rest = this.src.slice(this.pos, this.pos + 3);
523
+ let op;
524
+ if (rest.startsWith("<<<")) throw unsupported("`<<<` (a here-string) is not portable; use a here-document.");
525
+ else if (rest.startsWith("<<-")) op = "<<-";
526
+ else if (rest.startsWith("<<")) op = "<<";
527
+ else if (rest.startsWith("<>")) op = "<>";
528
+ else if (rest.startsWith("<&")) op = "<&";
529
+ else if (rest.startsWith("<(") || rest.startsWith(">(")) throw unsupported("Process substitution (`<(…)`, `>(…)`) is not supported.");
530
+ else if (rest.startsWith(">>")) op = ">>";
531
+ else if (rest.startsWith(">|")) op = ">|";
532
+ else if (rest.startsWith(">&")) op = ">&";
533
+ else op = this.peek();
534
+ this.pos += op.length;
535
+ const fd = digits === "" ? void 0 : Number(digits);
536
+ this.skipBlanks();
537
+ if (this.atEnd() || /[\n;&|<>()]/.test(this.peek())) throw unsupported(`The redirection \`${op}\` has no target.`);
538
+ const target = this.parseWord();
539
+ if (op === "<<" || op === "<<-") {
540
+ if (this.state.nesting > 0) throw unsupported("Here-documents inside subshells or substitutions are not supported.");
541
+ if (target.dynamic) throw unsupported("A here-document delimiter must be literal text.");
542
+ this.pendingHeredocs.push({
543
+ delimiter: target.value,
544
+ stripTabs: op === "<<-",
545
+ expand: !target.quoted
546
+ });
547
+ return {
548
+ op,
549
+ ...fd !== void 0 ? { fd } : {},
550
+ target,
551
+ type: "heredoc"
552
+ };
553
+ }
554
+ if (op === ">&" || op === "<&") {
555
+ if (target.dynamic || !/^(?:[0-9]+|-)$/.test(target.value)) throw unsupported(`\`${op}${target.raw}\` is not portable; write \`> file 2>&1\` to send both streams to a file.`);
556
+ return {
557
+ op,
558
+ ...fd !== void 0 ? { fd } : {},
559
+ target,
560
+ type: "dup"
561
+ };
562
+ }
563
+ return {
564
+ op,
565
+ ...fd !== void 0 ? { fd } : {},
566
+ target,
567
+ type: op === "<" ? "read" : "write"
568
+ };
569
+ }
570
+ /** Parse one unquoted-context word. */
571
+ parseWord() {
572
+ const start = this.pos;
573
+ let value = "";
574
+ let dynamic = false;
575
+ let quoted = false;
576
+ let braces = false;
577
+ let nameCandidate = true;
578
+ let assignmentName;
579
+ while (!this.atEnd()) {
580
+ const char = this.peek();
581
+ if (char === " " || char === " " || char === "\n" || char === ";" || char === "&" || char === "|" || char === "<" || char === ">" || char === "(" || char === ")") break;
582
+ if (char === "\r" || char === "\0") throw unsupported("Carriage returns and NUL bytes are not supported in a command line.");
583
+ if (char === "\\") {
584
+ const next = this.peek(1);
585
+ if (next === "") throw unsupported("The line ends with a backslash.");
586
+ this.pos += 2;
587
+ if (next !== "\n") {
588
+ value += next;
589
+ quoted = true;
590
+ nameCandidate = false;
591
+ }
592
+ continue;
593
+ }
594
+ if (char === "'") {
595
+ const closing = this.src.indexOf("'", this.pos + 1);
596
+ if (closing === -1) throw unbalanced("A `'` quote is never closed.");
597
+ value += this.src.slice(this.pos + 1, closing);
598
+ quoted = true;
599
+ nameCandidate = false;
600
+ this.pos = closing + 1;
601
+ continue;
602
+ }
603
+ if (char === "\"") {
604
+ const part = this.parseDoubleQuoted();
605
+ value += part.value;
606
+ dynamic = dynamic || part.dynamic;
607
+ quoted = true;
608
+ nameCandidate = false;
609
+ continue;
610
+ }
611
+ if (char === "$") {
612
+ const part = this.parseDollar("unquoted");
613
+ value += part.text;
614
+ dynamic = dynamic || part.dynamic;
615
+ nameCandidate = false;
616
+ continue;
617
+ }
618
+ if (char === "`") {
619
+ value += this.parseBackticks(false);
620
+ dynamic = true;
621
+ nameCandidate = false;
622
+ continue;
623
+ }
624
+ if (char === "=" && nameCandidate && assignmentName === void 0) {
625
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) assignmentName = value;
626
+ nameCandidate = false;
627
+ } else if (!/[A-Za-z0-9_]/.test(char)) nameCandidate = false;
628
+ if (char === "*" || char === "?" || char === "[") dynamic = true;
629
+ if (char === "~") {
630
+ const previous = this.src.charAt(this.pos - 1);
631
+ if (this.pos === start || assignmentName !== void 0 && (previous === "=" || previous === ":")) dynamic = true;
632
+ }
633
+ if (char === "{" || char === "}") braces = true;
634
+ value += char;
635
+ this.pos++;
636
+ }
637
+ const raw = this.src.slice(start, this.pos);
638
+ if (braces && raw !== "{}") throw unsupported(`Unquoted \`{\` or \`}\` in \`${raw}\` (brace expansion or a command group) is not supported; quote it.`);
639
+ if (raw === "[" || raw === "]") dynamic = false;
640
+ return {
641
+ raw,
642
+ value,
643
+ dynamic,
644
+ quoted,
645
+ ...assignmentName !== void 0 ? { assignmentName } : {}
646
+ };
647
+ }
648
+ /** Parse a `"…"` span (cursor on the opening quote). */
649
+ parseDoubleQuoted() {
650
+ this.pos++;
651
+ let value = "";
652
+ let dynamic = false;
653
+ while (true) {
654
+ if (this.atEnd()) throw unbalanced("A `\"` quote is never closed.");
655
+ const char = this.peek();
656
+ if (char === "\"") {
657
+ this.pos++;
658
+ return {
659
+ value,
660
+ dynamic
661
+ };
662
+ }
663
+ if (char === "\0") throw unsupported("NUL bytes are not supported in a command line.");
664
+ if (char === "\\") {
665
+ const next = this.peek(1);
666
+ if (next === "") throw unbalanced("A `\"` quote is never closed.");
667
+ if (next === "$" || next === "`" || next === "\"" || next === "\\") {
668
+ value += next;
669
+ this.pos += 2;
670
+ continue;
671
+ }
672
+ if (next === "\n") {
673
+ this.pos += 2;
674
+ continue;
675
+ }
676
+ value += char;
677
+ this.pos++;
678
+ continue;
679
+ }
680
+ if (char === "$") {
681
+ const part = this.parseDollar("dquote");
682
+ value += part.text;
683
+ dynamic = dynamic || part.dynamic;
684
+ continue;
685
+ }
686
+ if (char === "`") {
687
+ value += this.parseBackticks(true);
688
+ dynamic = true;
689
+ continue;
690
+ }
691
+ value += char;
692
+ this.pos++;
693
+ }
694
+ }
695
+ /** Parse a `$…` expansion (cursor on the `$`). */
696
+ parseDollar(context) {
697
+ const start = this.pos;
698
+ const next = this.peek(1);
699
+ if (next === "(") {
700
+ if (this.peek(2) === "(") throw unsupported("`$((…))` (arithmetic expansion) is not supported.");
701
+ this.pos += 2;
702
+ this.enterNesting();
703
+ this.parseList(")", void 0);
704
+ if (this.peek() !== ")") throw unbalanced("A `$(` is never closed with `)`.");
705
+ this.pos++;
706
+ this.exitNesting();
707
+ return {
708
+ text: this.src.slice(start, this.pos),
709
+ dynamic: true
710
+ };
711
+ }
712
+ if (next === "{") {
713
+ const closing = this.src.indexOf("}", this.pos + 2);
714
+ if (closing === -1) throw unbalanced("A `${` is never closed with `}`.");
715
+ const body = this.src.slice(this.pos + 2, closing);
716
+ if (!PARAMETER_EXPANSION.test(body)) throw unsupported(`\`\${${body}}\` is not a supported parameter expansion; use \`$name\`, \`\${name}\` or \`\${name:-default}\`.`);
717
+ this.pos = closing + 1;
718
+ return {
719
+ text: this.src.slice(start, this.pos),
720
+ dynamic: true
721
+ };
722
+ }
723
+ if (next === "[") throw unsupported("`$[…]` (arithmetic expansion) is not supported.");
724
+ if (context === "unquoted" && (next === "'" || next === "\"")) throw unsupported(`\`$${next}…${next}\` quoting is not portable; use plain quotes.`);
725
+ if (/[A-Za-z_]/.test(next)) {
726
+ this.pos += 2;
727
+ while (/[A-Za-z0-9_]/.test(this.peek())) this.pos++;
728
+ return {
729
+ text: this.src.slice(start, this.pos),
730
+ dynamic: true
731
+ };
732
+ }
733
+ if (/[0-9@*#?$!-]/.test(next) && next !== "") {
734
+ this.pos += 2;
735
+ return {
736
+ text: this.src.slice(start, this.pos),
737
+ dynamic: true
738
+ };
739
+ }
740
+ this.pos++;
741
+ return {
742
+ text: "$",
743
+ dynamic: false
744
+ };
745
+ }
746
+ /** Parse a backtick substitution (cursor on the opening backtick); returns its source. */
747
+ parseBackticks(insideDoubleQuotes) {
748
+ const start = this.pos;
749
+ let content = "";
750
+ this.pos++;
751
+ while (true) {
752
+ if (this.atEnd()) throw unbalanced("A backtick substitution is never closed.");
753
+ const char = this.peek();
754
+ if (char === "`") {
755
+ this.pos++;
756
+ break;
757
+ }
758
+ if (char === "\\") {
759
+ const next = this.peek(1);
760
+ if (next === "") throw unbalanced("A backtick substitution is never closed.");
761
+ if (next === "`" || next === "\\" || next === "$") {
762
+ content += next;
763
+ this.pos += 2;
764
+ continue;
765
+ }
766
+ if (next === "\"" && insideDoubleQuotes) throw unsupported("An escaped `\\\"` inside backticks inside double quotes is not portable; use `$(…)`.");
767
+ content += char + next;
768
+ this.pos += 2;
769
+ continue;
770
+ }
771
+ content += char;
772
+ this.pos++;
773
+ }
774
+ this.enterNesting();
775
+ new PosixParser(content, this.state).parseNestedProgram();
776
+ this.exitNesting();
777
+ return this.src.slice(start, this.pos);
778
+ }
779
+ };
780
+ /** Parser for the restricted `cmd.exe` dialect. */
781
+ var CmdParser = class {
782
+ constructor(src, commands) {
783
+ this.src = src;
784
+ this.commands = commands;
785
+ this.pos = 0;
786
+ }
787
+ parseProgram(segments) {
788
+ if (/[\r\n\0]/.test(this.src)) throw unsupported("Line breaks and NUL bytes are not supported in a cmd.exe command line.");
789
+ while (true) {
790
+ this.skipBlanks();
791
+ if (this.atEnd()) throw unsupported(segments.length === 0 ? "The command is empty." : "The line ends with an operator but no command follows it.");
792
+ const start = this.pos;
793
+ this.parsePipeline();
794
+ const end = this.pos;
795
+ this.skipBlanks();
796
+ const operator = this.readListOperator();
797
+ segments.push({
798
+ source: this.src.slice(start, end).trim(),
799
+ ...operator !== null ? { operator } : {}
800
+ });
801
+ if (operator === null) {
802
+ if (!this.atEnd()) throw unsupported(`Unexpected \`${this.peek()}\`.`);
803
+ return;
804
+ }
805
+ }
806
+ }
807
+ atEnd() {
808
+ return this.pos >= this.src.length;
809
+ }
810
+ peek(offset = 0) {
811
+ return this.src.charAt(this.pos + offset);
812
+ }
813
+ skipBlanks() {
814
+ while (this.peek() === " " || this.peek() === " ") this.pos++;
815
+ }
816
+ readListOperator() {
817
+ if (this.peek() === "&") {
818
+ if (this.peek(1) === "&") {
819
+ this.pos += 2;
820
+ return "&&";
821
+ }
822
+ this.pos++;
823
+ return "&";
824
+ }
825
+ if (this.peek() === "|" && this.peek(1) === "|") {
826
+ this.pos += 2;
827
+ return "||";
828
+ }
829
+ return null;
830
+ }
831
+ parsePipeline() {
832
+ this.parseSimpleCommand();
833
+ while (this.peek() === "|" && this.peek(1) !== "|") {
834
+ this.pos++;
835
+ this.skipBlanks();
836
+ if (this.atEnd()) throw unsupported("The line ends with `|` but no command follows it.");
837
+ this.parseSimpleCommand();
838
+ }
839
+ }
840
+ atRedirectStart() {
841
+ const char = this.peek();
842
+ if (char === "<" || char === ">") return true;
843
+ return /[0-9]/.test(char) && (this.peek(1) === ">" || this.peek(1) === "<");
844
+ }
845
+ parseSimpleCommand() {
846
+ const start = this.pos;
847
+ const command = {
848
+ assignments: [],
849
+ words: [],
850
+ redirects: [],
851
+ source: ""
852
+ };
853
+ while (true) {
854
+ this.skipBlanks();
855
+ if (this.atEnd() || this.peek() === "&" || this.peek() === "|") break;
856
+ if (this.atRedirectStart()) {
857
+ command.redirects.push(this.parseRedirect());
858
+ continue;
859
+ }
860
+ const word = this.parseWord();
861
+ command.words.push(word);
862
+ if (command.words.length === 1) this.checkCommandWord(word);
863
+ }
864
+ if (command.words.length === 0 && command.redirects.length === 0) throw unsupported("The line has an empty command (two operators in a row?).");
865
+ command.source = this.src.slice(start, this.pos).trim();
866
+ this.commands.push(command);
867
+ }
868
+ checkCommandWord(word) {
869
+ if (word.raw.startsWith("@") || word.raw.startsWith(":")) throw unsupported(`\`${word.raw}\` is not supported as a cmd.exe command name.`);
870
+ if (/[/=]/.test(word.value)) throw unsupported(`A cmd.exe command name may not contain \`/\` or \`=\` (\`${word.value}\`); use \`\\\` in paths.`);
871
+ if (!word.quoted && CMD_RESERVED_WORDS.has(word.value.toLowerCase())) throw unsupported(`\`${word.value}\` starts a cmd.exe compound command, which is not supported.`);
872
+ }
873
+ parseRedirect() {
874
+ let fd;
875
+ if (/[0-9]/.test(this.peek())) {
876
+ fd = Number(this.peek());
877
+ this.pos++;
878
+ }
879
+ let op;
880
+ if (this.src.startsWith(">>", this.pos)) op = ">>";
881
+ else if (this.src.startsWith(">&", this.pos)) op = ">&";
882
+ else if (this.src.startsWith("<&", this.pos) || this.src.startsWith("<<", this.pos)) throw unsupported(`\`${this.src.slice(this.pos, this.pos + 2)}\` is not supported by cmd.exe.`);
883
+ else op = this.peek();
884
+ this.pos += op.length;
885
+ this.skipBlanks();
886
+ if (this.atEnd() || /[&|<>]/.test(this.peek())) throw unsupported(`The redirection \`${op}\` has no target.`);
887
+ const target = this.parseWord();
888
+ if (op === ">&") {
889
+ if (!/^[0-9]$/.test(target.value)) throw unsupported(`\`>&${target.raw}\` is not supported; write \`> file 2>&1\`.`);
890
+ return {
891
+ op,
892
+ ...fd !== void 0 ? { fd } : {},
893
+ target,
894
+ type: "dup"
895
+ };
896
+ }
897
+ return {
898
+ op,
899
+ ...fd !== void 0 ? { fd } : {},
900
+ target,
901
+ type: op === "<" ? "read" : "write"
902
+ };
903
+ }
904
+ parseWord() {
905
+ const start = this.pos;
906
+ let value = "";
907
+ let dynamic = false;
908
+ let quoted = false;
909
+ while (!this.atEnd()) {
910
+ const char = this.peek();
911
+ if (char === " " || char === " " || char === "&" || char === "|" || char === "<" || char === ">") break;
912
+ if (char === "\"") {
913
+ const closing = this.src.indexOf("\"", this.pos + 1);
914
+ if (closing === -1) throw unbalanced("A `\"` quote is never closed.");
915
+ const inner = this.src.slice(this.pos + 1, closing);
916
+ if (/[%!]/.test(inner)) throw unsupported("`%` and `!` expand even inside quotes in cmd.exe; they are not supported.");
917
+ if (value.endsWith("\\") || inner.endsWith("\\")) throw unsupported("A backslash before a `\"` is read differently by cmd.exe and programs.");
918
+ value += inner;
919
+ quoted = true;
920
+ this.pos = closing + 1;
921
+ continue;
922
+ }
923
+ if (char === "%" || char === "!" || char === "^" || char === "(" || char === ")" || char === ";" || char === ",") throw unsupported(`Unquoted \`${char}\` has a special meaning in cmd.exe and is not supported.`);
924
+ if (char === "\\" && this.peek(1) === "\"") throw unsupported("A backslash before a `\"` is read differently by cmd.exe and programs.");
925
+ if (char === "*" || char === "?") dynamic = true;
926
+ value += char;
927
+ this.pos++;
928
+ }
929
+ return {
930
+ raw: this.src.slice(start, this.pos),
931
+ value,
932
+ dynamic,
933
+ quoted
934
+ };
935
+ }
936
+ };
937
+ /**
938
+ * Parse a command line for the shell policy. Never throws: a construct the
939
+ * parser does not read with confidence comes back as `{ ok: false }` with a
940
+ * message naming it.
941
+ *
942
+ * @example
943
+ * const parsed = parseShellLine("git add -A && git commit -m 'wip'", "posix");
944
+ * // parsed.ok === true; parsed.commands.map((c) => c.words.map((w) => w.value))
945
+ * // → [["git", "add", "-A"], ["git", "commit", "-m", "wip"]]
946
+ */
947
+ function parseShellLine(line, dialect) {
948
+ const segments = [];
949
+ try {
950
+ if (dialect === "cmd") {
951
+ const commands = [];
952
+ new CmdParser(line, commands).parseProgram(segments);
953
+ return {
954
+ ok: true,
955
+ commands,
956
+ segments,
957
+ background: false
958
+ };
959
+ }
960
+ const state = {
961
+ commands: [],
962
+ background: false,
963
+ nesting: 0
964
+ };
965
+ new PosixParser(line, state).parseProgram(segments);
966
+ return {
967
+ ok: true,
968
+ commands: state.commands,
969
+ segments,
970
+ background: state.background
971
+ };
972
+ } catch (error) {
973
+ if (error instanceof ShellSyntaxRefusal) return {
974
+ ok: false,
975
+ reason: error.reason,
976
+ message: error.message
977
+ };
978
+ throw error;
979
+ }
980
+ }
90
981
 
91
982
  //#endregion
92
983
  //#region ../../packages/ai-workspace/src/policy/tokenize-command.ts
@@ -99,7 +990,7 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
99
990
  * rejected instead of silently running commands past the allowlist. Inside
100
991
  * quotes they are ordinary literal bytes and pass through as argument data.
101
992
  */
102
- const UNQUOTED_METACHARACTERS = new Set([
993
+ const UNQUOTED_METACHARACTERS = /* @__PURE__ */ new Set([
103
994
  ";",
104
995
  "&",
105
996
  "|",
@@ -110,6 +1001,85 @@ const UNQUOTED_METACHARACTERS = new Set([
110
1001
  "(",
111
1002
  ")"
112
1003
  ]);
1004
+ /** Two-character operators reported as one token in a refusal. */
1005
+ const TWO_CHARACTER_OPERATORS = /* @__PURE__ */ new Set([
1006
+ "&&",
1007
+ "||",
1008
+ ">>",
1009
+ "<<",
1010
+ "$(",
1011
+ "${",
1012
+ ">&",
1013
+ "<&"
1014
+ ]);
1015
+ /**
1016
+ * Tokenize a command line into an argv the way {@link tokenizeCommand}
1017
+ * does, but say **why** a line cannot be represented as one argv — which
1018
+ * quote is unbalanced, or which unquoted operator (`&&`, `|`, `>`, `$(`,
1019
+ * a newline, …) was found first. The shell policy uses it to phrase a
1020
+ * refusal that tells the model how to split the line.
1021
+ *
1022
+ * @example
1023
+ * analyzeArgvCommand("npm test"); // { ok: true, argv: ["npm", "test"] }
1024
+ * analyzeArgvCommand("git add -A && git commit"); // { ok: false, type: "metacharacter", token: "&&", index: 11 }
1025
+ */
1026
+ function analyzeArgvCommand(command) {
1027
+ const argv = [];
1028
+ let current = "";
1029
+ let inToken = false;
1030
+ let index = 0;
1031
+ while (index < command.length) {
1032
+ const char = command.charAt(index);
1033
+ if (char === "'" || char === "\"") {
1034
+ const closing = command.indexOf(char, index + 1);
1035
+ if (closing === -1) return {
1036
+ ok: false,
1037
+ type: "unbalanced-quotes",
1038
+ quote: char,
1039
+ index
1040
+ };
1041
+ current += command.slice(index + 1, closing);
1042
+ inToken = true;
1043
+ index = closing + 1;
1044
+ continue;
1045
+ }
1046
+ if (char === " " || char === " ") {
1047
+ if (inToken) {
1048
+ argv.push(current);
1049
+ current = "";
1050
+ inToken = false;
1051
+ }
1052
+ index++;
1053
+ continue;
1054
+ }
1055
+ if (char === "\n" || char === "\r") return {
1056
+ ok: false,
1057
+ type: "metacharacter",
1058
+ token: "\n",
1059
+ index
1060
+ };
1061
+ if (UNQUOTED_METACHARACTERS.has(char)) {
1062
+ const pair = command.slice(index, index + 2);
1063
+ return {
1064
+ ok: false,
1065
+ type: "metacharacter",
1066
+ token: TWO_CHARACTER_OPERATORS.has(pair) ? pair : char,
1067
+ index
1068
+ };
1069
+ }
1070
+ current += char;
1071
+ inToken = true;
1072
+ index++;
1073
+ }
1074
+ if (inToken) argv.push(current);
1075
+ return argv.length > 0 ? {
1076
+ ok: true,
1077
+ argv
1078
+ } : {
1079
+ ok: false,
1080
+ type: "empty"
1081
+ };
1082
+ }
113
1083
  /**
114
1084
  * Tokenize a command line into an argv array WITHOUT any shell semantics.
115
1085
  *
@@ -125,6 +1095,7 @@ const UNQUOTED_METACHARACTERS = new Set([
125
1095
  * shell metacharacter / newline (see {@link UNQUOTED_METACHARACTERS}). The
126
1096
  * policy gate treats `null` as denied and the local backend refuses to
127
1097
  * spawn it, which is what closes the `allowed_cmd; anything-else` injection.
1098
+ * {@link analyzeArgvCommand} says why a line was refused.
128
1099
  *
129
1100
  * @example
130
1101
  * tokenizeCommand('npm test'); // ["npm", "test"]
@@ -132,36 +1103,639 @@ const UNQUOTED_METACHARACTERS = new Set([
132
1103
  * tokenizeCommand('npm test; curl http://evil'); // null (unquoted `;`)
133
1104
  */
134
1105
  function tokenizeCommand(command) {
135
- const argv = [];
136
- let current = "";
137
- let inToken = false;
138
- let index = 0;
139
- while (index < command.length) {
140
- const char = command[index];
141
- if (char === "'" || char === "\"") {
142
- const closing = command.indexOf(char, index + 1);
143
- if (closing === -1) return null;
144
- current += command.slice(index + 1, closing);
145
- inToken = true;
146
- index = closing + 1;
1106
+ const analysis = analyzeArgvCommand(command);
1107
+ return analysis.ok ? analysis.argv : null;
1108
+ }
1109
+
1110
+ //#endregion
1111
+ //#region ../../packages/ai-workspace/src/policy/shell-check.ts
1112
+ /**
1113
+ * Programs and shell builtins that run a command or code the policy cannot
1114
+ * see (a script string, a file, a command read from input, another user).
1115
+ * With deny rules in force they would hide a denied command, so they are
1116
+ * refused unless the policy names them in `allow`.
1117
+ */
1118
+ const OPAQUE_RUNNERS = /* @__PURE__ */ new Set([
1119
+ "sh",
1120
+ "bash",
1121
+ "dash",
1122
+ "zsh",
1123
+ "ksh",
1124
+ "mksh",
1125
+ "ash",
1126
+ "yash",
1127
+ "posh",
1128
+ "fish",
1129
+ "csh",
1130
+ "tcsh",
1131
+ "busybox",
1132
+ "cmd",
1133
+ "powershell",
1134
+ "pwsh",
1135
+ "wsl",
1136
+ "sudo",
1137
+ "doas",
1138
+ "su",
1139
+ "runuser",
1140
+ "pkexec",
1141
+ "runas",
1142
+ "chroot",
1143
+ "nsenter",
1144
+ "unshare",
1145
+ "setsid",
1146
+ "systemd-run",
1147
+ "start-stop-daemon",
1148
+ "daemonize",
1149
+ "xargs",
1150
+ "parallel",
1151
+ "watch",
1152
+ "script",
1153
+ "expect",
1154
+ "at",
1155
+ "batch",
1156
+ "screen",
1157
+ "tmux",
1158
+ "stdbuf",
1159
+ "flock"
1160
+ ]);
1161
+ /** POSIX shell builtins that evaluate code or change how later names resolve. */
1162
+ const SHELL_OPAQUE_BUILTINS = /* @__PURE__ */ new Set([
1163
+ "eval",
1164
+ ".",
1165
+ "source",
1166
+ "alias",
1167
+ "unalias",
1168
+ "hash",
1169
+ "enable",
1170
+ "trap",
1171
+ "let",
1172
+ "declare",
1173
+ "typeset",
1174
+ "local",
1175
+ "set",
1176
+ "shopt",
1177
+ "fc",
1178
+ "bind",
1179
+ "complete",
1180
+ "compgen",
1181
+ "compopt",
1182
+ "mapfile",
1183
+ "readarray"
1184
+ ]);
1185
+ /** cmd.exe builtins that run commands or change the environment. */
1186
+ const CMD_OPAQUE_BUILTINS = /* @__PURE__ */ new Set([
1187
+ "call",
1188
+ "start",
1189
+ "set",
1190
+ "pushd",
1191
+ "popd"
1192
+ ]);
1193
+ /**
1194
+ * Shell builtins whose arguments bash may evaluate as arithmetic (array
1195
+ * subscripts such as `a[$(cmd)]`): their arguments must be literal and free
1196
+ * of `[`.
1197
+ */
1198
+ const ARITHMETIC_SENSITIVE_BUILTINS = /* @__PURE__ */ new Set([
1199
+ "test",
1200
+ "[",
1201
+ "printf",
1202
+ "read",
1203
+ "unset",
1204
+ "export",
1205
+ "readonly",
1206
+ "getopts",
1207
+ "wait",
1208
+ "kill",
1209
+ "shift",
1210
+ "return",
1211
+ "exit",
1212
+ "break",
1213
+ "continue",
1214
+ "ulimit"
1215
+ ]);
1216
+ /**
1217
+ * Variables a shell-mode line may not assign: they change how the shell
1218
+ * parses, traces or resolves what follows (`CDPATH` would move `cd` out of
1219
+ * the jail's sight, `PS4` is expanded — command substitutions included —
1220
+ * on every traced line, `IFS` re-splits expansions).
1221
+ */
1222
+ const SENSITIVE_VARIABLES = /* @__PURE__ */ new Set([
1223
+ "CDPATH",
1224
+ "ENV",
1225
+ "BASH_ENV",
1226
+ "SHELLOPTS",
1227
+ "BASHOPTS",
1228
+ "IFS",
1229
+ "PS1",
1230
+ "PS2",
1231
+ "PS3",
1232
+ "PS4",
1233
+ "PROMPT_COMMAND",
1234
+ "BASH_XTRACEFD",
1235
+ "GLOBIGNORE",
1236
+ "EXECIGNORE",
1237
+ "BASH_LOADABLES_PATH"
1238
+ ]);
1239
+ /** Redirection targets that are always fine even though they sit outside the jail. */
1240
+ const DEVICE_TARGETS = /* @__PURE__ */ new Set([
1241
+ "/dev/null",
1242
+ "/dev/stdout",
1243
+ "/dev/stderr"
1244
+ ]);
1245
+ /** cmd.exe's null device. */
1246
+ const CMD_DEVICE_TARGETS = /* @__PURE__ */ new Set(["nul"]);
1247
+ /** Deepest chain of wrappers (`env time nice …`) the policy unwraps. */
1248
+ const MAX_WRAPPER_DEPTH = 8;
1249
+ /** Most distinct directories a line may `cd` between. */
1250
+ const MAX_CD_CANDIDATES = 32;
1251
+ /** Longest command shown verbatim in a refusal before it is abbreviated. */
1252
+ const MAX_SHOWN_COMMAND = 48;
1253
+ /** The dialect shell mode parses on this platform. */
1254
+ function shellDialect() {
1255
+ return process.platform === "win32" ? "cmd" : "posix";
1256
+ }
1257
+ /**
1258
+ * Reduce an argv's first element to the basename the allow/deny policy is
1259
+ * keyed on. `"npm"` → `"npm"`; `"/usr/bin/node"` → `"node"`; `"node.exe"`
1260
+ * → `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
1261
+ * stripped). The cmd dialect splits Windows paths too.
1262
+ */
1263
+ function executableBasename(firstToken, dialect = "posix") {
1264
+ return (dialect === "cmd" ? node_path.default.win32.basename(firstToken) : node_path.default.basename(firstToken)).replace(/\.(exe|cmd|bat|com)$/i, "");
1265
+ }
1266
+ /** Show a command in a refusal, abbreviated at a word boundary when long. */
1267
+ function show(source) {
1268
+ const flat = source.replace(/\s+/g, " ").trim();
1269
+ if (flat.length <= MAX_SHOWN_COMMAND) return `\`${flat}\``;
1270
+ const cut = flat.lastIndexOf(" ", MAX_SHOWN_COMMAND);
1271
+ return `\`${flat.slice(0, cut > 0 ? cut : MAX_SHOWN_COMMAND)} …\``;
1272
+ }
1273
+ function compilePattern(pattern) {
1274
+ if (pattern instanceof RegExp) {
1275
+ const regex = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
1276
+ return {
1277
+ source: pattern,
1278
+ test: (argv, dialect) => regex.test([executableBasename(argv[0] ?? "", dialect), ...argv.slice(1)].join(" "))
1279
+ };
1280
+ }
1281
+ const tokens = tokenizeCommand(pattern);
1282
+ if (tokens === null) return {
1283
+ source: pattern,
1284
+ test: (argv, dialect) => [executableBasename(argv[0] ?? "", dialect), ...argv.slice(1)].join(" ").includes(pattern)
1285
+ };
1286
+ return {
1287
+ source: pattern,
1288
+ test: (argv, dialect) => {
1289
+ if (argv.length < tokens.length) return false;
1290
+ return tokens.every((token, index) => {
1291
+ const actual = argv[index] ?? "";
1292
+ return index === 0 ? executableBasename(actual, dialect).toLowerCase() === executableBasename(token, dialect).toLowerCase() : actual === token;
1293
+ });
1294
+ }
1295
+ };
1296
+ }
1297
+ /** Thrown inside the checker to stop at the first refusal. */
1298
+ var Refusal = class extends Error {
1299
+ constructor(reason, message, executable, refusedPath) {
1300
+ super(message);
1301
+ this.reason = reason;
1302
+ this.executable = executable;
1303
+ this.refusedPath = refusedPath;
1304
+ }
1305
+ };
1306
+ /** Per-line checker state. */
1307
+ var LineChecker = class {
1308
+ constructor(policy, shell, line, dialect) {
1309
+ this.policy = policy;
1310
+ this.shell = shell;
1311
+ this.line = line;
1312
+ this.dialect = dialect;
1313
+ this.commands = [];
1314
+ this.paths = [];
1315
+ this.deny = new Set((shell.deny ?? []).map((name) => name.toLowerCase()));
1316
+ this.allow = shell.allow ?? [];
1317
+ this.patterns = (shell.denyPatterns ?? []).map(compilePattern);
1318
+ this.interpreter = shell.interpreter ?? "none";
1319
+ this.hasCommandRules = this.deny.size > 0 || this.patterns.length > 0 || shell.allowCommand !== void 0;
1320
+ }
1321
+ /** Whether argument hooks (`denyPatterns` / `allowCommand`) need literal argv. */
1322
+ get hasArgumentHooks() {
1323
+ return this.patterns.length > 0 || this.shell.allowCommand !== void 0;
1324
+ }
1325
+ get shellMode() {
1326
+ return this.interpreter === "sh";
1327
+ }
1328
+ /** Run `denyPatterns` and `allowCommand` on one argv. */
1329
+ checkHooks(argv, nested) {
1330
+ const shown = show(argv.join(" "));
1331
+ for (const pattern of this.patterns) if (pattern.test(argv, this.dialect)) throw new Refusal("denied-pattern", `${shown} matches a command pattern the workspace shell policy denies.`, executableBasename(argv[0] ?? "", this.dialect));
1332
+ const hook = this.shell.allowCommand;
1333
+ if (hook === void 0) return;
1334
+ let verdict;
1335
+ try {
1336
+ verdict = hook([...argv], {
1337
+ command: this.line,
1338
+ interpreter: this.interpreter,
1339
+ nested
1340
+ });
1341
+ } catch {
1342
+ verdict = false;
1343
+ }
1344
+ if (verdict === true) return;
1345
+ const explanation = typeof verdict === "string" && verdict.trim() !== "" ? ` ${verdict.trim()}` : "";
1346
+ throw new Refusal("not-allowed", `${shown} was refused by the workspace shell policy.${explanation}`, executableBasename(argv[0] ?? "", this.dialect));
1347
+ }
1348
+ /** Whether the policy names `executable` in a non-empty `allow` list. */
1349
+ explicitlyAllowed(executable) {
1350
+ return this.allow.includes(executable);
1351
+ }
1352
+ /** Refuse a command runner the policy cannot see into, unless explicitly allowed. */
1353
+ checkOpaque(executable, argv) {
1354
+ if (this.explicitlyAllowed(executable) || !this.hasCommandRules) return;
1355
+ throw new Refusal("nested-command", `\`${executable}\` runs a command the workspace shell policy cannot inspect (in ${show(argv.join(" "))}). Run the inner command directly instead.`, executable);
1356
+ }
1357
+ /**
1358
+ * Check one argv: `deny`, `allow`, `denyPatterns`, `allowCommand`, then
1359
+ * follow wrappers and `find -exec` to the command they run. `dynamic[i]`
1360
+ * says whether `argv[i]` is decided at run time (shell mode).
1361
+ */
1362
+ checkArgv(argv, dynamic, depth) {
1363
+ const first = argv[0] ?? "";
1364
+ const executable = executableBasename(first, this.dialect);
1365
+ const key = executable.toLowerCase();
1366
+ if (executable === "") throw new Refusal("not-allowed", "The command has no executable name.");
1367
+ if (dynamic[0] === true) throw new Refusal("unsupported-syntax", `The command name \`${first}\` is decided when the line runs, so the workspace shell policy cannot check it. Write the command name literally.`);
1368
+ if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${executable}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, executable);
1369
+ if (this.allow.length > 0 && !this.allow.includes(executable)) {
1370
+ const listed = this.allow.slice(0, 20).join(", ");
1371
+ throw new Refusal("not-allowed", `\`${executable}\` is not in the workspace shell allowlist. Allowed commands: ${listed}${this.allow.length > 20 ? ", …" : ""}.`, executable);
1372
+ }
1373
+ this.checkHooks(argv, depth > 0);
1374
+ this.commands.push([...argv]);
1375
+ if (OPAQUE_RUNNERS.has(key) || this.shellMode && this.dialect === "posix" && SHELL_OPAQUE_BUILTINS.has(key) || this.shellMode && this.dialect === "cmd" && CMD_OPAQUE_BUILTINS.has(key)) {
1376
+ this.checkOpaque(executable, argv);
1377
+ return;
1378
+ }
1379
+ if (this.shellMode && this.dialect === "posix" && ARITHMETIC_SENSITIVE_BUILTINS.has(key)) this.checkArithmeticSensitive(executable, argv, dynamic);
1380
+ const ranges = this.unwrap(key, argv);
1381
+ if (ranges === void 0) return;
1382
+ if (ranges === null || depth >= MAX_WRAPPER_DEPTH) throw new Refusal("nested-command", `\`${executable}\` is used with options the workspace shell policy cannot read (in ${show(argv.join(" "))}). Run the inner command directly instead.`, executable);
1383
+ for (const [start, end] of ranges) this.checkArgv(argv.slice(start, end), dynamic.slice(start, end), depth + 1);
1384
+ }
1385
+ /**
1386
+ * Builtins bash may evaluate arguments of as arithmetic (array subscripts
1387
+ * like `a[$(cmd)]` run the substitution): arguments must be literal, free
1388
+ * of `[`, and must not name a variable that changes how the shell runs.
1389
+ */
1390
+ checkArithmeticSensitive(executable, argv, dynamic) {
1391
+ for (let index = 1; index < argv.length; index++) {
1392
+ const argument = argv[index] ?? "";
1393
+ if (dynamic[index] === true) throw new Refusal("unsupported-syntax", `\`${executable}\` arguments must be literal here (\`${argument}\` is not): bash may evaluate them as arithmetic.`, executable);
1394
+ if (argument.includes("[") && argument !== "[" && argument !== "]") throw new Refusal("unsupported-syntax", `\`${executable}\` arguments with \`[\` (array subscripts, evaluated as arithmetic) are not supported.`, executable);
1395
+ const name = argument.split("=")[0] ?? "";
1396
+ if (SENSITIVE_VARIABLES.has(name) || name.startsWith("BASH_FUNC_")) throw new Refusal("unsupported-syntax", `Changing \`${name}\` is not supported: it changes how the shell runs the commands that follow.`, executable);
1397
+ }
1398
+ }
1399
+ /**
1400
+ * The argv ranges a wrapper runs. `undefined` — not a wrapper (or one that
1401
+ * runs nothing, like a bare `env`); `null` — a wrapper whose options the
1402
+ * policy cannot read (refused).
1403
+ */
1404
+ unwrap(key, argv) {
1405
+ switch (key) {
1406
+ case "env": return suffixFrom(argv, unwrapEnv(argv));
1407
+ case "nice": return suffixFrom(argv, unwrapNice(argv));
1408
+ case "nohup": return suffixFrom(argv, unwrapSimple(argv, []));
1409
+ case "time": return suffixFrom(argv, unwrapSimple(argv, ["-p"]));
1410
+ case "timeout": return suffixFrom(argv, unwrapTimeout(argv));
1411
+ case "command": return argv.slice(1).some((arg) => arg === "-v" || arg === "-V") ? void 0 : suffixFrom(argv, unwrapSimple(argv, ["-p"]));
1412
+ case "builtin": return suffixFrom(argv, unwrapSimple(argv, []));
1413
+ case "exec": return this.dialect === "posix" ? suffixFrom(argv, unwrapExec(argv)) : void 0;
1414
+ case "find": return unwrapFind(argv);
1415
+ default: return;
1416
+ }
1417
+ }
1418
+ /** Check a `cd` (shell mode): literal, one operand, inside the jail from every possible cwd. */
1419
+ checkCd(words, cwds) {
1420
+ const argv = words.map((word) => word.value);
1421
+ if (this.deny.has("cd")) throw new Refusal("denied-executable", "`cd` is denied by the workspace shell policy.", "cd");
1422
+ this.checkHooks(argv, false);
1423
+ this.commands.push(argv);
1424
+ const operands = words.slice(1);
1425
+ while (this.dialect === "posix" && operands.length > 0 && [
1426
+ "-P",
1427
+ "-L",
1428
+ "--"
1429
+ ].includes(operands[0]?.value ?? "")) operands.shift();
1430
+ const target = operands[0];
1431
+ if (operands.length !== 1 || target === void 0 || target.value === "-" || target.dynamic) throw new Refusal("unsupported-syntax", "`cd` needs exactly one literal directory inside the workspace (no `-`, `~` or variables).", "cd");
1432
+ const next = new Set(cwds);
1433
+ for (const cwd of cwds) {
1434
+ const resolved = this.resolveTarget(cwd, target.value);
1435
+ this.assertInJail(resolved, target.value, "cd");
1436
+ this.paths.push({
1437
+ path: resolved,
1438
+ access: "cwd",
1439
+ target: target.value
1440
+ });
1441
+ next.add(resolved);
1442
+ }
1443
+ if (next.size > MAX_CD_CANDIDATES) throw new Refusal("unsupported-syntax", "The line changes directory too many times.", "cd");
1444
+ return [...next];
1445
+ }
1446
+ /** Check a redirection target from every possible cwd. */
1447
+ checkRedirectTarget(word, access, cwds) {
1448
+ if (word.dynamic) throw new Refusal("unsupported-syntax", `The redirection target \`${word.raw}\` must be a literal path (no variables, globs or \`~\`).`);
1449
+ const value = word.value;
1450
+ if (this.dialect === "posix" && DEVICE_TARGETS.has(value) || this.dialect === "cmd" && CMD_DEVICE_TARGETS.has(value.toLowerCase())) return;
1451
+ for (const cwd of cwds) {
1452
+ const resolved = this.resolveTarget(cwd, value);
1453
+ this.assertInJail(resolved, value, "redirect");
1454
+ this.paths.push({
1455
+ path: resolved,
1456
+ access,
1457
+ target: value
1458
+ });
1459
+ }
1460
+ }
1461
+ resolveTarget(cwd, target) {
1462
+ return node_path.default.resolve(cwd, target);
1463
+ }
1464
+ assertInJail(resolved, target, kind) {
1465
+ if (isInsideJailLexically(this.policy, resolved)) return;
1466
+ throw new Refusal("path-escape", kind === "cd" ? `\`cd ${target}\` leaves the workspace jail (or enters a denied path); commands must stay inside the workspace.` : `The redirection target \`${target}\` is outside the workspace jail or on a denied path.`, void 0, target);
1467
+ }
1468
+ };
1469
+ /**
1470
+ * Turn a wrapper's "inner command starts at `start`" into a range; a
1471
+ * wrapper that runs nothing (`start === argv.length`) becomes `undefined`.
1472
+ */
1473
+ function suffixFrom(argv, start) {
1474
+ if (start === null) return null;
1475
+ return start < argv.length ? [[start, argv.length]] : void 0;
1476
+ }
1477
+ /** `nohup` / `time` / `command` / `builtin`: skip the listed flags and `--`. */
1478
+ function unwrapSimple(argv, flags) {
1479
+ let index = 1;
1480
+ while (index < argv.length) {
1481
+ const arg = argv[index] ?? "";
1482
+ if (arg === "--") {
1483
+ index++;
1484
+ break;
1485
+ }
1486
+ if (flags.includes(arg)) {
1487
+ index++;
147
1488
  continue;
148
1489
  }
149
- if (char === " " || char === " ") {
150
- if (inToken) {
151
- argv.push(current);
152
- current = "";
153
- inToken = false;
154
- }
1490
+ if (arg.startsWith("-") && arg !== "-") return null;
1491
+ break;
1492
+ }
1493
+ return index;
1494
+ }
1495
+ /** `env [-i] [-u NAME] [NAME=value]… command…` */
1496
+ function unwrapEnv(argv) {
1497
+ let index = 1;
1498
+ while (index < argv.length) {
1499
+ const arg = argv[index] ?? "";
1500
+ if (arg === "-i" || arg === "-" || arg === "--ignore-environment") {
1501
+ index++;
1502
+ continue;
1503
+ }
1504
+ if (arg === "-u" || arg === "--unset") {
1505
+ index += 2;
1506
+ continue;
1507
+ }
1508
+ if (arg.startsWith("--unset=") || arg.startsWith("-u") && arg.length > 2) {
1509
+ index++;
1510
+ continue;
1511
+ }
1512
+ if (arg === "--") {
1513
+ index++;
1514
+ break;
1515
+ }
1516
+ if (arg.startsWith("-")) return null;
1517
+ break;
1518
+ }
1519
+ while (index < argv.length && /^[^=]+=/.test(argv[index] ?? "")) index++;
1520
+ return index;
1521
+ }
1522
+ /** `nice [-n N | -N | --adjustment=N] command…` */
1523
+ function unwrapNice(argv) {
1524
+ let index = 1;
1525
+ while (index < argv.length) {
1526
+ const arg = argv[index] ?? "";
1527
+ if (arg === "-n" || arg === "--adjustment") {
1528
+ index += 2;
1529
+ continue;
1530
+ }
1531
+ if (/^-n-?[0-9]+$/.test(arg) || /^--adjustment=-?[0-9]+$/.test(arg) || /^-[0-9]+$/.test(arg)) {
1532
+ index++;
1533
+ continue;
1534
+ }
1535
+ if (arg === "--") {
1536
+ index++;
1537
+ break;
1538
+ }
1539
+ if (arg.startsWith("-")) return null;
1540
+ break;
1541
+ }
1542
+ return index;
1543
+ }
1544
+ /** `timeout [options] DURATION command…` */
1545
+ function unwrapTimeout(argv) {
1546
+ let index = 1;
1547
+ while (index < argv.length) {
1548
+ const arg = argv[index] ?? "";
1549
+ if (arg === "-s" || arg === "--signal" || arg === "-k" || arg === "--kill-after") {
1550
+ index += 2;
1551
+ continue;
1552
+ }
1553
+ if (/^-[sk].+$/.test(arg) || arg.startsWith("--signal=") || arg.startsWith("--kill-after=") || arg === "--preserve-status" || arg === "--foreground" || arg === "-v" || arg === "--verbose") {
1554
+ index++;
1555
+ continue;
1556
+ }
1557
+ if (arg === "--") {
1558
+ index++;
1559
+ break;
1560
+ }
1561
+ if (arg.startsWith("-")) return null;
1562
+ break;
1563
+ }
1564
+ if (index >= argv.length - 1) return null;
1565
+ return index + 1;
1566
+ }
1567
+ /** `exec [-cl] [-a name] command…` (bash). */
1568
+ function unwrapExec(argv) {
1569
+ let index = 1;
1570
+ while (index < argv.length) {
1571
+ const arg = argv[index] ?? "";
1572
+ if (arg === "-c" || arg === "-l" || arg === "-cl" || arg === "-lc") {
1573
+ index++;
1574
+ continue;
1575
+ }
1576
+ if (arg === "-a") {
1577
+ index += 2;
1578
+ continue;
1579
+ }
1580
+ if (arg === "--") {
155
1581
  index++;
156
- continue;
1582
+ break;
157
1583
  }
158
- if (char === "\n" || char === "\r" || UNQUOTED_METACHARACTERS.has(char)) return null;
159
- current += char;
160
- inToken = true;
161
- index++;
1584
+ if (arg.startsWith("-")) return null;
1585
+ break;
1586
+ }
1587
+ return index;
1588
+ }
1589
+ /** The commands `find -exec / -execdir / -ok / -okdir … ;|+` runs. */
1590
+ function unwrapFind(argv) {
1591
+ const inner = [];
1592
+ for (let index = 1; index < argv.length; index++) {
1593
+ const arg = argv[index] ?? "";
1594
+ if (arg !== "-exec" && arg !== "-execdir" && arg !== "-ok" && arg !== "-okdir") continue;
1595
+ const start = index + 1;
1596
+ let end = start;
1597
+ while (end < argv.length && argv[end] !== ";" && argv[end] !== "+") end++;
1598
+ if (end >= argv.length || end === start) return null;
1599
+ inner.push([start, end]);
1600
+ index = end;
1601
+ }
1602
+ return inner.length > 0 ? inner : void 0;
1603
+ }
1604
+ /** Build the argv-mode refusal for a line that is not a single argv. */
1605
+ function argvModeRefusal(command) {
1606
+ const analysis = analyzeArgvCommand(command);
1607
+ if (analysis.ok) return new Refusal("metacharacter", "The command cannot run as a single argv.");
1608
+ if (analysis.type === "empty") return new Refusal("not-allowed", "The command is empty.");
1609
+ if (analysis.type === "unbalanced-quotes") return new Refusal("unbalanced-quotes", `The command has an unbalanced \`${analysis.quote}\` quote. Close it, or quote the argument with the other kind of quote.`);
1610
+ const token = analysis.token;
1611
+ const suffix = " (run_shell executes a single command without a shell.)";
1612
+ const parsed = parseShellLine(command, "posix");
1613
+ const segments = parsed.ok ? parsed.segments : [];
1614
+ if ((token === "&&" || token === "||" || token === ";" || token === "\n") && segments.length >= 2) {
1615
+ const shownOperator = token === "\n" ? "A line break" : `\`${token}\``;
1616
+ if ((segments[0]?.source ?? "").trim().split(/\s+/)[0] === "cd") {
1617
+ const rest = segments.slice(1).map((segment) => show(segment.source));
1618
+ return new Refusal("metacharacter", `${shownOperator} is not supported in argv mode, and \`cd\` does not carry over: every command starts in the workspace root. Run ${rest.join(", then ")} on its own, with paths relative to the root.${suffix}`);
1619
+ }
1620
+ const steps = segments.map((segment) => show(segment.source));
1621
+ const joiner = token === "||" ? ", and if it fails, " : ", then ";
1622
+ return new Refusal("metacharacter", `${shownOperator} is not supported in argv mode: run ${steps.join(joiner)}, as separate commands.${suffix}`);
1623
+ }
1624
+ if (token === "&" && segments.length >= 1) {
1625
+ const steps = segments.map((segment) => show(segment.source));
1626
+ const run = steps.length === 1 ? `run ${steps[0] ?? ""} without it` : `run ${steps.join(", then ")} as separate commands`;
1627
+ return new Refusal("metacharacter", `\`&\` is not supported in argv mode: ${run}; start a long-running command with background: true.${suffix}`);
1628
+ }
1629
+ if (token === "|") {
1630
+ const pipeline = command.split("|").map((part) => part.trim()).filter(Boolean);
1631
+ return new Refusal("metacharacter", `\`|\` (a pipe) is not supported in argv mode: run ${show(pipeline[0] ?? command)} on its own and read its output yourself.${suffix}`);
1632
+ }
1633
+ if (token === ">" || token === ">>" || token === ">&") return new Refusal("metacharacter", `\`${token}\` (redirection) is not supported in argv mode: run the command without it and save the output with write_file if you need it.${suffix}`);
1634
+ if (token === "<" || token === "<<" || token === "<&") return new Refusal("metacharacter", `\`${token}\` (input redirection) is not supported in argv mode: pass the file path to the command as an argument.${suffix}`);
1635
+ if (token === "$(" || token === "`") return new Refusal("metacharacter", `Command substitution (${token === "$(" ? "`$(…)`" : "backticks"}) is not supported in argv mode: run the inner command first, then pass its output literally.${suffix}`);
1636
+ if (token === "$" || token === "${") return new Refusal("metacharacter", `\`$\` (variable expansion) is not supported in argv mode: write the value literally, or put the \`$\` inside quotes to pass it as text.${suffix}`);
1637
+ if (token === "(" || token === ")") return new Refusal("metacharacter", `\`${token}\` is not supported in argv mode: put it inside quotes to pass it as text.${suffix}`);
1638
+ return new Refusal("metacharacter", `\`${token === "\n" ? "\\n" : token}\` is not supported in argv mode: run one command per call, and quote any argument that contains it.${suffix}`);
1639
+ }
1640
+ /** Check a shell-mode (`interpreter: "sh"`) line. */
1641
+ function checkShellLine(checker, policy, command, dialect) {
1642
+ if (/^[-+]/.test(command)) throw new Refusal("unsupported-syntax", "A command line may not start with `-` or `+`.");
1643
+ const parsed = parseShellLine(command, dialect);
1644
+ if (!parsed.ok) throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
1645
+ if (parsed.background) throw new Refusal("unsupported-syntax", "`&` (run in the background) is not supported in a command line: start a long-running command with background: true instead.");
1646
+ let cwds = [node_path.default.resolve(policy.cwd)];
1647
+ for (const simple of parsed.commands) cwds = checkSimpleCommand(checker, simple, cwds, dialect);
1648
+ }
1649
+ /** Check one parsed simple command; returns the possible working directories after it. */
1650
+ function checkSimpleCommand(checker, simple, cwds, dialect) {
1651
+ for (const assignment of simple.assignments) {
1652
+ const name = assignment.assignmentName ?? "";
1653
+ if (SENSITIVE_VARIABLES.has(name) || name.startsWith("BASH_FUNC_")) throw new Refusal("unsupported-syntax", `Assigning \`${name}\` is not supported: it changes how the shell runs the commands that follow.`);
1654
+ }
1655
+ for (const redirect of simple.redirects) if (redirect.type === "read" || redirect.type === "write") checker.checkRedirectTarget(redirect.target, redirect.type, cwds);
1656
+ const first = simple.words[0];
1657
+ if (first === void 0) return cwds;
1658
+ if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
1659
+ if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
1660
+ if (checker.hasArgumentHooks) {
1661
+ const dynamic = simple.words.find((word) => word.dynamic);
1662
+ if (dynamic !== void 0) throw new Refusal("unsupported-syntax", `\`${dynamic.raw}\` is decided when the line runs, so it cannot be checked against the workspace shell policy's command rules. Write the value literally (quote globs).`);
1663
+ }
1664
+ const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
1665
+ if (executable === "cd") return checker.checkCd(simple.words, cwds);
1666
+ if (dialect === "posix" && (executable === "pushd" || executable === "popd")) throw new Refusal("unsupported-syntax", `\`${executable}\` is not supported; use \`cd\`.`);
1667
+ checker.checkArgv(simple.words.map((word) => word.dynamic ? word.raw : word.value), simple.words.map((word) => word.dynamic), 0);
1668
+ return cwds;
1669
+ }
1670
+ /**
1671
+ * The shell policy's verdict on a command line, without touching the
1672
+ * filesystem.
1673
+ *
1674
+ * - **argv mode** (`interpreter: "none"`, the default): the line must
1675
+ * tokenize into one argv; otherwise it is refused with a
1676
+ * `"metacharacter"` / `"unbalanced-quotes"` reason and a message that
1677
+ * names the operator and suggests the split.
1678
+ * - **shell mode** (`interpreter: "sh"`): the line is parsed and every
1679
+ * simple command — inside `;`, `&&`, `||`, pipes, subshells, `$( )`,
1680
+ * backticks and here-documents — is checked; redirection targets and `cd`
1681
+ * directories must stay in the jail (checked lexically here; `exec`
1682
+ * re-checks them with symlinks resolved).
1683
+ *
1684
+ * Every argv goes through `deny` (case-insensitive, wins), `allow`
1685
+ * (exhaustive when non-empty), `denyPatterns` and `allowCommand`.
1686
+ * Wrappers (`env`, `time`, `timeout`, `nice`, `nohup`, `command`,
1687
+ * `builtin`, `exec`) and `find -exec` are followed to the command they run.
1688
+ * A runner the policy cannot see into (`bash -c`, `eval`, `xargs`, `sudo`,
1689
+ * …) is refused while the policy has deny rules, unless `allow` names it.
1690
+ *
1691
+ * @example
1692
+ * checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
1693
+ * // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
1694
+ */
1695
+ function checkShellCommand(policy, command) {
1696
+ return checkShellCommandInDialect(policy, command, shellDialect());
1697
+ }
1698
+ /**
1699
+ * {@link checkShellCommand} with the shell-mode dialect chosen explicitly
1700
+ * instead of from the platform (used to test the cmd.exe rules anywhere).
1701
+ * Argv mode ignores `dialect`.
1702
+ */
1703
+ function checkShellCommandInDialect(policy, command, dialect) {
1704
+ const shell = policy.shell;
1705
+ if (!shell) return {
1706
+ allowed: false,
1707
+ reason: "not-allowed",
1708
+ message: "The workspace has no shell policy, so no command may run.",
1709
+ commands: [],
1710
+ paths: []
1711
+ };
1712
+ const interpreter = shell.interpreter ?? "none";
1713
+ const checker = new LineChecker(policy, shell, command, interpreter === "sh" ? dialect : "posix");
1714
+ try {
1715
+ if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
1716
+ if (interpreter === "sh") checkShellLine(checker, policy, command, dialect);
1717
+ else {
1718
+ const argv = tokenizeCommand(command);
1719
+ if (argv === null) throw argvModeRefusal(command);
1720
+ checker.checkArgv(argv, argv.map(() => false), 0);
1721
+ }
1722
+ return {
1723
+ allowed: true,
1724
+ commands: checker.commands,
1725
+ paths: checker.paths
1726
+ };
1727
+ } catch (error) {
1728
+ if (!(error instanceof Refusal)) throw error;
1729
+ return {
1730
+ allowed: false,
1731
+ reason: error.reason,
1732
+ message: error.message,
1733
+ ...error.executable !== void 0 ? { executable: error.executable } : {},
1734
+ ...error.refusedPath !== void 0 ? { path: error.refusedPath } : {},
1735
+ commands: checker.commands,
1736
+ paths: checker.paths
1737
+ };
162
1738
  }
163
- if (inToken) argv.push(current);
164
- return argv.length > 0 ? argv : null;
165
1739
  }
166
1740
 
167
1741
  //#endregion
@@ -188,55 +1762,6 @@ async function canonicalize$1(target) {
188
1762
  }
189
1763
  }
190
1764
  /**
191
- * Whether `child` is contained within `root` (or equals it), comparing
192
- * canonical absolute paths. Guards against the `/srv/app-evil` vs
193
- * `/srv/app` prefix-collision by anchoring on a path separator.
194
- */
195
- function isInside(child, root) {
196
- const relative = node_path.default.relative(root, child);
197
- return relative === "" || !relative.startsWith("..") && !node_path.default.isAbsolute(relative);
198
- }
199
- /**
200
- * Translate a glob (the small subset used by `denyPaths` — `*`, `**`,
201
- * `?`) into an anchored `RegExp` over a `/`-separated relative path.
202
- * `**` spans path separators; a single `*` does not.
203
- */
204
- function globToRegExp$1(glob) {
205
- let source = "";
206
- for (let index = 0; index < glob.length; index++) {
207
- const char = glob[index];
208
- if (char === "*") {
209
- if (glob[index + 1] === "*") {
210
- source += ".*";
211
- index++;
212
- if (glob[index + 1] === "/") index++;
213
- } else source += "[^/]*";
214
- continue;
215
- }
216
- if (char === "?") {
217
- source += "[^/]";
218
- continue;
219
- }
220
- source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
221
- }
222
- return new RegExp(`^${source}$`);
223
- }
224
- /**
225
- * Whether a workspace-relative (`/`-separated) path matches any of the
226
- * policy's `denyPaths` globs. A deny glob also blocks everything beneath
227
- * a matched directory (`".git/**"` blocks `.git/config`).
228
- */
229
- function matchesDeny(relativePath, denyPaths) {
230
- return denyPaths.some((glob) => {
231
- if (globToRegExp$1(glob).test(relativePath)) return true;
232
- if (!glob.includes("*") && !glob.includes("?")) {
233
- const prefix = glob.endsWith("/") ? glob : `${glob}/`;
234
- return relativePath.startsWith(prefix);
235
- }
236
- return false;
237
- });
238
- }
239
- /**
240
1765
  * Resolve and jail a single input path against a {@link WorkspacePolicy}.
241
1766
  *
242
1767
  * The input is resolved against `policy.cwd`, canonicalized (symlinks in
@@ -246,6 +1771,17 @@ function matchesDeny(relativePath, denyPaths) {
246
1771
  * `denyPaths` glob even while inside `cwd`, is rejected with a
247
1772
  * {@link WorkspacePolicyError} of type `"path-escape"`.
248
1773
  *
1774
+ * Why this does not use `@warlock.js/fs`'s confined reader (`fs.confined`):
1775
+ * the two answer different questions. The confined reader refuses absolute
1776
+ * paths, refuses every symbolic link (or follows only links inside its one
1777
+ * root), requires the path to exist, and reads the file itself through a
1778
+ * no-follow, identity-checked open. This jail must instead accept absolute
1779
+ * paths inside `cwd`, several `allowPaths` roots, `denyPaths` globs, a
1780
+ * symlinked `cwd`, links whose target stays inside, and paths that do not
1781
+ * exist yet (`writeFile`, `mkdir`), and it returns a path that writes,
1782
+ * `exec`'s working directory and redirections use afterwards. Switching would
1783
+ * change what agents may touch, so the workspace keeps this resolver.
1784
+ *
249
1785
  * @param policy - The bounding policy (its `cwd` is the jail root).
250
1786
  * @param inputPath - A workspace-relative or absolute path to resolve.
251
1787
  * @returns The canonical absolute path plus its `/`-separated relative form.
@@ -260,8 +1796,9 @@ async function resolveInJail(policy, inputPath) {
260
1796
  const insideCwd = isInside(absolutePath, jailRoot);
261
1797
  const allowRoots = policy.allowPaths ?? [];
262
1798
  let insideAllow = false;
263
- if (!insideCwd) {
264
- for (const root of allowRoots) if (isInside(absolutePath, await canonicalize$1(root))) {
1799
+ if (!insideCwd) for (const root of allowRoots) {
1800
+ const canonicalRoot = await canonicalize$1(root);
1801
+ if (isInside(absolutePath, canonicalRoot)) {
265
1802
  insideAllow = true;
266
1803
  break;
267
1804
  }
@@ -272,7 +1809,7 @@ async function resolveInJail(policy, inputPath) {
272
1809
  });
273
1810
  const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, absolutePath).split(node_path.default.sep).join("/") : "";
274
1811
  if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {
275
- if (matchesDeny(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
1812
+ if (matchesAnyGlob(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
276
1813
  type: "path-escape",
277
1814
  path: inputPath
278
1815
  });
@@ -283,32 +1820,22 @@ async function resolveInJail(policy, inputPath) {
283
1820
  };
284
1821
  }
285
1822
  /**
286
- * Reduce an argv's first element to the basename the allow/deny policy is
287
- * keyed on. `"npm"` → `"npm"`; `"/usr/bin/node"` → `"node"`; `"node.exe"`
288
- * → `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
289
- * stripped).
290
- */
291
- function executableBasename(firstToken) {
292
- return node_path.default.basename(firstToken).replace(/\.(exe|cmd|bat|com)$/i, "");
293
- }
294
- /**
295
- * Whether a shell command is permitted by the policy's `shell` sub-policy.
1823
+ * Whether a command line is permitted by the policy's `shell` sub-policy —
1824
+ * `checkShellCommand(policy, command).allowed`.
296
1825
  *
297
- * The command is first tokenized via {@link tokenizeCommand} — a command
298
- * that cannot be represented as a single argv (unbalanced quotes, or
299
- * unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,
300
- * redirection) is denied outright. The backend spawns the argv directly
301
- * with no shell, so such a command has no meaning here — and unquoted
302
- * metacharacters were exactly how an injected command chain used to ride
303
- * past the allowlist. The resolved `argv[0]` basename is then matched
304
- * against `shell.deny` then `shell.allow`. **Deny always wins.** When
305
- * `shell.allow` is set, the executable MUST appear in it (fail-closed
306
- * allowlist); when `allow` is absent/empty, any non-denied command is
307
- * permitted. An absent `shell` block means no command may run at all.
1826
+ * In argv mode (the default) the command must tokenize into a single argv
1827
+ * (no unquoted `;`, `&&`, `|`, backticks, `$`, redirection, …): the backend
1828
+ * spawns it with no shell, and unquoted metacharacters were exactly how an
1829
+ * injected command chain used to ride past the allowlist. In shell mode
1830
+ * (`interpreter: "sh"`) every simple command in the line is checked. Each
1831
+ * argv's executable basename is matched against `shell.deny` (wins, case
1832
+ * insensitive) then `shell.allow` (exhaustive when non-empty), then
1833
+ * `shell.denyPatterns` and `shell.allowCommand`. An absent `shell` block
1834
+ * means no command may run at all.
308
1835
  *
309
- * Returns a plain `boolean` rather than throwing — the ops layer raises
310
- * the {@link WorkspacePolicyError} so the thrown context (`command`)
311
- * lives next to the call site.
1836
+ * Returns a plain `boolean`; use {@link checkShellCommand} for the reason
1837
+ * and the model-facing message. Redirection targets are checked lexically
1838
+ * here; `exec` re-checks them with symlinks resolved.
312
1839
  *
313
1840
  * @example
314
1841
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
@@ -316,15 +1843,7 @@ function executableBasename(firstToken) {
316
1843
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
317
1844
  */
318
1845
  function isCommandAllowed(policy, command) {
319
- const shell = policy.shell;
320
- if (!shell) return false;
321
- const argv = tokenizeCommand(command);
322
- if (argv === null) return false;
323
- const executable = executableBasename(argv[0]);
324
- if (executable === "") return false;
325
- if (shell.deny && shell.deny.includes(executable)) return false;
326
- if (shell.allow && shell.allow.length > 0) return shell.allow.includes(executable);
327
- return true;
1846
+ return checkShellCommand(policy, command).allowed;
328
1847
  }
329
1848
  /**
330
1849
  * Build the exact environment a spawned process receives — `process.env`
@@ -349,6 +1868,280 @@ function buildEnv(policy) {
349
1868
  if (shell.env) for (const [key, value] of Object.entries(shell.env)) env[key] = value;
350
1869
  return env;
351
1870
  }
1871
+ /**
1872
+ * Variables removed from the environment of a shell-mode (`interpreter:
1873
+ * "sh"`) command: each changes how the shell itself starts, parses or
1874
+ * resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
1875
+ * `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
1876
+ * shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
1877
+ */
1878
+ const SHELL_STARTUP_VARIABLES = /* @__PURE__ */ new Set([
1879
+ "ENV",
1880
+ "BASH_ENV",
1881
+ "SHELLOPTS",
1882
+ "BASHOPTS",
1883
+ "CDPATH",
1884
+ "IFS",
1885
+ "PS4",
1886
+ "GLOBIGNORE"
1887
+ ]);
1888
+ /**
1889
+ * The environment a command actually receives: {@link buildEnv}, minus the
1890
+ * shell start-up variables when the policy runs lines through a shell.
1891
+ * Compared case-insensitively, because Windows environment names are.
1892
+ */
1893
+ function buildCommandEnv(policy) {
1894
+ const env = buildEnv(policy);
1895
+ if ((policy.shell?.interpreter ?? "none") !== "sh") return env;
1896
+ for (const key of Object.keys(env)) {
1897
+ const upper = key.toUpperCase();
1898
+ if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
1899
+ }
1900
+ return env;
1901
+ }
1902
+
1903
+ //#endregion
1904
+ //#region ../../packages/ai-workspace/src/shell-jobs.ts
1905
+ /** Finished commands kept for a last `shell_output` read before the oldest are pruned. */
1906
+ const MAX_FINISHED_JOBS = 16;
1907
+ /** Longest `waitMs` a `shellOutput` call may ask for. */
1908
+ const MAX_OUTPUT_WAIT_MS = 3e4;
1909
+ /** How long `kill()` / `dispose()` wait for a killed tree to report its exit. */
1910
+ const KILL_SETTLE_MS = 5e3;
1911
+ /** Resolve after `ms`, without keeping the event loop alive. */
1912
+ function delay(ms) {
1913
+ return new Promise((resolve) => {
1914
+ setTimeout(resolve, ms).unref?.();
1915
+ });
1916
+ }
1917
+ /**
1918
+ * Keep the last `maxBytes` UTF-8 bytes of `text`, cutting forward to a
1919
+ * character boundary so the kept tail never starts mid-character.
1920
+ */
1921
+ function tailUtf8(text, maxBytes) {
1922
+ const buffer = Buffer.from(text, "utf8");
1923
+ if (buffer.byteLength <= maxBytes) return {
1924
+ text,
1925
+ bytes: buffer.byteLength
1926
+ };
1927
+ let start = buffer.byteLength - maxBytes;
1928
+ while (start < buffer.byteLength && ((buffer[start] ?? 0) & 192) === 128) start++;
1929
+ const kept = buffer.subarray(start);
1930
+ return {
1931
+ text: kept.toString("utf8"),
1932
+ bytes: kept.byteLength
1933
+ };
1934
+ }
1935
+ /** Bytes of `text` from byte `skip` on, moved forward to a character boundary. */
1936
+ function skipUtf8(text, skip) {
1937
+ const buffer = Buffer.from(text, "utf8");
1938
+ let start = Math.max(0, skip);
1939
+ while (start < buffer.byteLength && ((buffer[start] ?? 0) & 192) === 128) start++;
1940
+ return buffer.subarray(start).toString("utf8");
1941
+ }
1942
+ /** One background command and its retained output. */
1943
+ var ShellJob = class {
1944
+ constructor(id, command, retainBytes) {
1945
+ this.id = id;
1946
+ this.command = command;
1947
+ this.retainBytes = retainBytes;
1948
+ this.status = "running";
1949
+ this.exitCode = null;
1950
+ this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
1951
+ this.produced = 0;
1952
+ this.readCursor = 0;
1953
+ this.chunks = [];
1954
+ this.retained = 0;
1955
+ this.listeners = /* @__PURE__ */ new Set();
1956
+ this.killRequested = false;
1957
+ this.timedOut = false;
1958
+ }
1959
+ append(stream, chunk) {
1960
+ if (chunk === "") return;
1961
+ const bytes = Buffer.byteLength(chunk, "utf8");
1962
+ this.chunks.push({
1963
+ stream,
1964
+ text: chunk,
1965
+ offset: this.produced,
1966
+ bytes
1967
+ });
1968
+ this.produced += bytes;
1969
+ this.retained += bytes;
1970
+ this.trim();
1971
+ this.notify();
1972
+ }
1973
+ /** Drop the oldest output until at most `retainBytes` are kept. */
1974
+ trim() {
1975
+ while (this.retained > this.retainBytes && this.chunks.length > 0) {
1976
+ const oldest = this.chunks[0];
1977
+ if (oldest === void 0) return;
1978
+ const excess = this.retained - this.retainBytes;
1979
+ if (oldest.bytes <= excess) {
1980
+ this.chunks.shift();
1981
+ this.retained -= oldest.bytes;
1982
+ continue;
1983
+ }
1984
+ const kept = tailUtf8(oldest.text, oldest.bytes - excess);
1985
+ this.retained -= oldest.bytes - kept.bytes;
1986
+ oldest.offset += oldest.bytes - kept.bytes;
1987
+ oldest.text = kept.text;
1988
+ oldest.bytes = kept.bytes;
1989
+ }
1990
+ }
1991
+ finish(exitCode) {
1992
+ if (this.status !== "running") return;
1993
+ this.exitCode = exitCode;
1994
+ this.status = this.timedOut ? "timed-out" : this.killRequested ? "killed" : "exited";
1995
+ this.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1996
+ if (this.timer !== void 0) clearTimeout(this.timer);
1997
+ this.notify();
1998
+ }
1999
+ notify() {
2000
+ for (const listener of [...this.listeners]) listener();
2001
+ }
2002
+ /** Resolve on the next output or state change (or after `ms`, or on abort). */
2003
+ waitForChange(ms, signal) {
2004
+ return new Promise((resolve) => {
2005
+ if (signal?.aborted) {
2006
+ resolve();
2007
+ return;
2008
+ }
2009
+ let timer;
2010
+ const done = () => {
2011
+ if (timer !== void 0) clearTimeout(timer);
2012
+ this.listeners.delete(done);
2013
+ signal?.removeEventListener("abort", done);
2014
+ resolve();
2015
+ };
2016
+ timer = setTimeout(done, ms);
2017
+ timer.unref?.();
2018
+ this.listeners.add(done);
2019
+ signal?.addEventListener("abort", done, { once: true });
2020
+ });
2021
+ }
2022
+ info() {
2023
+ return {
2024
+ id: this.id,
2025
+ command: this.command,
2026
+ status: this.status,
2027
+ exitCode: this.exitCode,
2028
+ ...this.process?.pid !== void 0 ? { pid: this.process.pid } : {},
2029
+ startedAt: this.startedAt,
2030
+ ...this.endedAt !== void 0 ? { endedAt: this.endedAt } : {}
2031
+ };
2032
+ }
2033
+ /** Output produced after byte `since`, plus how much of it is gone. */
2034
+ read(since) {
2035
+ const from = Math.min(Math.max(0, Math.floor(since)), this.produced);
2036
+ const firstRetained = this.chunks[0]?.offset ?? this.produced;
2037
+ const droppedBytes = Math.max(0, firstRetained - from);
2038
+ let stdout = "";
2039
+ let stderr = "";
2040
+ for (const chunk of this.chunks) {
2041
+ if (chunk.offset + chunk.bytes <= from) continue;
2042
+ const text = chunk.offset >= from ? chunk.text : skipUtf8(chunk.text, from - chunk.offset);
2043
+ if (chunk.stream === "stdout") stdout += text;
2044
+ else stderr += text;
2045
+ }
2046
+ this.readCursor = this.produced;
2047
+ return {
2048
+ ...this.info(),
2049
+ stdout,
2050
+ stderr,
2051
+ cursor: this.produced,
2052
+ droppedBytes,
2053
+ truncated: droppedBytes > 0
2054
+ };
2055
+ }
2056
+ };
2057
+ /**
2058
+ * The background commands one workspace owns. Shared by every projection
2059
+ * (`readonly()`, `scope()`) of the workspace, so `dispose()` on any of them
2060
+ * stops them all.
2061
+ */
2062
+ var ShellJobs = class {
2063
+ constructor() {
2064
+ this.jobs = /* @__PURE__ */ new Map();
2065
+ this.nextId = 1;
2066
+ this.disposed = false;
2067
+ }
2068
+ /** Whether {@link dispose} has run. */
2069
+ get isDisposed() {
2070
+ return this.disposed;
2071
+ }
2072
+ /** Start a command as a background job. */
2073
+ start(spec) {
2074
+ if (this.disposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot start new commands.", {
2075
+ type: "denied-command",
2076
+ reason: "not-allowed",
2077
+ command: spec.command
2078
+ });
2079
+ const running = [...this.jobs.values()].filter((job) => job.status === "running").length;
2080
+ if (running >= spec.maxJobs) throw new WorkspacePolicyError(`${running} background commands are already running (the limit is ${spec.maxJobs}). Stop one with shell_kill first.`, {
2081
+ type: "denied-command",
2082
+ reason: "not-allowed",
2083
+ command: spec.command
2084
+ });
2085
+ const job = new ShellJob(`job-${this.nextId++}`, spec.command, spec.retainBytes);
2086
+ this.jobs.set(job.id, job);
2087
+ const handle = spec.launch((stream, chunk) => job.append(stream, chunk));
2088
+ job.process = handle;
2089
+ if (spec.timeoutMs !== void 0 && spec.timeoutMs > 0) {
2090
+ job.timer = setTimeout(() => {
2091
+ if (job.status === "running") {
2092
+ job.timedOut = true;
2093
+ handle.kill();
2094
+ }
2095
+ }, spec.timeoutMs);
2096
+ job.timer.unref?.();
2097
+ }
2098
+ handle.done.then(({ exitCode }) => job.finish(exitCode));
2099
+ this.prune();
2100
+ return job.info();
2101
+ }
2102
+ /** Forget the oldest finished jobs beyond {@link MAX_FINISHED_JOBS}. */
2103
+ prune() {
2104
+ const finished = [...this.jobs.values()].filter((job) => job.status !== "running");
2105
+ for (const job of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_JOBS))) this.jobs.delete(job.id);
2106
+ }
2107
+ get(id) {
2108
+ const job = this.jobs.get(id);
2109
+ if (job === void 0) throw new WorkspaceJobError(`No background command with id "${id}" exists in this workspace.`, {
2110
+ type: "not-found",
2111
+ jobId: id
2112
+ });
2113
+ return job;
2114
+ }
2115
+ /** New output of a job (see {@link ShellOutputOptions}). */
2116
+ async output(id, opts) {
2117
+ const job = this.get(id);
2118
+ const since = opts?.since ?? job.readCursor;
2119
+ const waitMs = Math.min(Math.max(0, opts?.waitMs ?? 0), MAX_OUTPUT_WAIT_MS);
2120
+ if (waitMs > 0 && job.status === "running" && job.produced <= since) await job.waitForChange(waitMs, opts?.signal);
2121
+ return job.read(since);
2122
+ }
2123
+ /** Kill a job's process tree and wait (bounded) for it to stop. */
2124
+ async kill(id) {
2125
+ const job = this.get(id);
2126
+ await this.stop(job);
2127
+ return job.info();
2128
+ }
2129
+ async stop(job) {
2130
+ if (job.status !== "running" || job.process === void 0) return;
2131
+ job.killRequested = true;
2132
+ job.process.kill();
2133
+ await Promise.race([job.process.done, delay(KILL_SETTLE_MS)]);
2134
+ }
2135
+ /** Every job, oldest first. */
2136
+ list() {
2137
+ return [...this.jobs.values()].map((job) => job.info());
2138
+ }
2139
+ /** Kill every running job and refuse new ones. Idempotent. */
2140
+ async dispose() {
2141
+ this.disposed = true;
2142
+ await Promise.all([...this.jobs.values()].map((job) => this.stop(job)));
2143
+ }
2144
+ };
352
2145
 
353
2146
  //#endregion
354
2147
  //#region ../../packages/ai-workspace/src/ops.ts
@@ -358,6 +2151,14 @@ const DEFAULT_READ_LINES = 2e3;
358
2151
  const DEFAULT_MAX_GREP_MATCHES = 1e3;
359
2152
  /** Default per-command output byte cap when the policy sets none. */
360
2153
  const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
2154
+ /** Default `list.ignore` when the policy sets none. */
2155
+ const DEFAULT_LIST_IGNORE = ["node_modules", ".git"];
2156
+ /** Default deepest `depth` a listing may ask for. */
2157
+ const DEFAULT_LIST_MAX_DEPTH = 5;
2158
+ /** Default most entries one listing returns. */
2159
+ const DEFAULT_LIST_MAX_ENTRIES = 500;
2160
+ /** Most edits one `multiEdit` call may carry. */
2161
+ const MAX_MULTI_EDITS = 100;
361
2162
  /**
362
2163
  * Exit code reported for a command refused because its signal was already
363
2164
  * aborted (the shell convention for an interrupted command). Callers should
@@ -448,32 +2249,6 @@ function capOutput(value, maxBytes) {
448
2249
  return clipUtf8(value, maxBytes);
449
2250
  }
450
2251
  /**
451
- * Minimal glob match over a `/`-separated relative path. Supports `**`
452
- * (spans separators), `*` (within a segment), and `?` (one non-separator
453
- * char) — enough for the workspace's `glob`/grep narrowing without
454
- * pulling in a runtime dependency.
455
- */
456
- function globToRegExp(glob) {
457
- let source = "";
458
- for (let index = 0; index < glob.length; index++) {
459
- const char = glob[index];
460
- if (char === "*") {
461
- if (glob[index + 1] === "*") {
462
- source += ".*";
463
- index++;
464
- if (glob[index + 1] === "/") index++;
465
- } else source += "[^/]*";
466
- continue;
467
- }
468
- if (char === "?") {
469
- source += "[^/]";
470
- continue;
471
- }
472
- source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
473
- }
474
- return new RegExp(`^${source}$`);
475
- }
476
- /**
477
2252
  * A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.
478
2253
  */
479
2254
  const QUANTIFIER_SOURCE = String.raw`[+*?]|\{\d*,?\d*\}`;
@@ -495,17 +2270,11 @@ function isSafeGrepPattern(pattern) {
495
2270
  if (pattern.length > MAX_GREP_PATTERN_LENGTH) return false;
496
2271
  return !NESTED_QUANTIFIER_PATTERN.test(pattern);
497
2272
  }
498
- /**
499
- * The internal, single-instance implementation of {@link WorkspaceOps}.
500
- * Holds the backend + policy and is the one place the jail, command
501
- * gating, read caps, and the read-before-edit guard are enforced — both
502
- * the agent-facing tools and the human-facing direct methods funnel
503
- * through this object, so there is exactly one set of rules.
504
- */
505
2273
  var Ops = class {
506
- constructor(backend, policy) {
2274
+ constructor(backend, policy, runtime) {
507
2275
  this.backend = backend;
508
2276
  this.policy = policy;
2277
+ this.runtime = runtime;
509
2278
  this.pathLocks = /* @__PURE__ */ new Map();
510
2279
  }
511
2280
  /** Run `task` after every earlier mutation of `absolutePath` has settled. */
@@ -599,30 +2368,76 @@ var Ops = class {
599
2368
  expectedHash: input.expectHash,
600
2369
  actualHash: currentHash
601
2370
  });
602
- const occurrences = countOccurrences(current, input.oldString);
603
- if (occurrences === 0) throw new WorkspaceEditError(`The text to replace was not found in "${input.path}".`, {
604
- type: "not-found",
605
- path: relativePath || input.path,
606
- matches: 0
607
- });
608
- if (occurrences > 1 && !input.replaceAll) throw new WorkspaceEditError(`The text to replace is not unique in "${input.path}" (${occurrences} matches); pass replaceAll or include more surrounding context.`, {
609
- type: "not-unique",
610
- path: relativePath || input.path,
611
- matches: occurrences
612
- });
613
- const updated = input.replaceAll ? current.split(input.oldString).join(input.newString) : replaceFirst(current, input.oldString, input.newString);
2371
+ const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
614
2372
  await this.backend.writeFile(absolutePath, updated);
615
2373
  return {
616
2374
  path: relativePath || input.path,
617
- replacements: input.replaceAll ? occurrences : 1,
2375
+ replacements,
618
2376
  hash: _warlock_js_fs.fs.hash.string(updated)
619
2377
  };
620
2378
  }
621
- async exec(command, opts) {
622
- if (!isCommandAllowed(this.policy, command)) throw new WorkspacePolicyError(`Command is not permitted by the workspace shell policy: ${command}`, {
2379
+ async multiEdit(input) {
2380
+ const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
2381
+ const shownPath = relativePath || input.path;
2382
+ if (input.edits.length === 0 || input.edits.length > 100) throw new RangeError(`A multi-edit needs between 1 and ${100} edits (got ${input.edits.length}).`);
2383
+ return this.withPathLock(absolutePath, async () => {
2384
+ const current = await this.backend.readFile(absolutePath);
2385
+ const currentHash = _warlock_js_fs.fs.hash.string(current);
2386
+ if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; none of the edits were applied.`, {
2387
+ type: "stale-hash",
2388
+ path: shownPath,
2389
+ expectedHash: input.expectHash,
2390
+ actualHash: currentHash
2391
+ });
2392
+ let content = current;
2393
+ let replacements = 0;
2394
+ input.edits.forEach((edit, editIndex) => {
2395
+ const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
2396
+ content = applied.content;
2397
+ replacements += applied.replacements;
2398
+ });
2399
+ await this.backend.writeFile(absolutePath, content);
2400
+ return {
2401
+ path: shownPath,
2402
+ edits: input.edits.length,
2403
+ replacements,
2404
+ hash: _warlock_js_fs.fs.hash.string(content)
2405
+ };
2406
+ });
2407
+ }
2408
+ /**
2409
+ * Check a command line against the shell policy: every command in it
2410
+ * (argv or shell mode), then — shell mode — every redirection target and
2411
+ * `cd` directory against the jail with symlinks resolved.
2412
+ */
2413
+ async assertCommandAllowed(command) {
2414
+ const verdict = checkShellCommand(this.policy, command);
2415
+ if (!verdict.allowed) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
2416
+ type: "denied-command",
2417
+ command,
2418
+ ...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
2419
+ ...verdict.executable !== void 0 ? { executable: verdict.executable } : {},
2420
+ ...verdict.path !== void 0 ? { path: verdict.path } : {}
2421
+ });
2422
+ for (const target of verdict.paths) try {
2423
+ await resolveInJail(this.policy, target.path);
2424
+ } catch (error) {
2425
+ if (!(error instanceof WorkspacePolicyError)) throw error;
2426
+ throw new WorkspacePolicyError(target.access === "cwd" ? `\`cd ${target.target}\` resolves (through a symlink) outside the workspace jail or onto a denied path.` : `The redirection target \`${target.target}\` resolves (through a symlink) outside the workspace jail or onto a denied path.`, {
2427
+ type: "denied-command",
2428
+ reason: "path-escape",
2429
+ command,
2430
+ path: target.target
2431
+ });
2432
+ }
2433
+ if (this.runtime.jobs.isDisposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot run new commands.", {
623
2434
  type: "denied-command",
2435
+ reason: "not-allowed",
624
2436
  command
625
2437
  });
2438
+ }
2439
+ async exec(command, opts) {
2440
+ await this.assertCommandAllowed(command);
626
2441
  const signal = opts?.signal;
627
2442
  if (signal?.aborted) return {
628
2443
  exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
@@ -635,11 +2450,13 @@ var Ops = class {
635
2450
  const shell = this.policy.shell;
636
2451
  const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
637
2452
  const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
2453
+ const interpreter = shell?.interpreter ?? "none";
638
2454
  const result = await this.backend.exec(command, {
639
2455
  cwd: this.policy.cwd,
640
2456
  timeoutMs,
641
- env: buildEnv(this.policy),
642
- ...signal ? { signal } : {}
2457
+ env: buildCommandEnv(this.policy),
2458
+ ...signal ? { signal } : {},
2459
+ ...interpreter !== "none" ? { interpreter } : {}
643
2460
  });
644
2461
  const stdout = capOutput(result.stdout, maxOutputBytes);
645
2462
  const stderr = capOutput(result.stderr, maxOutputBytes);
@@ -653,6 +2470,120 @@ var Ops = class {
653
2470
  cancelled
654
2471
  };
655
2472
  }
2473
+ async startShell(command, opts) {
2474
+ const background = this.policy.shell?.background;
2475
+ if (background === void 0) throw new WorkspacePolicyError("Background commands are not enabled for this workspace. Run the command without background, or ask for shell.background to be enabled.", {
2476
+ type: "denied-command",
2477
+ reason: "not-allowed",
2478
+ command
2479
+ });
2480
+ await this.assertCommandAllowed(command);
2481
+ const start = this.backend.start?.bind(this.backend);
2482
+ if (start === void 0) throw new WorkspacePolicyError("This workspace backend cannot run background commands.", {
2483
+ type: "denied-command",
2484
+ reason: "not-allowed",
2485
+ command
2486
+ });
2487
+ const shell = this.policy.shell;
2488
+ const interpreter = shell?.interpreter ?? "none";
2489
+ const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
2490
+ const env = buildCommandEnv(this.policy);
2491
+ return this.runtime.jobs.start({
2492
+ command,
2493
+ retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
2494
+ maxJobs: background.maxJobs ?? 4,
2495
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
2496
+ launch: (onOutput) => start(command, {
2497
+ cwd: this.policy.cwd,
2498
+ env,
2499
+ ...interpreter !== "none" ? { interpreter } : {},
2500
+ onOutput
2501
+ })
2502
+ });
2503
+ }
2504
+ shellOutput(id, opts) {
2505
+ return this.runtime.jobs.output(id, opts);
2506
+ }
2507
+ shellKill(id) {
2508
+ return this.runtime.jobs.kill(id);
2509
+ }
2510
+ listShells() {
2511
+ return this.runtime.jobs.list();
2512
+ }
2513
+ dispose() {
2514
+ return this.runtime.jobs.dispose();
2515
+ }
2516
+ async listDir(inputPath = ".", opts) {
2517
+ const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
2518
+ const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
2519
+ const listPolicy = this.policy.list;
2520
+ const maxDepth = Math.max(1, Math.floor(listPolicy?.maxDepth ?? DEFAULT_LIST_MAX_DEPTH));
2521
+ const depth = Math.min(Math.max(1, Math.floor(opts?.depth ?? 1)), maxDepth);
2522
+ const maxEntries = Math.max(1, Math.floor(listPolicy?.maxEntries ?? DEFAULT_LIST_MAX_ENTRIES));
2523
+ const ignore = [...listPolicy?.ignore ?? DEFAULT_LIST_IGNORE, ...opts?.ignore ?? []];
2524
+ const insideCwd = isInside(root, jailRoot);
2525
+ if (!await this.isDirectory(root)) throw new Error(`"${inputPath}" is not a directory in the workspace.`);
2526
+ const entries = [];
2527
+ let level = [root];
2528
+ let truncated = false;
2529
+ for (let currentDepth = 1; currentDepth <= depth && level.length > 0; currentDepth++) {
2530
+ const nextLevel = [];
2531
+ for (const directory of level) {
2532
+ let children;
2533
+ try {
2534
+ children = await this.backend.list(directory);
2535
+ } catch {
2536
+ continue;
2537
+ }
2538
+ children.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
2539
+ for (const child of children) {
2540
+ const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, child).split(node_path.default.sep).join("/") : child;
2541
+ if (insideCwd && (this.isDenied(relativeToJail) || matchesAnyGlob(relativeToJail, ignore))) continue;
2542
+ const relativeToRoot = node_path.default.relative(root, child).split(node_path.default.sep).join("/");
2543
+ if (!insideCwd && matchesAnyGlob(relativeToRoot, ignore)) continue;
2544
+ if (entries.length >= maxEntries) {
2545
+ truncated = true;
2546
+ break;
2547
+ }
2548
+ const described = await this.describe(child);
2549
+ entries.push({
2550
+ path: relativeToJail,
2551
+ type: described.type,
2552
+ ...described.size !== void 0 ? { size: described.size } : {},
2553
+ depth: currentDepth
2554
+ });
2555
+ if (described.type === "directory") nextLevel.push(child);
2556
+ }
2557
+ if (truncated) break;
2558
+ }
2559
+ if (truncated) break;
2560
+ level = nextLevel;
2561
+ }
2562
+ return {
2563
+ path: insideCwd ? rootRelative || "." : root,
2564
+ entries,
2565
+ truncated
2566
+ };
2567
+ }
2568
+ /** Kind and size of a listed entry; symlinks are reported, never followed. */
2569
+ async describe(absPath) {
2570
+ if (this.backend.stat) try {
2571
+ const stats = await this.backend.stat(absPath);
2572
+ return stats.type === "file" ? {
2573
+ type: "file",
2574
+ size: stats.size
2575
+ } : { type: stats.type };
2576
+ } catch {
2577
+ return { type: "other" };
2578
+ }
2579
+ if (await this.isDirectory(absPath)) try {
2580
+ await resolveInJail(this.policy, absPath);
2581
+ return { type: "directory" };
2582
+ } catch {
2583
+ return { type: "other" };
2584
+ }
2585
+ return { type: "file" };
2586
+ }
656
2587
  async grep(pattern, opts) {
657
2588
  if (!isSafeGrepPattern(pattern)) throw new WorkspacePolicyError(`Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`, {
658
2589
  type: "unsafe-pattern",
@@ -676,7 +2607,7 @@ var Ops = class {
676
2607
  }
677
2608
  const lines = content.split("\n");
678
2609
  for (let index = 0; index < lines.length; index++) {
679
- const line = lines[index];
2610
+ const line = lines[index] ?? "";
680
2611
  if (line.length > MAX_GREP_LINE_SCAN_LENGTH) continue;
681
2612
  if (regex.test(line)) {
682
2613
  matches.push({
@@ -723,17 +2654,40 @@ var Ops = class {
723
2654
  /** Whether a `/`-separated relative path hits the policy deny list. */
724
2655
  isDenied(relativePath) {
725
2656
  const denyPaths = this.policy.denyPaths;
726
- if (!denyPaths || denyPaths.length === 0) return false;
727
- return denyPaths.some((glob) => {
728
- if (globToRegExp(glob).test(relativePath)) return true;
729
- if (!glob.includes("*") && !glob.includes("?")) {
730
- const prefix = glob.endsWith("/") ? glob : `${glob}/`;
731
- return relativePath.startsWith(prefix);
732
- }
733
- return false;
734
- });
2657
+ return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
735
2658
  }
736
2659
  };
2660
+ /** The lower of two optional limits (either may be absent). */
2661
+ function lowerOf(policyLimit, requested) {
2662
+ if (requested === void 0 || requested <= 0) return policyLimit;
2663
+ return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
2664
+ }
2665
+ /**
2666
+ * Apply one exact-string replacement to `content` under the edit rules:
2667
+ * `oldString` must occur, and exactly once unless `replaceAll`.
2668
+ */
2669
+ function applyReplacement(content, edit, inputPath, shownPath, editIndex) {
2670
+ const occurrences = countOccurrences(content, edit.oldString);
2671
+ const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
2672
+ const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
2673
+ const indexOption = editIndex === void 0 ? {} : { editIndex };
2674
+ if (occurrences === 0) throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".${nothingApplied}`, {
2675
+ type: "not-found",
2676
+ path: shownPath,
2677
+ matches: 0,
2678
+ ...indexOption
2679
+ });
2680
+ if (occurrences > 1 && !edit.replaceAll) throw new WorkspaceEditError(`${which}The text to replace is not unique in "${inputPath}" (${occurrences} matches); pass replaceAll or include more surrounding context.${nothingApplied}`, {
2681
+ type: "not-unique",
2682
+ path: shownPath,
2683
+ matches: occurrences,
2684
+ ...indexOption
2685
+ });
2686
+ return {
2687
+ content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
2688
+ replacements: edit.replaceAll ? occurrences : 1
2689
+ };
2690
+ }
737
2691
  /** Count non-overlapping occurrences of `needle` in `haystack`. */
738
2692
  function countOccurrences(haystack, needle) {
739
2693
  if (needle === "") return 0;
@@ -765,13 +2719,15 @@ function replaceFirst(haystack, needle, replacement) {
765
2719
  *
766
2720
  * @param backend - The dumb IO executor (local disk or in-memory mock).
767
2721
  * @param policy - The policy that bounds every operation.
2722
+ * @param runtime - State shared with other views of the same workspace
2723
+ * (its background commands); a fresh one when omitted.
768
2724
  *
769
2725
  * @example
770
2726
  * const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
771
2727
  * const { content, hash } = await ops.readFile("src/index.ts");
772
2728
  */
773
- function createOps(backend, policy) {
774
- return new Ops(backend, policy);
2729
+ function createOps(backend, policy, runtime) {
2730
+ return new Ops(backend, policy, runtime ?? { jobs: new ShellJobs() });
775
2731
  }
776
2732
 
777
2733
  //#endregion
@@ -783,7 +2739,7 @@ function createOps(backend, policy) {
783
2739
  * policy-driven cap (and the `truncated` flag) on top. Generous enough that
784
2740
  * ordinary command output is never clipped here.
785
2741
  */
786
- const MAX_STREAM_BYTES = 10 * 1024 * 1024;
2742
+ const MAX_STREAM_BYTES = 10485760;
787
2743
  /**
788
2744
  * Append a chunk to a capped list of buffers, tracking the running byte
789
2745
  * total and stopping once {@link MAX_STREAM_BYTES} is reached. Trailing bytes
@@ -866,6 +2822,74 @@ function toWin32CmdInvocation(argv) {
866
2822
  ]
867
2823
  };
868
2824
  }
2825
+ /** The shell a POSIX shell-mode line runs through — an absolute path, never a PATH lookup. */
2826
+ const POSIX_SHELL = "/bin/sh";
2827
+ /**
2828
+ * Work out the spawn for a command line.
2829
+ *
2830
+ * - argv mode: tokenize (no shell semantics) and spawn the argv directly —
2831
+ * on Windows through an individually-quoted `cmd.exe` wrapper.
2832
+ * - shell mode: `/bin/sh -c <line>`, or `cmd.exe /d /v:off /s /c "<line>"`
2833
+ * on Windows (`/d` skips AutoRun, `/v:off` disables delayed `!` expansion,
2834
+ * `/s` keeps the line verbatim inside the outer quotes). The ops layer has
2835
+ * already checked every command in the line.
2836
+ */
2837
+ function planSpawn(command, interpreter) {
2838
+ if (interpreter === "sh") {
2839
+ if (node_process.platform === "win32") return {
2840
+ file: process.env.ComSpec ?? "cmd.exe",
2841
+ args: [
2842
+ "/d",
2843
+ "/v:off",
2844
+ "/s",
2845
+ "/c",
2846
+ `"${command}"`
2847
+ ],
2848
+ windowsVerbatimArguments: true
2849
+ };
2850
+ return {
2851
+ file: POSIX_SHELL,
2852
+ args: ["-c", command],
2853
+ windowsVerbatimArguments: false
2854
+ };
2855
+ }
2856
+ const argv = tokenizeCommand(command);
2857
+ const executable = argv?.[0];
2858
+ if (argv === null || executable === void 0) return { refused: "Command was not executed: it is empty, has unbalanced quotes, or contains unquoted shell metacharacters (;, &, |, `, $, <, >, parentheses). Commands run without a shell — pass metacharacters inside quotes as literal arguments, or run one command at a time." };
2859
+ if (node_process.platform === "win32") {
2860
+ const invocation = toWin32CmdInvocation(argv);
2861
+ if (invocation === null) return { refused: "Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely." };
2862
+ return {
2863
+ ...invocation,
2864
+ windowsVerbatimArguments: true
2865
+ };
2866
+ }
2867
+ return {
2868
+ file: executable,
2869
+ args: argv.slice(1),
2870
+ windowsVerbatimArguments: false
2871
+ };
2872
+ }
2873
+ /**
2874
+ * Kill functions of every background process still running, so they can
2875
+ * be reaped if the Node process exits without the workspace being disposed
2876
+ * (detached process groups would otherwise outlive it).
2877
+ */
2878
+ const liveBackgroundKills = /* @__PURE__ */ new Set();
2879
+ let exitHookInstalled = false;
2880
+ /** Track a live background tree; installs (once) the exit hook that kills them. */
2881
+ function trackBackgroundProcess(kill) {
2882
+ liveBackgroundKills.add(kill);
2883
+ if (!exitHookInstalled) {
2884
+ exitHookInstalled = true;
2885
+ process.on("exit", () => {
2886
+ for (const killTreeNow of liveBackgroundKills) killTreeNow();
2887
+ });
2888
+ }
2889
+ return () => {
2890
+ liveBackgroundKills.delete(kill);
2891
+ };
2892
+ }
869
2893
  /**
870
2894
  * The real-disk executor: every filesystem method delegates to
871
2895
  * `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a
@@ -922,6 +2946,17 @@ var LocalBackend = class {
922
2946
  return _warlock_js_fs.fs.dirs.list(absDir);
923
2947
  }
924
2948
  /**
2949
+ * Describe a path without following a final symlink (`lstat` — like
2950
+ * `realpath`, a filesystem operation `@warlock.js/fs` does not expose).
2951
+ */
2952
+ async stat(absPath) {
2953
+ const stats = await (0, node_fs_promises.lstat)(absPath);
2954
+ return {
2955
+ type: stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
2956
+ size: stats.size
2957
+ };
2958
+ }
2959
+ /**
925
2960
  * Resolve symlinks and `..` segments to a canonical absolute path — the
926
2961
  * primitive the ops-layer jail uses to detect escapes. Delegates to
927
2962
  * `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`
@@ -931,14 +2966,17 @@ var LocalBackend = class {
931
2966
  return (0, node_fs_promises.realpath)(absPath);
932
2967
  }
933
2968
  /**
934
- * Run a command and capture its outcome. The command line is tokenized
935
- * into an argv (quotes respected, NO shell semantics — see
936
- * `tokenizeCommand`) and spawned **without a shell**, so metacharacters
937
- * like `;`, `&&`, `|`, backticks, and `$()` can never chain extra
938
- * commands past the ops layer's allowlist; a command they appear
939
- * unquoted in is refused with exit code 127. On Windows the argv runs
940
- * through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
2969
+ * Run a command and capture its outcome. In argv mode (the default) the
2970
+ * command line is tokenized into an argv (quotes respected, NO shell
2971
+ * semantics — see `tokenizeCommand`) and spawned **without a shell**, so
2972
+ * metacharacters like `;`, `&&`, `|`, backticks, and `$()` can never
2973
+ * chain extra commands past the ops layer's allowlist; a command they
2974
+ * appear unquoted in is refused with exit code 127. On Windows the argv
2975
+ * runs through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
941
2976
  * cannot be spawned shell-less) with every element individually quoted.
2977
+ * With `interpreter: "sh"` the line runs through `/bin/sh -c` (or
2978
+ * `cmd.exe /d /v:off /s /c` on Windows) — only after the ops layer has
2979
+ * checked every command in it.
942
2980
  * `cwd`, `env`, and the timeout are taken verbatim from the ops layer
943
2981
  * (the environment is NOT merged with `process.env`). On timeout the
944
2982
  * process tree is SIGKILLed and `timedOut` is set. When `signal` aborts
@@ -970,31 +3008,17 @@ var LocalBackend = class {
970
3008
  });
971
3009
  return;
972
3010
  }
973
- const argv = tokenizeCommand(command);
974
- const executable = argv?.[0];
975
- if (argv === null || executable === void 0) {
976
- refuse("Command was not executed: it is empty, has unbalanced quotes, or contains unquoted shell metacharacters (;, &, |, `, $, <, >, parentheses). Commands run without a shell — pass metacharacters inside quotes as literal arguments, or run one command at a time.");
3011
+ const plan = planSpawn(command, opts.interpreter ?? "none");
3012
+ if ("refused" in plan) {
3013
+ refuse(plan.refused);
977
3014
  return;
978
3015
  }
979
- let file = executable;
980
- let args = argv.slice(1);
981
- let windowsVerbatimArguments = false;
982
- if (node_process.platform === "win32") {
983
- const invocation = toWin32CmdInvocation(argv);
984
- if (invocation === null) {
985
- refuse("Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely.");
986
- return;
987
- }
988
- file = invocation.file;
989
- args = invocation.args;
990
- windowsVerbatimArguments = true;
991
- }
992
- const child = (0, node_child_process.spawn)(file, args, {
3016
+ const child = (0, node_child_process.spawn)(plan.file, plan.args, {
993
3017
  cwd: opts.cwd,
994
3018
  env: opts.env,
995
3019
  shell: false,
996
3020
  windowsHide: true,
997
- windowsVerbatimArguments,
3021
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
998
3022
  detached: node_process.platform !== "win32"
999
3023
  });
1000
3024
  const stdoutChunks = [];
@@ -1050,6 +3074,75 @@ var LocalBackend = class {
1050
3074
  });
1051
3075
  });
1052
3076
  }
3077
+ /**
3078
+ * Start a long-running command and return at once. Spawned like
3079
+ * {@link exec} (same argv/shell planning, own process group on POSIX), with
3080
+ * stdin closed. Output is decoded per stream with a `StringDecoder`, so a
3081
+ * chunk never ends inside a character. `kill()` kills the whole tree; a
3082
+ * still-running tree is also killed when the Node process exits.
3083
+ */
3084
+ start(command, opts) {
3085
+ const plan = planSpawn(command, opts.interpreter ?? "none");
3086
+ if ("refused" in plan) {
3087
+ opts.onOutput("stderr", plan.refused);
3088
+ return {
3089
+ done: Promise.resolve({ exitCode: 127 }),
3090
+ kill: () => {}
3091
+ };
3092
+ }
3093
+ const child = (0, node_child_process.spawn)(plan.file, plan.args, {
3094
+ cwd: opts.cwd,
3095
+ env: opts.env,
3096
+ shell: false,
3097
+ windowsHide: true,
3098
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
3099
+ detached: node_process.platform !== "win32",
3100
+ stdio: [
3101
+ "ignore",
3102
+ "pipe",
3103
+ "pipe"
3104
+ ]
3105
+ });
3106
+ const stdoutDecoder = new node_string_decoder.StringDecoder("utf8");
3107
+ const stderrDecoder = new node_string_decoder.StringDecoder("utf8");
3108
+ let finished = false;
3109
+ const kill = () => {
3110
+ if (!finished) killTree(child.pid, child);
3111
+ };
3112
+ const untrack = trackBackgroundProcess(kill);
3113
+ child.stdout?.on("data", (chunk) => {
3114
+ const text = stdoutDecoder.write(chunk);
3115
+ if (text !== "") opts.onOutput("stdout", text);
3116
+ });
3117
+ child.stderr?.on("data", (chunk) => {
3118
+ const text = stderrDecoder.write(chunk);
3119
+ if (text !== "") opts.onOutput("stderr", text);
3120
+ });
3121
+ const done = new Promise((resolve) => {
3122
+ const finish = (exitCode) => {
3123
+ if (finished) return;
3124
+ finished = true;
3125
+ untrack();
3126
+ const stdoutTail = stdoutDecoder.end();
3127
+ const stderrTail = stderrDecoder.end();
3128
+ if (stdoutTail !== "") opts.onOutput("stdout", stdoutTail);
3129
+ if (stderrTail !== "") opts.onOutput("stderr", stderrTail);
3130
+ resolve({ exitCode });
3131
+ };
3132
+ child.on("error", (error) => {
3133
+ opts.onOutput("stderr", `Command could not be started: ${error.message}`);
3134
+ finish(127);
3135
+ });
3136
+ child.on("close", (code, signal) => {
3137
+ finish(code === null ? 128 + (signal === "SIGKILL" ? 9 : 1) : code);
3138
+ });
3139
+ });
3140
+ return {
3141
+ ...child.pid !== void 0 ? { pid: child.pid } : {},
3142
+ done,
3143
+ kill
3144
+ };
3145
+ }
1053
3146
  };
1054
3147
  /**
1055
3148
  * Create the **local** workspace backend — the default executor that runs the
@@ -1171,6 +3264,40 @@ var MockBackend = class {
1171
3264
  async realpath(absPath) {
1172
3265
  return canonicalize(absPath);
1173
3266
  }
3267
+ async stat(absPath) {
3268
+ const canonical = canonicalize(absPath);
3269
+ const content = this.files.get(canonical);
3270
+ if (content !== void 0) return {
3271
+ type: "file",
3272
+ size: Buffer.byteLength(content, "utf8")
3273
+ };
3274
+ if (this.directories.has(canonical)) return {
3275
+ type: "directory",
3276
+ size: 0
3277
+ };
3278
+ throw new Error(`Mock backend: no such file or directory: ${canonical}`);
3279
+ }
3280
+ /**
3281
+ * Scripted background start: emits the registered `stdout` / `stderr`
3282
+ * synchronously, then exits with `exitCode` — or, when scripted with
3283
+ * `running: true`, stays running until `kill()` (exit code 137).
3284
+ */
3285
+ start(command, opts) {
3286
+ const scripted = this.commands.get(command);
3287
+ if (scripted?.stdout) opts.onOutput("stdout", scripted.stdout);
3288
+ if (scripted?.stderr) opts.onOutput("stderr", scripted.stderr);
3289
+ if (scripted?.running !== true) return {
3290
+ done: Promise.resolve({ exitCode: scripted?.exitCode ?? 0 }),
3291
+ kill: () => {}
3292
+ };
3293
+ let stop = () => {};
3294
+ return {
3295
+ done: new Promise((resolve) => {
3296
+ stop = resolve;
3297
+ }),
3298
+ kill: () => stop({ exitCode: 137 })
3299
+ };
3300
+ }
1174
3301
  async exec(command, opts) {
1175
3302
  if (opts?.signal?.aborted) return {
1176
3303
  exitCode: 130,
@@ -1261,6 +3388,10 @@ function isMockBackendSeed(seed) {
1261
3388
  */
1262
3389
  /** The vendor tag stamped on every issue these builders produce. */
1263
3390
  const VENDOR = "ai-workspace";
3391
+ /** Attach a JSON Schema description to a hand-rolled Standard Schema. */
3392
+ function withJsonSchema(schema, jsonSchema) {
3393
+ return Object.assign(schema, { jsonSchema });
3394
+ }
1264
3395
  /** Required string field — rejects anything that is not a string. */
1265
3396
  function stringField() {
1266
3397
  return (value, key) => {
@@ -1311,24 +3442,25 @@ function optionalBooleanField() {
1311
3442
  };
1312
3443
  }
1313
3444
  /**
1314
- * Build a {@link StandardSchemaV1} for a flat object whose every property
1315
- * is validated by a {@link FieldValidator}. The input must be a non-null
1316
- * object; each declared field is validated and the (possibly coerced)
1317
- * values are collected into the typed result. All field issues are merged
1318
- * so the caller sees every problem at once.
1319
- *
1320
- * `T` is constrained to `object` rather than `Record<string, unknown>` so
1321
- * the tool IO `interface`s (which carry no implicit string index
1322
- * signature) satisfy it directly — only the declared keys in `shape` are
1323
- * ever read, so a string index signature is never required.
1324
- *
1325
- * @example
1326
- * const schema = objectSchema<{ path: string; limit?: number }>({
1327
- * path: stringField(),
1328
- * limit: optionalNumberField(),
1329
- * });
3445
+ * Optional array-of-strings field — accepts `undefined` or an array whose
3446
+ * every element is a string (at most `maxItems` of them).
1330
3447
  */
1331
- function objectSchema(shape) {
3448
+ function optionalStringArrayField(maxItems = 50) {
3449
+ return (value, key) => {
3450
+ if (value === void 0) return { value: void 0 };
3451
+ if (Array.isArray(value) && value.length <= maxItems && value.every((item) => typeof item === "string")) return { value };
3452
+ return { issues: [{
3453
+ message: `"${key}" must be an array of at most ${maxItems} strings when provided`,
3454
+ path: [key]
3455
+ }] };
3456
+ };
3457
+ }
3458
+ function objectSchema(shape, jsonSchema) {
3459
+ const schema = buildObjectSchema(shape);
3460
+ return jsonSchema === void 0 ? schema : withJsonSchema(schema, jsonSchema);
3461
+ }
3462
+ /** The validator behind {@link objectSchema}. */
3463
+ function buildObjectSchema(shape) {
1332
3464
  return { "~standard": {
1333
3465
  version: 1,
1334
3466
  vendor: VENDOR,
@@ -1352,18 +3484,191 @@ function objectSchema(shape) {
1352
3484
  } };
1353
3485
  }
1354
3486
 
3487
+ //#endregion
3488
+ //#region ../../packages/ai-workspace/src/tools/json-schemas.ts
3489
+ /**
3490
+ * The JSON Schemas the workspace tools publish for their input (read by
3491
+ * `extractJsonSchema` and sent to the provider with each tool), kept short
3492
+ * because they ride along on every model call. They describe what the
3493
+ * hand-rolled validators accept; the validators remain the authority.
3494
+ */
3495
+ const string = (description) => ({
3496
+ type: "string",
3497
+ description
3498
+ });
3499
+ const integer = (description) => ({
3500
+ type: "integer",
3501
+ description
3502
+ });
3503
+ const boolean = (description) => ({
3504
+ type: "boolean",
3505
+ description
3506
+ });
3507
+ /** `read_file`. */
3508
+ const READ_FILE_JSON_SCHEMA = {
3509
+ type: "object",
3510
+ properties: {
3511
+ path: string("Workspace-relative file path."),
3512
+ startLine: integer("1-based first line to return."),
3513
+ limit: integer("Most lines to return.")
3514
+ },
3515
+ required: ["path"]
3516
+ };
3517
+ /** `edit_file`. */
3518
+ const EDIT_FILE_JSON_SCHEMA = {
3519
+ type: "object",
3520
+ properties: {
3521
+ path: string("Workspace-relative file path."),
3522
+ oldString: string("Exact text to replace; must be unique unless replaceAll."),
3523
+ newString: string("Replacement text."),
3524
+ replaceAll: boolean("Replace every occurrence."),
3525
+ expectHash: string("Hash from read_file; rejects the edit if the file changed.")
3526
+ },
3527
+ required: [
3528
+ "path",
3529
+ "oldString",
3530
+ "newString"
3531
+ ]
3532
+ };
3533
+ /** `multi_edit`. */
3534
+ const MULTI_EDIT_JSON_SCHEMA = {
3535
+ type: "object",
3536
+ properties: {
3537
+ path: string("Workspace-relative file path."),
3538
+ edits: {
3539
+ type: "array",
3540
+ description: "Replacements applied in order, each to the result of the previous; all or none.",
3541
+ minItems: 1,
3542
+ maxItems: 100,
3543
+ items: {
3544
+ type: "object",
3545
+ properties: {
3546
+ oldString: string("Exact text to replace; must be unique unless replaceAll."),
3547
+ newString: string("Replacement text."),
3548
+ replaceAll: boolean("Replace every occurrence.")
3549
+ },
3550
+ required: ["oldString", "newString"]
3551
+ }
3552
+ },
3553
+ expectHash: string("Hash from read_file; rejects every edit if the file changed.")
3554
+ },
3555
+ required: ["path", "edits"]
3556
+ };
3557
+ /** `write_file`. */
3558
+ const WRITE_FILE_JSON_SCHEMA = {
3559
+ type: "object",
3560
+ properties: {
3561
+ path: string("Workspace-relative file path."),
3562
+ content: string("Full file content.")
3563
+ },
3564
+ required: ["path", "content"]
3565
+ };
3566
+ /** `list_dir`. */
3567
+ const LIST_DIR_JSON_SCHEMA = {
3568
+ type: "object",
3569
+ properties: {
3570
+ path: string("Workspace-relative directory (default \".\")."),
3571
+ depth: integer("Levels to list; 1 = direct children (default)."),
3572
+ ignore: {
3573
+ type: "array",
3574
+ items: { type: "string" },
3575
+ description: "Extra globs to leave out."
3576
+ }
3577
+ }
3578
+ };
3579
+ /** `run_shell` (the `background` property is only listed when background commands are enabled). */
3580
+ function runShellJsonSchema(background) {
3581
+ return {
3582
+ type: "object",
3583
+ properties: {
3584
+ command: string("The command line to run."),
3585
+ timeoutMs: integer("Wall-clock cap in ms (can only lower the workspace limit)."),
3586
+ ...background ? { background: boolean("Start it in the background and return its id at once (dev servers, watchers).") } : {}
3587
+ },
3588
+ required: ["command"]
3589
+ };
3590
+ }
3591
+ /** `shell_output`. */
3592
+ const SHELL_OUTPUT_JSON_SCHEMA = {
3593
+ type: "object",
3594
+ properties: {
3595
+ id: string("Background command id from run_shell."),
3596
+ since: integer("A cursor from an earlier result; omit for output since the last read."),
3597
+ waitMs: integer("Wait up to this long (max 30000) for new output.")
3598
+ },
3599
+ required: ["id"]
3600
+ };
3601
+ /** `shell_kill`. */
3602
+ const SHELL_KILL_JSON_SCHEMA = {
3603
+ type: "object",
3604
+ properties: { id: string("Background command id from run_shell.") },
3605
+ required: ["id"]
3606
+ };
3607
+ /** `run_tests`. */
3608
+ const RUN_TESTS_JSON_SCHEMA = {
3609
+ type: "object",
3610
+ properties: { pattern: string("Optional test path or name filter.") }
3611
+ };
3612
+ /** `grep`. */
3613
+ const GREP_JSON_SCHEMA = {
3614
+ type: "object",
3615
+ properties: {
3616
+ pattern: string("Regular expression to search for."),
3617
+ glob: string("Only search files matching this glob (e.g. \"src/**/*.ts\")."),
3618
+ ignoreCase: boolean("Case-insensitive match.")
3619
+ },
3620
+ required: ["pattern"]
3621
+ };
3622
+ /** `glob`. */
3623
+ const GLOB_JSON_SCHEMA = {
3624
+ type: "object",
3625
+ properties: { pattern: string("Glob such as \"src/**/*.ts\".") },
3626
+ required: ["pattern"]
3627
+ };
3628
+ /** `todo_write`. */
3629
+ const TODO_WRITE_JSON_SCHEMA = {
3630
+ type: "object",
3631
+ properties: { todos: {
3632
+ type: "array",
3633
+ description: "The complete updated list (replaces the old one).",
3634
+ maxItems: 100,
3635
+ items: {
3636
+ type: "object",
3637
+ properties: {
3638
+ id: string("Stable id; omit to number items by position."),
3639
+ content: string("The step."),
3640
+ status: {
3641
+ type: "string",
3642
+ enum: [
3643
+ "pending",
3644
+ "in_progress",
3645
+ "completed"
3646
+ ]
3647
+ }
3648
+ },
3649
+ required: ["content", "status"]
3650
+ }
3651
+ } },
3652
+ required: ["todos"]
3653
+ };
3654
+ /** `todo_read`. */
3655
+ const TODO_READ_JSON_SCHEMA = {
3656
+ type: "object",
3657
+ properties: {}
3658
+ };
3659
+
1355
3660
  //#endregion
1356
3661
  //#region ../../packages/ai-workspace/src/tools/edit-file.ts
1357
3662
  /** Default tool name exposed to the LLM. */
1358
- const DEFAULT_NAME$2 = "edit_file";
3663
+ const DEFAULT_NAME$6 = "edit_file";
1359
3664
  /** Input schema for the `edit_file` tool. */
1360
- const inputSchema$2 = objectSchema({
3665
+ const inputSchema$6 = objectSchema({
1361
3666
  path: stringField(),
1362
3667
  oldString: stringField(),
1363
3668
  newString: stringField(),
1364
3669
  replaceAll: optionalBooleanField(),
1365
3670
  expectHash: optionalStringField()
1366
- });
3671
+ }, EDIT_FILE_JSON_SCHEMA);
1367
3672
  /**
1368
3673
  * Build the agent-facing `edit_file` tool over a workspace's policy-
1369
3674
  * enforced {@link WorkspaceOps}.
@@ -1395,9 +3700,9 @@ const inputSchema$2 = objectSchema({
1395
3700
  */
1396
3701
  function makeEditFileTool(ops, options) {
1397
3702
  return (0, _warlock_js_ai.tool)({
1398
- name: options?.name ?? DEFAULT_NAME$2,
3703
+ name: options?.name ?? DEFAULT_NAME$6,
1399
3704
  description: "Replace an exact substring in a workspace file. oldString must match uniquely unless replaceAll is set. Pass expectHash (from read_file) to reject the edit if the file changed since you read it. Returns the number of replacements and the new content hash.",
1400
- input: inputSchema$2,
3705
+ input: inputSchema$6,
1401
3706
  async execute(input) {
1402
3707
  return ops.editFile(input);
1403
3708
  }
@@ -1412,7 +3717,7 @@ function makeEditFileTool(ops, options) {
1412
3717
  * (no schema library, matching the validator idiom the `@warlock.js/ai`
1413
3718
  * tool runtime expects).
1414
3719
  */
1415
- const globInputSchema = objectSchema({ pattern: stringField() });
3720
+ const globInputSchema = objectSchema({ pattern: stringField() }, GLOB_JSON_SCHEMA);
1416
3721
  /**
1417
3722
  * Build the agent-facing `glob` tool — resolve a glob pattern to the
1418
3723
  * matching workspace-relative paths within the jail. The returned
@@ -1456,7 +3761,7 @@ const grepInputSchema = objectSchema({
1456
3761
  pattern: stringField(),
1457
3762
  glob: optionalStringField(),
1458
3763
  ignoreCase: optionalBooleanField()
1459
- });
3764
+ }, GREP_JSON_SCHEMA);
1460
3765
  /**
1461
3766
  * Build the agent-facing `grep` tool — a regex content search across the
1462
3767
  * jailed file set. The returned {@link ToolContract} validates the LLM's
@@ -1490,16 +3795,174 @@ function makeGrepTool(ops, options) {
1490
3795
  });
1491
3796
  }
1492
3797
 
3798
+ //#endregion
3799
+ //#region ../../packages/ai-workspace/src/tools/list-dir.ts
3800
+ /** Default tool name exposed to the LLM. */
3801
+ const DEFAULT_NAME$5 = "list_dir";
3802
+ /** Input schema for the `list_dir` tool. */
3803
+ const inputSchema$5 = objectSchema({
3804
+ path: optionalStringField(),
3805
+ depth: optionalNumberField(),
3806
+ ignore: optionalStringArrayField()
3807
+ }, LIST_DIR_JSON_SCHEMA);
3808
+ /**
3809
+ * Build the agent-facing `list_dir` tool over a workspace's policy-enforced
3810
+ * {@link WorkspaceOps}: a bounded, jailed listing of one directory.
3811
+ *
3812
+ * Entries come back breadth-first (each level sorted by name) with their
3813
+ * type and — for files — size, down to `depth` levels (capped by
3814
+ * `list.maxDepth`) and at most `list.maxEntries` entries (`truncated` says
3815
+ * when the cap hit). `denyPaths` are always left out, as are the
3816
+ * `list.ignore` globs (default `node_modules`, `.git`) and any `ignore`
3817
+ * globs the model passes. Symlinks are reported as `"symlink"` and never
3818
+ * followed. A path outside the jail surfaces as a `WorkspacePolicyError` in
3819
+ * the result's `error` field.
3820
+ *
3821
+ * @param ops - The shared, policy-enforced operation layer.
3822
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
3823
+ *
3824
+ * @example
3825
+ * const listDir = makeListDirTool(ops);
3826
+ * const { data } = await listDir.invoke({ path: "src", depth: 2 });
3827
+ * for (const entry of data?.entries ?? []) console.log(entry.type, entry.path, entry.size);
3828
+ */
3829
+ function makeListDirTool(ops, options) {
3830
+ return (0, _warlock_js_ai.tool)({
3831
+ name: options?.name ?? DEFAULT_NAME$5,
3832
+ description: "List a workspace directory: entries with their type and file size, breadth-first, `depth` levels deep (default 1). Dependency and VCS folders are left out unless you list them directly.",
3833
+ action: (input) => `Listing ${typeof input.path === "string" ? input.path : "."}`,
3834
+ input: inputSchema$5,
3835
+ execute: (input) => ops.listDir(input.path ?? ".", {
3836
+ ...input.depth !== void 0 ? { depth: input.depth } : {},
3837
+ ...input.ignore !== void 0 ? { ignore: input.ignore } : {}
3838
+ })
3839
+ });
3840
+ }
3841
+
3842
+ //#endregion
3843
+ //#region ../../packages/ai-workspace/src/tools/multi-edit.ts
3844
+ /** Default tool name exposed to the LLM. */
3845
+ const DEFAULT_NAME$4 = "multi_edit";
3846
+ /** Validate one element of `edits`. */
3847
+ function validateEdit(value, index) {
3848
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{
3849
+ message: `edits[${index}] must be an object`,
3850
+ path: ["edits", index]
3851
+ }] };
3852
+ const candidate = value;
3853
+ const issues = [];
3854
+ if (typeof candidate.oldString !== "string" || candidate.oldString === "") issues.push({
3855
+ message: `edits[${index}].oldString must be a non-empty string`,
3856
+ path: [
3857
+ "edits",
3858
+ index,
3859
+ "oldString"
3860
+ ]
3861
+ });
3862
+ if (typeof candidate.newString !== "string") issues.push({
3863
+ message: `edits[${index}].newString must be a string`,
3864
+ path: [
3865
+ "edits",
3866
+ index,
3867
+ "newString"
3868
+ ]
3869
+ });
3870
+ if (candidate.replaceAll !== void 0 && typeof candidate.replaceAll !== "boolean") issues.push({
3871
+ message: `edits[${index}].replaceAll must be a boolean when provided`,
3872
+ path: [
3873
+ "edits",
3874
+ index,
3875
+ "replaceAll"
3876
+ ]
3877
+ });
3878
+ if (issues.length > 0 || typeof candidate.oldString !== "string" || typeof candidate.newString !== "string") return { issues };
3879
+ return { edit: {
3880
+ oldString: candidate.oldString,
3881
+ newString: candidate.newString,
3882
+ ...candidate.replaceAll === true ? { replaceAll: true } : {}
3883
+ } };
3884
+ }
3885
+ /** Input schema for the `multi_edit` tool. */
3886
+ const inputSchema$4 = withJsonSchema({ "~standard": {
3887
+ version: 1,
3888
+ vendor: "ai-workspace",
3889
+ validate(value) {
3890
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
3891
+ const candidate = value;
3892
+ const issues = [];
3893
+ const edits = [];
3894
+ if (typeof candidate.path !== "string" || candidate.path === "") issues.push({
3895
+ message: "\"path\" must be a non-empty string",
3896
+ path: ["path"]
3897
+ });
3898
+ if (candidate.expectHash !== void 0 && typeof candidate.expectHash !== "string") issues.push({
3899
+ message: "\"expectHash\" must be a string when provided",
3900
+ path: ["expectHash"]
3901
+ });
3902
+ if (!Array.isArray(candidate.edits) || candidate.edits.length === 0 || candidate.edits.length > 100) issues.push({
3903
+ message: `"edits" must be an array of 1 to ${100} edits`,
3904
+ path: ["edits"]
3905
+ });
3906
+ else candidate.edits.forEach((item, index) => {
3907
+ const outcome = validateEdit(item, index);
3908
+ if ("issues" in outcome) issues.push(...outcome.issues);
3909
+ else edits.push(outcome.edit);
3910
+ });
3911
+ if (issues.length > 0 || typeof candidate.path !== "string") return { issues };
3912
+ return { value: {
3913
+ path: candidate.path,
3914
+ edits,
3915
+ ...typeof candidate.expectHash === "string" ? { expectHash: candidate.expectHash } : {}
3916
+ } };
3917
+ }
3918
+ } }, MULTI_EDIT_JSON_SCHEMA);
3919
+ /**
3920
+ * Build the agent-facing `multi_edit` tool over a workspace's policy-
3921
+ * enforced {@link WorkspaceOps}: several exact-string replacements in one
3922
+ * file, **atomic** — every edit applies, in order (each against the result
3923
+ * of the previous ones), and the file is written once; or one edit fails
3924
+ * and nothing is written.
3925
+ *
3926
+ * Each edit follows the `edit_file` rules: `oldString` must be found, and
3927
+ * found exactly once unless `replaceAll`. An optional `expectHash` (from
3928
+ * `read_file`) rejects the whole call when the file changed since it was
3929
+ * read. A failure surfaces as a `WorkspaceEditError` in the result's
3930
+ * `error` field, with `editIndex` naming the failing edit.
3931
+ *
3932
+ * @param ops - The shared, policy-enforced operation layer.
3933
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
3934
+ *
3935
+ * @example
3936
+ * const multiEdit = makeMultiEditTool(ops);
3937
+ * const { data, error } = await multiEdit.invoke({
3938
+ * path: "src/cart.ts",
3939
+ * expectHash,
3940
+ * edits: [
3941
+ * { oldString: "let total = 0;", newString: "let total = 0n;" },
3942
+ * { oldString: "total += price;", newString: "total += BigInt(price);" },
3943
+ * ],
3944
+ * });
3945
+ */
3946
+ function makeMultiEditTool(ops, options) {
3947
+ return (0, _warlock_js_ai.tool)({
3948
+ name: options?.name ?? DEFAULT_NAME$4,
3949
+ description: "Make several exact-string replacements in one workspace file at once. Edits apply in order, each to the result of the previous; if any edit fails (text not found, or not unique without replaceAll) none are applied. Pass expectHash (from read_file) to reject the call if the file changed since you read it.",
3950
+ action: (input) => `Editing ${typeof input.path === "string" ? input.path : "a file"}`,
3951
+ input: inputSchema$4,
3952
+ execute: (input) => ops.multiEdit(input)
3953
+ });
3954
+ }
3955
+
1493
3956
  //#endregion
1494
3957
  //#region ../../packages/ai-workspace/src/tools/read-file.ts
1495
3958
  /** Default tool name exposed to the LLM. */
1496
- const DEFAULT_NAME$1 = "read_file";
3959
+ const DEFAULT_NAME$3 = "read_file";
1497
3960
  /** Input schema for the `read_file` tool. */
1498
- const inputSchema$1 = objectSchema({
3961
+ const inputSchema$3 = objectSchema({
1499
3962
  path: stringField(),
1500
3963
  startLine: optionalNumberField(),
1501
3964
  limit: optionalNumberField()
1502
- });
3965
+ }, READ_FILE_JSON_SCHEMA);
1503
3966
  /**
1504
3967
  * Build the agent-facing `read_file` tool over a workspace's policy-
1505
3968
  * enforced {@link WorkspaceOps}.
@@ -1526,9 +3989,9 @@ const inputSchema$1 = objectSchema({
1526
3989
  */
1527
3990
  function makeReadFileTool(ops, options) {
1528
3991
  return (0, _warlock_js_ai.tool)({
1529
- name: options?.name ?? DEFAULT_NAME$1,
3992
+ name: options?.name ?? DEFAULT_NAME$3,
1530
3993
  description: "Read a file from the workspace, returning a numbered line window plus the file's content hash. Pass the hash to edit_file's expectHash to guard against editing a stale version. Use startLine/limit to page through large files. Output is byte-capped by the workspace policy; when bytesCapped is true the last returned line may be cut short.",
1531
- input: inputSchema$1,
3994
+ input: inputSchema$3,
1532
3995
  async execute(input) {
1533
3996
  return ops.readFile(input.path, {
1534
3997
  offset: input.startLine !== void 0 ? Math.max(1, input.startLine) : 1,
@@ -1545,64 +4008,67 @@ const DEFAULT_RUN_SHELL_TOOL_NAME = "run_shell";
1545
4008
  /**
1546
4009
  * Hand-rolled Standard Schema for {@link RunShellInput}. We validate the
1547
4010
  * model's arguments without a runtime schema dependency: `command` must be
1548
- * a non-empty string, and `timeoutMs` (when present) a positive number.
1549
- * Invalid args surface as a `SchemaValidationError` in the tool result's
1550
- * `error` field rather than reaching `ops.exec`.
1551
- */
1552
- const runShellInputSchema = { "~standard": {
1553
- version: 1,
1554
- vendor: "@warlock.js/ai-workspace",
1555
- validate: (value) => {
1556
- if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
1557
- const candidate = value;
1558
- if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
1559
- message: "command must be a non-empty string",
1560
- path: ["command"]
1561
- }] };
1562
- if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
1563
- message: "timeoutMs must be a positive number",
1564
- path: ["timeoutMs"]
1565
- }] };
1566
- const result = { command: candidate.command };
1567
- if (candidate.timeoutMs !== void 0) result.timeoutMs = candidate.timeoutMs;
1568
- return { value: result };
1569
- }
1570
- } };
1571
- /**
1572
- * Build the `run_shell` tool — a {@link ToolContract} that runs a single
1573
- * shell command through the policy-enforced {@link WorkspaceOps} layer.
1574
- *
1575
- * The command's leading executable basename is gated against the shell
1576
- * allow/deny policy by `ops.exec`; a blocked command throws a
1577
- * `WorkspacePolicyError` which the `tool()` runtime catches and surfaces
1578
- * in the result's `error` field (never a thrown run-killer), so the agent
1579
- * reads the refusal as tool data and self-corrects. A command that runs
1580
- * but exits non-zero is *not* an error — its `exitCode`/`stderr` come back
1581
- * in `data` for the agent to inspect.
1582
- *
1583
- * The agent run's cancellation `signal` (from the dispatch `ctx`) is passed
1584
- * to `ops.exec` per invocation: a cancelled run never starts the command,
1585
- * and one cancelled mid-command kills its process tree (`cancelled: true`).
1586
- *
1587
- * @param ops - The policy-enforced operation layer to delegate `exec` to.
1588
- * @param options - Optional tool-name override.
1589
- *
1590
- * @example
1591
- * const runShell = makeRunShellTool(ops);
1592
- * const { data, error } = await runShell.invoke({ command: "npm run build" });
1593
- * if (error) handleDenied(error);
1594
- * else console.log(data.exitCode, data.stdout);
4011
+ * a non-empty string, `timeoutMs` (when present) a positive number and
4012
+ * `background` (when present) a boolean. Invalid args surface as a
4013
+ * `SchemaValidationError` in the tool result's `error` field rather than
4014
+ * reaching `ops.exec`.
1595
4015
  */
4016
+ function runShellInputSchema(background) {
4017
+ return withJsonSchema({ "~standard": {
4018
+ version: 1,
4019
+ vendor: "@warlock.js/ai-workspace",
4020
+ validate: (value) => {
4021
+ if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
4022
+ const candidate = value;
4023
+ if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
4024
+ message: "command must be a non-empty string",
4025
+ path: ["command"]
4026
+ }] };
4027
+ if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
4028
+ message: "timeoutMs must be a positive number",
4029
+ path: ["timeoutMs"]
4030
+ }] };
4031
+ if (candidate.background !== void 0 && typeof candidate.background !== "boolean") return { issues: [{
4032
+ message: "background must be a boolean",
4033
+ path: ["background"]
4034
+ }] };
4035
+ const result = { command: candidate.command };
4036
+ if (typeof candidate.timeoutMs === "number") result.timeoutMs = candidate.timeoutMs;
4037
+ if (candidate.background === true) result.background = true;
4038
+ return { value: result };
4039
+ }
4040
+ } }, runShellJsonSchema(background));
4041
+ }
4042
+ /** The lower of the model's timeout and the cap (either may be absent). */
4043
+ function cappedTimeout(requested, cap) {
4044
+ if (requested === void 0) return cap;
4045
+ return cap === void 0 ? requested : Math.min(requested, cap);
4046
+ }
1596
4047
  function makeRunShellTool(ops, options) {
4048
+ const background = options?.background === true;
1597
4049
  return (0, _warlock_js_ai.tool)({
1598
4050
  name: options?.name ?? DEFAULT_RUN_SHELL_TOOL_NAME,
1599
- description: "Run a single shell command inside the workspace. The command's executable must be permitted by the shell policy; output is byte-capped and the run is time-limited. A non-zero exit code is returned as data, not an error.",
4051
+ description: "Run a shell command inside the workspace. Every command in it must be permitted by the shell policy; a refusal explains how to fix the command. Output is byte-capped and the run is time-limited. A non-zero exit code is returned as data, not an error." + (background ? " Pass background: true for a long-running command (dev server, watcher): it returns an id at once; read its output with shell_output and stop it with shell_kill." : ""),
1600
4052
  action: (input) => `Running \`${input.command}\``,
1601
- input: runShellInputSchema,
1602
- execute: (input, ctx) => ops.exec(input.command, {
1603
- timeoutMs: input.timeoutMs,
1604
- ...ctx?.signal ? { signal: ctx.signal } : {}
1605
- })
4053
+ input: runShellInputSchema(background),
4054
+ execute: async (input, ctx) => {
4055
+ if (input.background === true) {
4056
+ if (!background) throw new WorkspacePolicyError("Background commands are not enabled for this workspace; run the command without background.", {
4057
+ type: "denied-command",
4058
+ reason: "not-allowed",
4059
+ command: input.command
4060
+ });
4061
+ return {
4062
+ ...await ops.startShell(input.command, input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : void 0),
4063
+ background: true
4064
+ };
4065
+ }
4066
+ const timeoutMs = cappedTimeout(input.timeoutMs, options?.maxTimeoutMs);
4067
+ return ops.exec(input.command, {
4068
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
4069
+ ...ctx?.signal ? { signal: ctx.signal } : {}
4070
+ });
4071
+ }
1606
4072
  });
1607
4073
  }
1608
4074
 
@@ -1621,7 +4087,7 @@ const DEFAULT_TEST_COMMAND = "npm test";
1621
4087
  * happens without a runtime schema dependency, mirroring the wider tool
1622
4088
  * layer.
1623
4089
  */
1624
- const runTestsInputSchema = { "~standard": {
4090
+ const runTestsInputSchema = withJsonSchema({ "~standard": {
1625
4091
  version: 1,
1626
4092
  vendor: "@warlock.js/ai-workspace",
1627
4093
  validate: (value) => {
@@ -1640,7 +4106,12 @@ const runTestsInputSchema = { "~standard": {
1640
4106
  if (candidate.pattern !== void 0) result.pattern = candidate.pattern;
1641
4107
  return { value: result };
1642
4108
  }
1643
- } };
4109
+ } }, RUN_TESTS_JSON_SCHEMA);
4110
+ /** Quote the test pattern as one literal argument for the line's interpreter. */
4111
+ function quotePattern(pattern, interpreter) {
4112
+ if (interpreter === "sh" && process.platform !== "win32") return `'${pattern.replace(/'/g, "'\\''")}'`;
4113
+ return `"${pattern}"`;
4114
+ }
1644
4115
  /**
1645
4116
  * Build the `run_tests` tool — a {@link ToolContract} convenience over
1646
4117
  * `run_shell` that runs the workspace's configured test command through
@@ -1673,12 +4144,275 @@ function makeRunTestsTool(ops, options) {
1673
4144
  action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
1674
4145
  input: runTestsInputSchema,
1675
4146
  execute: (input, ctx) => {
1676
- const command = input.pattern ? `${baseCommand} "${input.pattern}"` : baseCommand;
4147
+ const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none")}` : baseCommand;
1677
4148
  return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
1678
4149
  }
1679
4150
  });
1680
4151
  }
1681
4152
 
4153
+ //#endregion
4154
+ //#region ../../packages/ai-workspace/src/tools/shell-kill.ts
4155
+ /** Default tool name exposed to the LLM. */
4156
+ const DEFAULT_NAME$2 = "shell_kill";
4157
+ /** Input schema for the `shell_kill` tool. */
4158
+ const inputSchema$2 = objectSchema({ id: stringField() }, SHELL_KILL_JSON_SCHEMA);
4159
+ /**
4160
+ * Build the agent-facing `shell_kill` tool: stop a background command
4161
+ * started with `run_shell({ background: true })`, killing its whole process
4162
+ * tree, and return its final state. Killing a command that already stopped
4163
+ * just reports its state. An unknown id surfaces as a `WorkspaceJobError`
4164
+ * in the result's `error` field.
4165
+ *
4166
+ * @param ops - The shared, policy-enforced operation layer.
4167
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4168
+ *
4169
+ * @example
4170
+ * const { data } = await makeShellKillTool(ops).invoke({ id: "job-1" });
4171
+ * console.log(data?.status); // "killed"
4172
+ */
4173
+ function makeShellKillTool(ops, options) {
4174
+ return (0, _warlock_js_ai.tool)({
4175
+ name: options?.name ?? DEFAULT_NAME$2,
4176
+ description: "Stop a background command started with run_shell (background: true), killing it and everything it started. Returns its final state.",
4177
+ action: (input) => `Stopping ${typeof input.id === "string" ? input.id : "a background command"}`,
4178
+ input: inputSchema$2,
4179
+ execute: (input) => ops.shellKill(input.id)
4180
+ });
4181
+ }
4182
+
4183
+ //#endregion
4184
+ //#region ../../packages/ai-workspace/src/tools/shell-output.ts
4185
+ /** Default tool name exposed to the LLM. */
4186
+ const DEFAULT_NAME$1 = "shell_output";
4187
+ /** Input schema for the `shell_output` tool. */
4188
+ const inputSchema$1 = objectSchema({
4189
+ id: stringField(),
4190
+ since: optionalNumberField(),
4191
+ waitMs: optionalNumberField()
4192
+ }, SHELL_OUTPUT_JSON_SCHEMA);
4193
+ /**
4194
+ * Build the agent-facing `shell_output` tool: the new output of a
4195
+ * background command started with `run_shell({ background: true })`, plus
4196
+ * its state (`running` / `exited` / `killed` / `timed-out`, `exitCode`).
4197
+ *
4198
+ * Without `since` it returns everything produced since the previous read;
4199
+ * `cursor` in the result is the position to pass as `since` next time.
4200
+ * `waitMs` (at most 30 s) waits for new output or for the command to stop
4201
+ * when there is nothing new yet — the agent run's signal ends the wait
4202
+ * early. Only the newest `shell.maxOutputBytes` of a command's output are
4203
+ * kept; `droppedBytes` says how much the reader missed.
4204
+ *
4205
+ * @param ops - The shared, policy-enforced operation layer.
4206
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4207
+ *
4208
+ * @example
4209
+ * const { data } = await makeShellOutputTool(ops).invoke({ id: "job-1", waitMs: 2_000 });
4210
+ * if (data?.stdout.includes("ready")) console.log("dev server is up");
4211
+ */
4212
+ function makeShellOutputTool(ops, options) {
4213
+ return (0, _warlock_js_ai.tool)({
4214
+ name: options?.name ?? DEFAULT_NAME$1,
4215
+ description: "Read new output of a background command started with run_shell (background: true) and see whether it is still running. Returns output since the last read; waitMs waits briefly for more.",
4216
+ action: (input) => `Reading output of ${typeof input.id === "string" ? input.id : "a background command"}`,
4217
+ input: inputSchema$1,
4218
+ execute: (input, ctx) => ops.shellOutput(input.id, {
4219
+ ...input.since !== void 0 ? { since: input.since } : {},
4220
+ ...input.waitMs !== void 0 ? { waitMs: input.waitMs } : {},
4221
+ ...ctx?.signal ? { signal: ctx.signal } : {}
4222
+ })
4223
+ });
4224
+ }
4225
+
4226
+ //#endregion
4227
+ //#region ../../packages/ai-workspace/src/todos.ts
4228
+ /** Most items a todo list may hold. */
4229
+ const MAX_TODOS = 100;
4230
+ /** Longest `content` one todo item may have. */
4231
+ const MAX_TODO_CONTENT_LENGTH = 1e3;
4232
+ /** The statuses a todo item may have, in display order. */
4233
+ const TODO_STATUSES = [
4234
+ "pending",
4235
+ "in_progress",
4236
+ "completed"
4237
+ ];
4238
+ /**
4239
+ * Validate a whole replacement list. Returns the problems found (empty when
4240
+ * the list is valid) so the tool schema and the direct method share one
4241
+ * set of rules.
4242
+ */
4243
+ function todoListProblems(todos) {
4244
+ const problems = [];
4245
+ const ids = /* @__PURE__ */ new Set();
4246
+ if (todos.length > 100) problems.push(`a todo list holds at most ${100} items (got ${todos.length})`);
4247
+ todos.forEach((todo, index) => {
4248
+ if (todo.content.trim() === "") problems.push(`todos[${index}].content must not be empty`);
4249
+ if (todo.content.length > 1e3) problems.push(`todos[${index}].content is longer than ${MAX_TODO_CONTENT_LENGTH} characters`);
4250
+ if (!TODO_STATUSES.includes(todo.status)) problems.push(`todos[${index}].status must be one of ${TODO_STATUSES.join(", ")}`);
4251
+ const id = todo.id ?? String(index + 1);
4252
+ if (ids.has(id)) problems.push(`todos[${index}].id "${id}" is used more than once`);
4253
+ ids.add(id);
4254
+ });
4255
+ return problems;
4256
+ }
4257
+ /**
4258
+ * The in-memory todo list of one workspace (shared by its projections).
4259
+ * `write` replaces the whole list, the way an agent's plan tool usually
4260
+ * works: the model sends the full updated list every time.
4261
+ */
4262
+ var TodoStore = class {
4263
+ constructor() {
4264
+ this.items = [];
4265
+ }
4266
+ /** The current list and a count per status. */
4267
+ read() {
4268
+ const counts = {
4269
+ pending: 0,
4270
+ in_progress: 0,
4271
+ completed: 0
4272
+ };
4273
+ for (const item of this.items) counts[item.status]++;
4274
+ return {
4275
+ todos: this.items.map((item) => ({ ...item })),
4276
+ counts
4277
+ };
4278
+ }
4279
+ /**
4280
+ * Replace the list. Items without an `id` get their 1-based position as
4281
+ * one. Throws a `RangeError` naming every problem when the list is invalid
4282
+ * (too long, empty content, unknown status, duplicate id); the old list is
4283
+ * kept in that case.
4284
+ */
4285
+ write(todos) {
4286
+ const problems = todoListProblems(todos);
4287
+ if (problems.length > 0) throw new RangeError(`Invalid todo list: ${problems.join("; ")}.`);
4288
+ this.items = todos.map((todo, index) => ({
4289
+ id: todo.id ?? String(index + 1),
4290
+ content: todo.content.trim(),
4291
+ status: todo.status
4292
+ }));
4293
+ return this.read();
4294
+ }
4295
+ };
4296
+
4297
+ //#endregion
4298
+ //#region ../../packages/ai-workspace/src/tools/todo.ts
4299
+ /** Whether `value` is a todo status. */
4300
+ function isTodoStatus(value) {
4301
+ return typeof value === "string" && TODO_STATUSES.includes(value);
4302
+ }
4303
+ /** Input schema for the `todo_write` tool. */
4304
+ const todoWriteSchema = withJsonSchema({ "~standard": {
4305
+ version: 1,
4306
+ vendor: "ai-workspace",
4307
+ validate(value) {
4308
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
4309
+ const raw = value.todos;
4310
+ if (!Array.isArray(raw) || raw.length > 100) return { issues: [{
4311
+ message: `"todos" must be an array of at most ${100} items`,
4312
+ path: ["todos"]
4313
+ }] };
4314
+ const issues = [];
4315
+ const todos = [];
4316
+ raw.forEach((item, index) => {
4317
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
4318
+ issues.push({
4319
+ message: `todos[${index}] must be an object`,
4320
+ path: ["todos", index]
4321
+ });
4322
+ return;
4323
+ }
4324
+ const candidate = item;
4325
+ if (typeof candidate.content !== "string") issues.push({
4326
+ message: `todos[${index}].content must be a string`,
4327
+ path: [
4328
+ "todos",
4329
+ index,
4330
+ "content"
4331
+ ]
4332
+ });
4333
+ if (!isTodoStatus(candidate.status)) issues.push({
4334
+ message: `todos[${index}].status must be one of ${TODO_STATUSES.join(", ")}`,
4335
+ path: [
4336
+ "todos",
4337
+ index,
4338
+ "status"
4339
+ ]
4340
+ });
4341
+ if (candidate.id !== void 0 && typeof candidate.id !== "string") issues.push({
4342
+ message: `todos[${index}].id must be a string when provided`,
4343
+ path: [
4344
+ "todos",
4345
+ index,
4346
+ "id"
4347
+ ]
4348
+ });
4349
+ if (typeof candidate.content === "string" && isTodoStatus(candidate.status)) todos.push({
4350
+ content: candidate.content,
4351
+ status: candidate.status,
4352
+ ...typeof candidate.id === "string" ? { id: candidate.id } : {}
4353
+ });
4354
+ });
4355
+ if (issues.length === 0) for (const problem of todoListProblems(todos)) issues.push({
4356
+ message: problem,
4357
+ path: ["todos"]
4358
+ });
4359
+ return issues.length > 0 ? { issues } : { value: { todos } };
4360
+ }
4361
+ } }, TODO_WRITE_JSON_SCHEMA);
4362
+ /** Input schema for the `todo_read` tool (no arguments). */
4363
+ const todoReadSchema = withJsonSchema({ "~standard": {
4364
+ version: 1,
4365
+ vendor: "ai-workspace",
4366
+ validate: () => ({ value: {} })
4367
+ } }, TODO_READ_JSON_SCHEMA);
4368
+ /**
4369
+ * Build the agent-facing `todo_write` tool over a workspace's todo list:
4370
+ * the model sends the **complete** updated list (each item `content` +
4371
+ * `status` of `pending` / `in_progress` / `completed`, optional `id`) and it
4372
+ * replaces the old one. The list lives in memory, per workspace (shared by
4373
+ * its `readonly()` / `scope()` projections), for the length of the run.
4374
+ *
4375
+ * @param store - The workspace's todo list.
4376
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4377
+ *
4378
+ * @example
4379
+ * await makeTodoWriteTool(store).invoke({
4380
+ * todos: [
4381
+ * { content: "Reproduce the failing test", status: "completed" },
4382
+ * { content: "Fix the rounding bug", status: "in_progress" },
4383
+ * ],
4384
+ * });
4385
+ */
4386
+ function makeTodoWriteTool(store, options) {
4387
+ return (0, _warlock_js_ai.tool)({
4388
+ name: options?.name ?? "todo_write",
4389
+ description: "Replace your todo list for this task with the complete updated list (each item: content and status pending | in_progress | completed). Use it to plan multi-step work and to mark progress.",
4390
+ action: "Updating the todo list",
4391
+ input: todoWriteSchema,
4392
+ execute: async (input) => store.write(input.todos)
4393
+ });
4394
+ }
4395
+ /**
4396
+ * Build the agent-facing `todo_read` tool: the workspace's current todo
4397
+ * list and a count per status.
4398
+ *
4399
+ * @param store - The workspace's todo list.
4400
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4401
+ *
4402
+ * @example
4403
+ * const { data } = await makeTodoReadTool(store).invoke({});
4404
+ * console.log(data?.counts.pending);
4405
+ */
4406
+ function makeTodoReadTool(store, options) {
4407
+ return (0, _warlock_js_ai.tool)({
4408
+ name: options?.name ?? "todo_read",
4409
+ description: "Read your current todo list for this task.",
4410
+ action: "Reading the todo list",
4411
+ input: todoReadSchema,
4412
+ execute: async () => store.read()
4413
+ });
4414
+ }
4415
+
1682
4416
  //#endregion
1683
4417
  //#region ../../packages/ai-workspace/src/tools/write-file.ts
1684
4418
  /** Default tool name exposed to the LLM. */
@@ -1687,7 +4421,7 @@ const DEFAULT_NAME = "write_file";
1687
4421
  const inputSchema = objectSchema({
1688
4422
  path: stringField(),
1689
4423
  content: stringField()
1690
- });
4424
+ }, WRITE_FILE_JSON_SCHEMA);
1691
4425
  /**
1692
4426
  * Build the agent-facing `write_file` tool over a workspace's policy-
1693
4427
  * enforced {@link WorkspaceOps}.
@@ -1735,23 +4469,35 @@ function makeWriteFileTool(ops, options) {
1735
4469
  const ALL_TOOL_NAMES = [
1736
4470
  "readFile",
1737
4471
  "editFile",
4472
+ "multiEdit",
1738
4473
  "writeFile",
4474
+ "listDir",
1739
4475
  "runShell",
4476
+ "shellOutput",
4477
+ "shellKill",
1740
4478
  "runTests",
1741
4479
  "grep",
1742
- "glob"
4480
+ "glob",
4481
+ "todoWrite",
4482
+ "todoRead"
1743
4483
  ];
1744
4484
  /**
1745
4485
  * The subset a {@link Workspace.readonly} projection exposes — the
1746
- * non-mutating tools only. `editFile` / `writeFile` / `runShell` /
1747
- * `runTests` are deliberately omitted so a reviewer agent has no path to
1748
- * change the tree.
4486
+ * tools that cannot change the tree. `editFile` / `multiEdit` /
4487
+ * `writeFile` / the shell tools / `runTests` are deliberately omitted so a
4488
+ * reviewer agent has no path to change it. The todo tools stay: they only
4489
+ * touch the in-memory plan.
1749
4490
  */
1750
4491
  const READONLY_TOOL_NAMES = [
1751
4492
  "readFile",
4493
+ "listDir",
1752
4494
  "grep",
1753
- "glob"
4495
+ "glob",
4496
+ "todoWrite",
4497
+ "todoRead"
1754
4498
  ];
4499
+ /** Tools vended only when the policy enables background commands. */
4500
+ const BACKGROUND_TOOL_NAMES = /* @__PURE__ */ new Set(["shellOutput", "shellKill"]);
1755
4501
  /**
1756
4502
  * Choose the dumb IO executor for a policy. `"mock"` selects the
1757
4503
  * in-memory backend (hermetic tests); anything else — including the
@@ -1776,11 +4522,15 @@ function selectBackend(policy) {
1776
4522
  * Constructed via {@link workspace}; the class itself is internal.
1777
4523
  */
1778
4524
  var WorkspaceImpl = class WorkspaceImpl {
1779
- constructor(policy, allowedTools = ALL_TOOL_NAMES) {
4525
+ constructor(policy, allowedTools = ALL_TOOL_NAMES, state) {
1780
4526
  this.policy = policy;
1781
4527
  this.allowedTools = new Set(allowedTools);
4528
+ this.state = state ?? {
4529
+ jobs: new ShellJobs(),
4530
+ todos: new TodoStore()
4531
+ };
1782
4532
  const backend = selectBackend(policy);
1783
- this.ops = createOps(backend, policy);
4533
+ this.ops = createOps(backend, policy, { jobs: this.state.jobs });
1784
4534
  this.tools = this.buildTools();
1785
4535
  }
1786
4536
  /**
@@ -1792,26 +4542,49 @@ var WorkspaceImpl = class WorkspaceImpl {
1792
4542
  */
1793
4543
  buildTools() {
1794
4544
  const erase = (contract) => contract;
4545
+ const shell = this.policy.shell;
4546
+ const background = shell?.background !== void 0;
4547
+ const interpreter = shell?.interpreter ?? "none";
1795
4548
  const factories = {
1796
4549
  readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
1797
4550
  editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
4551
+ multiEdit: (opts) => erase(makeMultiEditTool(this.ops, opts)),
1798
4552
  writeFile: (opts) => erase(makeWriteFileTool(this.ops, opts)),
1799
- runShell: (opts) => erase(makeRunShellTool(this.ops, opts)),
1800
- runTests: (opts) => erase(makeRunTestsTool(this.ops, opts)),
4553
+ listDir: (opts) => erase(makeListDirTool(this.ops, opts)),
4554
+ runShell: (opts) => erase(makeRunShellTool(this.ops, {
4555
+ ...opts?.name !== void 0 ? { name: opts.name } : {},
4556
+ ...shell?.timeoutMs !== void 0 ? { maxTimeoutMs: shell.timeoutMs } : {},
4557
+ background
4558
+ })),
4559
+ shellOutput: (opts) => erase(makeShellOutputTool(this.ops, opts)),
4560
+ shellKill: (opts) => erase(makeShellKillTool(this.ops, opts)),
4561
+ runTests: (opts) => erase(makeRunTestsTool(this.ops, {
4562
+ ...opts,
4563
+ interpreter
4564
+ })),
1801
4565
  grep: (opts) => erase(makeGrepTool(this.ops, opts)),
1802
- glob: (opts) => erase(makeGlobTool(this.ops, opts))
4566
+ glob: (opts) => erase(makeGlobTool(this.ops, opts)),
4567
+ todoWrite: (opts) => erase(makeTodoWriteTool(this.state.todos, opts)),
4568
+ todoRead: (opts) => erase(makeTodoReadTool(this.state.todos, opts))
1803
4569
  };
1804
4570
  const build = (name, opts) => factories[name](opts);
4571
+ const vendedByAll = (name) => this.allowedTools.has(name) && (background || !BACKGROUND_TOOL_NAMES.has(name));
1805
4572
  return {
1806
- all: () => ALL_TOOL_NAMES.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
4573
+ all: () => ALL_TOOL_NAMES.filter(vendedByAll).map((name) => build(name)),
1807
4574
  pick: (...names) => names.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
1808
4575
  readFile: (opts) => build("readFile", opts),
1809
4576
  editFile: (opts) => build("editFile", opts),
4577
+ multiEdit: (opts) => build("multiEdit", opts),
1810
4578
  writeFile: (opts) => build("writeFile", opts),
4579
+ listDir: (opts) => build("listDir", opts),
1811
4580
  runShell: (opts) => build("runShell", opts),
4581
+ shellOutput: (opts) => build("shellOutput", opts),
4582
+ shellKill: (opts) => build("shellKill", opts),
1812
4583
  runTests: (opts) => build("runTests", opts),
1813
4584
  grep: (opts) => build("grep", opts),
1814
- glob: (opts) => build("glob", opts)
4585
+ glob: (opts) => build("glob", opts),
4586
+ todoWrite: (opts) => build("todoWrite", opts),
4587
+ todoRead: (opts) => build("todoRead", opts)
1815
4588
  };
1816
4589
  }
1817
4590
  /**
@@ -1837,10 +4610,40 @@ var WorkspaceImpl = class WorkspaceImpl {
1837
4610
  this.assertWritable("editFile");
1838
4611
  return this.ops.editFile(input);
1839
4612
  }
4613
+ async multiEdit(input) {
4614
+ this.assertWritable("multiEdit");
4615
+ return this.ops.multiEdit(input);
4616
+ }
4617
+ listDir(dirPath, opts) {
4618
+ return this.ops.listDir(dirPath, opts);
4619
+ }
1840
4620
  async exec(command, opts) {
1841
4621
  this.assertWritable("exec");
1842
4622
  return this.ops.exec(command, opts);
1843
4623
  }
4624
+ async startShell(command, opts) {
4625
+ this.assertWritable("startShell");
4626
+ return this.ops.startShell(command, opts);
4627
+ }
4628
+ shellOutput(id, opts) {
4629
+ return this.ops.shellOutput(id, opts);
4630
+ }
4631
+ async shellKill(id) {
4632
+ this.assertWritable("shellKill");
4633
+ return this.ops.shellKill(id);
4634
+ }
4635
+ listShells() {
4636
+ return this.ops.listShells();
4637
+ }
4638
+ readTodos() {
4639
+ return this.state.todos.read();
4640
+ }
4641
+ writeTodos(todos) {
4642
+ return this.state.todos.write(todos);
4643
+ }
4644
+ dispose() {
4645
+ return this.ops.dispose();
4646
+ }
1844
4647
  grep(pattern, opts) {
1845
4648
  return this.ops.grep(pattern, opts);
1846
4649
  }
@@ -1859,44 +4662,49 @@ var WorkspaceImpl = class WorkspaceImpl {
1859
4662
  return this.ops.remove(filePath);
1860
4663
  }
1861
4664
  /**
1862
- * A read-only projection over the SAME policy — only the read/grep/glob
1863
- * tools are vended and every mutating direct method rejects with a
1864
- * {@link WorkspacePolicyError}. A fresh ops/backend is built from the
1865
- * identical policy, so the projection sees the same jailed tree.
4665
+ * A read-only projection over the SAME policy — only the read / list /
4666
+ * grep / glob / todo tools are vended and every mutating direct method
4667
+ * rejects with a {@link WorkspacePolicyError}. A fresh ops/backend is
4668
+ * built from the identical policy, so the projection sees the same jailed
4669
+ * tree; it shares this workspace's background commands and todo list.
1866
4670
  */
1867
4671
  readonly() {
1868
- return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES);
4672
+ return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES.filter((name) => this.allowedTools.has(name)), this.state);
1869
4673
  }
1870
4674
  /**
1871
4675
  * A sub-jailed view rooted at `subdir` (relative to this workspace's
1872
4676
  * `cwd`). Returns a brand-new workspace whose policy is this policy
1873
4677
  * with `cwd` narrowed to `join(cwd, subdir)` — same backend selection,
1874
- * same allow/deny/shell/read sub-policies, but a tighter jail root.
4678
+ * same allow/deny/shell/read sub-policies, but a tighter jail root. It
4679
+ * shares this workspace's background commands and todo list.
1875
4680
  */
1876
4681
  scope(subdir) {
1877
4682
  return new WorkspaceImpl({
1878
4683
  ...this.policy,
1879
4684
  cwd: node_path.default.join(this.policy.cwd, subdir)
1880
- }, [...this.allowedTools]);
4685
+ }, [...this.allowedTools], this.state);
1881
4686
  }
1882
4687
  };
1883
4688
  /**
1884
4689
  * Build a {@link Workspace} — the integrator that wires a
1885
4690
  * {@link WorkspacePolicy} to a backend, the shared policy-enforced ops
1886
- * layer, and the seven agent-facing tool factories.
4691
+ * layer, and the agent-facing tool factories.
1887
4692
  *
1888
4693
  * The backend is chosen from `policy.backend`: `"mock"` runs in memory
1889
4694
  * (hermetic tests); the `"local"` default (and any absent value) runs
1890
4695
  * over the real disk via `@warlock.js/fs` + `node:child_process`. The
1891
4696
  * returned workspace exposes:
1892
4697
  *
1893
- * - **`tools.*`** — `readFile` / `editFile` / `writeFile` / `runShell` /
1894
- * `runTests` / `grep` / `glob`, plus `all()` (every tool) and
1895
- * `pick(...)` (a least-privilege subset).
1896
- * - **direct methods** — `readFile` / `writeFile` / `editFile` / `exec` /
1897
- * `grep` / `glob` / `exists` / `mkdir` / `remove`, each delegating 1:1
1898
- * to the shared ops layer.
1899
- * - **`readonly()`** — a projection that vends only read/grep/glob and
4698
+ * - **`tools.*`** — `readFile` / `editFile` / `multiEdit` / `writeFile` /
4699
+ * `listDir` / `runShell` / `shellOutput` / `shellKill` / `runTests` /
4700
+ * `grep` / `glob` / `todoWrite` / `todoRead`, plus `all()` (every tool)
4701
+ * and `pick(...)` (a least-privilege subset).
4702
+ * - **direct methods** — `readFile` / `writeFile` / `editFile` /
4703
+ * `multiEdit` / `listDir` / `exec` / `startShell` / `shellOutput` /
4704
+ * `shellKill` / `listShells` / `grep` / `glob` / `exists` / `mkdir` /
4705
+ * `remove` / `readTodos` / `writeTodos`, each delegating to the shared
4706
+ * ops layer, and `dispose()`.
4707
+ * - **`readonly()`** — a projection that vends only the read-side tools and
1900
4708
  * rejects every mutating direct method.
1901
4709
  * - **`scope(subdir)`** — a sub-jailed workspace rooted at `subdir`.
1902
4710
  *
@@ -1921,9 +4729,13 @@ function workspace(policy) {
1921
4729
  _warlock_js_ai.ai.workspace = workspace;
1922
4730
 
1923
4731
  //#endregion
4732
+ exports.TodoStore = TodoStore;
1924
4733
  exports.WorkspaceEditError = WorkspaceEditError;
4734
+ exports.WorkspaceJobError = WorkspaceJobError;
1925
4735
  exports.WorkspacePolicyError = WorkspacePolicyError;
4736
+ exports.analyzeArgvCommand = analyzeArgvCommand;
1926
4737
  exports.buildEnv = buildEnv;
4738
+ exports.checkShellCommand = checkShellCommand;
1927
4739
  exports.createLocalBackend = createLocalBackend;
1928
4740
  exports.createMockBackend = createMockBackend;
1929
4741
  exports.createOps = createOps;
@@ -1931,10 +4743,17 @@ exports.isCommandAllowed = isCommandAllowed;
1931
4743
  exports.makeEditFileTool = makeEditFileTool;
1932
4744
  exports.makeGlobTool = makeGlobTool;
1933
4745
  exports.makeGrepTool = makeGrepTool;
4746
+ exports.makeListDirTool = makeListDirTool;
4747
+ exports.makeMultiEditTool = makeMultiEditTool;
1934
4748
  exports.makeReadFileTool = makeReadFileTool;
1935
4749
  exports.makeRunShellTool = makeRunShellTool;
1936
4750
  exports.makeRunTestsTool = makeRunTestsTool;
4751
+ exports.makeShellKillTool = makeShellKillTool;
4752
+ exports.makeShellOutputTool = makeShellOutputTool;
4753
+ exports.makeTodoReadTool = makeTodoReadTool;
4754
+ exports.makeTodoWriteTool = makeTodoWriteTool;
1937
4755
  exports.makeWriteFileTool = makeWriteFileTool;
1938
4756
  exports.resolveInJail = resolveInJail;
4757
+ exports.tokenizeCommand = tokenizeCommand;
1939
4758
  exports.workspace = workspace;
1940
4759
  //# sourceMappingURL=index.cjs.map