@warlock.js/ai-workspace 5.29.0 → 5.31.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 +68 -0
  2. package/README.md +40 -21
  3. package/cjs/index.cjs +3295 -360
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts +1 -1
  6. package/esm/backends/local.d.mts.map +1 -1
  7. package/esm/backends/local.mjs +199 -31
  8. package/esm/backends/local.mjs.map +1 -1
  9. package/esm/backends/mock.d.mts +12 -6
  10. package/esm/backends/mock.d.mts.map +1 -1
  11. package/esm/backends/mock.mjs +45 -3
  12. package/esm/backends/mock.mjs.map +1 -1
  13. package/esm/contracts/index.d.mts +3 -3
  14. package/esm/contracts/tool-io.type.d.mts +248 -11
  15. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  16. package/esm/contracts/workspace-backend.contract.d.mts +78 -2
  17. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  18. package/esm/contracts/workspace-ops.contract.d.mts +41 -16
  19. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  20. package/esm/contracts/workspace-policy.type.d.mts +142 -14
  21. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  22. package/esm/contracts/workspace.contract.d.mts +75 -22
  23. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  24. package/esm/errors.d.mts +80 -6
  25. package/esm/errors.d.mts.map +1 -1
  26. package/esm/errors.mjs +23 -2
  27. package/esm/errors.mjs.map +1 -1
  28. package/esm/index.d.mts +13 -5
  29. package/esm/index.mjs +10 -2
  30. package/esm/ops.d.mts +22 -3
  31. package/esm/ops.d.mts.map +1 -1
  32. package/esm/ops.mjs +341 -82
  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 +26 -16
  37. package/esm/policy/policy.d.mts.map +1 -1
  38. package/esm/policy/policy.mjs +62 -86
  39. package/esm/policy/policy.mjs.map +1 -1
  40. package/esm/policy/shell-check.d.mts +64 -0
  41. package/esm/policy/shell-check.d.mts.map +1 -0
  42. package/esm/policy/shell-check.mjs +627 -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 +63 -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 +1 -1
  59. package/esm/tools/edit-file.d.mts.map +1 -1
  60. package/esm/tools/edit-file.mjs +3 -2
  61. package/esm/tools/edit-file.mjs.map +1 -1
  62. package/esm/tools/glob.d.mts +1 -1
  63. package/esm/tools/glob.d.mts.map +1 -1
  64. package/esm/tools/glob.mjs +3 -2
  65. package/esm/tools/glob.mjs.map +1 -1
  66. package/esm/tools/grep.d.mts +1 -1
  67. package/esm/tools/grep.d.mts.map +1 -1
  68. package/esm/tools/grep.mjs +3 -2
  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 +32 -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 +38 -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 +5 -5
  81. package/esm/tools/read-file.d.mts.map +1 -1
  82. package/esm/tools/read-file.mjs +10 -20
  83. package/esm/tools/read-file.mjs.map +1 -1
  84. package/esm/tools/run-shell.d.mts +39 -13
  85. package/esm/tools/run-shell.d.mts.map +1 -1
  86. package/esm/tools/run-shell.mjs +60 -47
  87. package/esm/tools/run-shell.mjs.map +1 -1
  88. package/esm/tools/run-tests.d.mts +11 -2
  89. package/esm/tools/run-tests.d.mts.map +1 -1
  90. package/esm/tools/run-tests.mjs +15 -7
  91. package/esm/tools/run-tests.mjs.map +1 -1
  92. package/esm/tools/schema.mjs +24 -19
  93. package/esm/tools/schema.mjs.map +1 -1
  94. package/esm/tools/shell-kill.d.mts +25 -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 +30 -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 +43 -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 +1 -1
  107. package/esm/tools/write-file.d.mts.map +1 -1
  108. package/esm/tools/write-file.mjs +3 -2
  109. package/esm/tools/write-file.mjs.map +1 -1
  110. package/esm/workspace.d.mts +12 -9
  111. package/esm/workspace.d.mts.map +1 -1
  112. package/esm/workspace.mjs +109 -28
  113. package/esm/workspace.mjs.map +1 -1
  114. package/llms-full.txt +192 -35
  115. package/llms.txt +1 -1
  116. package/package.json +5 -4
  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 +185 -28
package/cjs/index.cjs CHANGED
@@ -33,8 +33,9 @@ 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
- //#region ../ai-workspace/src/errors.ts
38
+ //#region ../../packages/ai-workspace/src/errors.ts
38
39
  /**
39
40
  * The workspace policy engine refused an operation — a path escaped the
40
41
  * jail (or hit a deny glob), or a shell command's executable was not
@@ -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,11 +88,899 @@ 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
+ }
90
181
 
91
182
  //#endregion
92
- //#region ../ai-workspace/src/policy/tokenize-command.ts
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 = 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 = 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
+ }
981
+
982
+ //#endregion
983
+ //#region ../../packages/ai-workspace/src/policy/tokenize-command.ts
93
984
  /**
94
985
  * Characters that are refused when they appear UNQUOTED in a command line.
95
986
  * Workspace commands are executed as a direct argv spawn — never through a
@@ -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 = 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,40 +1103,632 @@ 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 = 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 = 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 = 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 = 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 = 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 = new Set([
1241
+ "/dev/null",
1242
+ "/dev/stdout",
1243
+ "/dev/stderr"
1244
+ ]);
1245
+ /** cmd.exe's null device. */
1246
+ const CMD_DEVICE_TARGETS = 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
+ throw new Refusal("not-allowed", `${shown} was refused by the workspace shell policy.${typeof verdict === "string" && verdict.trim() !== "" ? ` ${verdict.trim()}` : ""}`, executableBasename(argv[0] ?? "", this.dialect));
1346
+ }
1347
+ /** Whether the policy names `executable` in a non-empty `allow` list. */
1348
+ explicitlyAllowed(executable) {
1349
+ return this.allow.includes(executable);
1350
+ }
1351
+ /** Refuse a command runner the policy cannot see into, unless explicitly allowed. */
1352
+ checkOpaque(executable, argv) {
1353
+ if (this.explicitlyAllowed(executable) || !this.hasCommandRules) return;
1354
+ 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);
1355
+ }
1356
+ /**
1357
+ * Check one argv: `deny`, `allow`, `denyPatterns`, `allowCommand`, then
1358
+ * follow wrappers and `find -exec` to the command they run. `dynamic[i]`
1359
+ * says whether `argv[i]` is decided at run time (shell mode).
1360
+ */
1361
+ checkArgv(argv, dynamic, depth) {
1362
+ const first = argv[0] ?? "";
1363
+ const executable = executableBasename(first, this.dialect);
1364
+ const key = executable.toLowerCase();
1365
+ if (executable === "") throw new Refusal("not-allowed", "The command has no executable name.");
1366
+ 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.`);
1367
+ if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${executable}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, executable);
1368
+ if (this.allow.length > 0 && !this.allow.includes(executable)) throw new Refusal("not-allowed", `\`${executable}\` is not in the workspace shell allowlist. Allowed commands: ${this.allow.slice(0, 20).join(", ")}${this.allow.length > 20 ? ", …" : ""}.`, executable);
1369
+ this.checkHooks(argv, depth > 0);
1370
+ this.commands.push([...argv]);
1371
+ 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)) {
1372
+ this.checkOpaque(executable, argv);
1373
+ return;
1374
+ }
1375
+ if (this.shellMode && this.dialect === "posix" && ARITHMETIC_SENSITIVE_BUILTINS.has(key)) this.checkArithmeticSensitive(executable, argv, dynamic);
1376
+ const ranges = this.unwrap(key, argv);
1377
+ if (ranges === void 0) return;
1378
+ 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);
1379
+ for (const [start, end] of ranges) this.checkArgv(argv.slice(start, end), dynamic.slice(start, end), depth + 1);
1380
+ }
1381
+ /**
1382
+ * Builtins bash may evaluate arguments of as arithmetic (array subscripts
1383
+ * like `a[$(cmd)]` run the substitution): arguments must be literal, free
1384
+ * of `[`, and must not name a variable that changes how the shell runs.
1385
+ */
1386
+ checkArithmeticSensitive(executable, argv, dynamic) {
1387
+ for (let index = 1; index < argv.length; index++) {
1388
+ const argument = argv[index] ?? "";
1389
+ 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);
1390
+ if (argument.includes("[") && argument !== "[" && argument !== "]") throw new Refusal("unsupported-syntax", `\`${executable}\` arguments with \`[\` (array subscripts, evaluated as arithmetic) are not supported.`, executable);
1391
+ const name = argument.split("=")[0] ?? "";
1392
+ 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);
1393
+ }
1394
+ }
1395
+ /**
1396
+ * The argv ranges a wrapper runs. `undefined` — not a wrapper (or one that
1397
+ * runs nothing, like a bare `env`); `null` — a wrapper whose options the
1398
+ * policy cannot read (refused).
1399
+ */
1400
+ unwrap(key, argv) {
1401
+ switch (key) {
1402
+ case "env": return suffixFrom(argv, unwrapEnv(argv));
1403
+ case "nice": return suffixFrom(argv, unwrapNice(argv));
1404
+ case "nohup": return suffixFrom(argv, unwrapSimple(argv, []));
1405
+ case "time": return suffixFrom(argv, unwrapSimple(argv, ["-p"]));
1406
+ case "timeout": return suffixFrom(argv, unwrapTimeout(argv));
1407
+ case "command": return argv.slice(1).some((arg) => arg === "-v" || arg === "-V") ? void 0 : suffixFrom(argv, unwrapSimple(argv, ["-p"]));
1408
+ case "builtin": return suffixFrom(argv, unwrapSimple(argv, []));
1409
+ case "exec": return this.dialect === "posix" ? suffixFrom(argv, unwrapExec(argv)) : void 0;
1410
+ case "find": return unwrapFind(argv);
1411
+ default: return;
1412
+ }
1413
+ }
1414
+ /** Check a `cd` (shell mode): literal, one operand, inside the jail from every possible cwd. */
1415
+ checkCd(words, cwds) {
1416
+ const argv = words.map((word) => word.value);
1417
+ if (this.deny.has("cd")) throw new Refusal("denied-executable", "`cd` is denied by the workspace shell policy.", "cd");
1418
+ this.checkHooks(argv, false);
1419
+ this.commands.push(argv);
1420
+ const operands = words.slice(1);
1421
+ while (this.dialect === "posix" && operands.length > 0 && [
1422
+ "-P",
1423
+ "-L",
1424
+ "--"
1425
+ ].includes(operands[0]?.value ?? "")) operands.shift();
1426
+ const target = operands[0];
1427
+ 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");
1428
+ const next = new Set(cwds);
1429
+ for (const cwd of cwds) {
1430
+ const resolved = this.resolveTarget(cwd, target.value);
1431
+ this.assertInJail(resolved, target.value, "cd");
1432
+ this.paths.push({
1433
+ path: resolved,
1434
+ access: "cwd",
1435
+ target: target.value
1436
+ });
1437
+ next.add(resolved);
1438
+ }
1439
+ if (next.size > MAX_CD_CANDIDATES) throw new Refusal("unsupported-syntax", "The line changes directory too many times.", "cd");
1440
+ return [...next];
1441
+ }
1442
+ /** Check a redirection target from every possible cwd. */
1443
+ checkRedirectTarget(word, access, cwds) {
1444
+ if (word.dynamic) throw new Refusal("unsupported-syntax", `The redirection target \`${word.raw}\` must be a literal path (no variables, globs or \`~\`).`);
1445
+ const value = word.value;
1446
+ if (this.dialect === "posix" && DEVICE_TARGETS.has(value) || this.dialect === "cmd" && CMD_DEVICE_TARGETS.has(value.toLowerCase())) return;
1447
+ for (const cwd of cwds) {
1448
+ const resolved = this.resolveTarget(cwd, value);
1449
+ this.assertInJail(resolved, value, "redirect");
1450
+ this.paths.push({
1451
+ path: resolved,
1452
+ access,
1453
+ target: value
1454
+ });
1455
+ }
1456
+ }
1457
+ resolveTarget(cwd, target) {
1458
+ return node_path.default.resolve(cwd, target);
1459
+ }
1460
+ assertInJail(resolved, target, kind) {
1461
+ if (isInsideJailLexically(this.policy, resolved)) return;
1462
+ 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);
1463
+ }
1464
+ };
1465
+ /**
1466
+ * Turn a wrapper's "inner command starts at `start`" into a range; a
1467
+ * wrapper that runs nothing (`start === argv.length`) becomes `undefined`.
1468
+ */
1469
+ function suffixFrom(argv, start) {
1470
+ if (start === null) return null;
1471
+ return start < argv.length ? [[start, argv.length]] : void 0;
1472
+ }
1473
+ /** `nohup` / `time` / `command` / `builtin`: skip the listed flags and `--`. */
1474
+ function unwrapSimple(argv, flags) {
1475
+ let index = 1;
1476
+ while (index < argv.length) {
1477
+ const arg = argv[index] ?? "";
1478
+ if (arg === "--") {
1479
+ index++;
1480
+ break;
1481
+ }
1482
+ if (flags.includes(arg)) {
1483
+ index++;
147
1484
  continue;
148
1485
  }
149
- if (char === " " || char === " ") {
150
- if (inToken) {
151
- argv.push(current);
152
- current = "";
153
- inToken = false;
154
- }
1486
+ if (arg.startsWith("-") && arg !== "-") return null;
1487
+ break;
1488
+ }
1489
+ return index;
1490
+ }
1491
+ /** `env [-i] [-u NAME] [NAME=value]… command…` */
1492
+ function unwrapEnv(argv) {
1493
+ let index = 1;
1494
+ while (index < argv.length) {
1495
+ const arg = argv[index] ?? "";
1496
+ if (arg === "-i" || arg === "-" || arg === "--ignore-environment") {
1497
+ index++;
1498
+ continue;
1499
+ }
1500
+ if (arg === "-u" || arg === "--unset") {
1501
+ index += 2;
1502
+ continue;
1503
+ }
1504
+ if (arg.startsWith("--unset=") || arg.startsWith("-u") && arg.length > 2) {
1505
+ index++;
1506
+ continue;
1507
+ }
1508
+ if (arg === "--") {
155
1509
  index++;
1510
+ break;
1511
+ }
1512
+ if (arg.startsWith("-")) return null;
1513
+ break;
1514
+ }
1515
+ while (index < argv.length && /^[^=]+=/.test(argv[index] ?? "")) index++;
1516
+ return index;
1517
+ }
1518
+ /** `nice [-n N | -N | --adjustment=N] command…` */
1519
+ function unwrapNice(argv) {
1520
+ let index = 1;
1521
+ while (index < argv.length) {
1522
+ const arg = argv[index] ?? "";
1523
+ if (arg === "-n" || arg === "--adjustment") {
1524
+ index += 2;
156
1525
  continue;
157
1526
  }
158
- if (char === "\n" || char === "\r" || UNQUOTED_METACHARACTERS.has(char)) return null;
159
- current += char;
160
- inToken = true;
161
- index++;
1527
+ if (/^-n-?[0-9]+$/.test(arg) || /^--adjustment=-?[0-9]+$/.test(arg) || /^-[0-9]+$/.test(arg)) {
1528
+ index++;
1529
+ continue;
1530
+ }
1531
+ if (arg === "--") {
1532
+ index++;
1533
+ break;
1534
+ }
1535
+ if (arg.startsWith("-")) return null;
1536
+ break;
1537
+ }
1538
+ return index;
1539
+ }
1540
+ /** `timeout [options] DURATION command…` */
1541
+ function unwrapTimeout(argv) {
1542
+ let index = 1;
1543
+ while (index < argv.length) {
1544
+ const arg = argv[index] ?? "";
1545
+ if (arg === "-s" || arg === "--signal" || arg === "-k" || arg === "--kill-after") {
1546
+ index += 2;
1547
+ continue;
1548
+ }
1549
+ if (/^-[sk].+$/.test(arg) || arg.startsWith("--signal=") || arg.startsWith("--kill-after=") || arg === "--preserve-status" || arg === "--foreground" || arg === "-v" || arg === "--verbose") {
1550
+ index++;
1551
+ continue;
1552
+ }
1553
+ if (arg === "--") {
1554
+ index++;
1555
+ break;
1556
+ }
1557
+ if (arg.startsWith("-")) return null;
1558
+ break;
1559
+ }
1560
+ if (index >= argv.length - 1) return null;
1561
+ return index + 1;
1562
+ }
1563
+ /** `exec [-cl] [-a name] command…` (bash). */
1564
+ function unwrapExec(argv) {
1565
+ let index = 1;
1566
+ while (index < argv.length) {
1567
+ const arg = argv[index] ?? "";
1568
+ if (arg === "-c" || arg === "-l" || arg === "-cl" || arg === "-lc") {
1569
+ index++;
1570
+ continue;
1571
+ }
1572
+ if (arg === "-a") {
1573
+ index += 2;
1574
+ continue;
1575
+ }
1576
+ if (arg === "--") {
1577
+ index++;
1578
+ break;
1579
+ }
1580
+ if (arg.startsWith("-")) return null;
1581
+ break;
1582
+ }
1583
+ return index;
1584
+ }
1585
+ /** The commands `find -exec / -execdir / -ok / -okdir … ;|+` runs. */
1586
+ function unwrapFind(argv) {
1587
+ const inner = [];
1588
+ for (let index = 1; index < argv.length; index++) {
1589
+ const arg = argv[index] ?? "";
1590
+ if (arg !== "-exec" && arg !== "-execdir" && arg !== "-ok" && arg !== "-okdir") continue;
1591
+ const start = index + 1;
1592
+ let end = start;
1593
+ while (end < argv.length && argv[end] !== ";" && argv[end] !== "+") end++;
1594
+ if (end >= argv.length || end === start) return null;
1595
+ inner.push([start, end]);
1596
+ index = end;
1597
+ }
1598
+ return inner.length > 0 ? inner : void 0;
1599
+ }
1600
+ /** Build the argv-mode refusal for a line that is not a single argv. */
1601
+ function argvModeRefusal(command) {
1602
+ const analysis = analyzeArgvCommand(command);
1603
+ if (analysis.ok) return new Refusal("metacharacter", "The command cannot run as a single argv.");
1604
+ if (analysis.type === "empty") return new Refusal("not-allowed", "The command is empty.");
1605
+ 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.`);
1606
+ const token = analysis.token;
1607
+ const suffix = " (run_shell executes a single command without a shell.)";
1608
+ const parsed = parseShellLine(command, "posix");
1609
+ const segments = parsed.ok ? parsed.segments : [];
1610
+ if ((token === "&&" || token === "||" || token === ";" || token === "\n") && segments.length >= 2) {
1611
+ const shownOperator = token === "\n" ? "A line break" : `\`${token}\``;
1612
+ if ((segments[0]?.source ?? "").trim().split(/\s+/)[0] === "cd") 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 ${segments.slice(1).map((segment) => show(segment.source)).join(", then ")} on its own, with paths relative to the root.${suffix}`);
1613
+ const steps = segments.map((segment) => show(segment.source));
1614
+ const joiner = token === "||" ? ", and if it fails, " : ", then ";
1615
+ return new Refusal("metacharacter", `${shownOperator} is not supported in argv mode: run ${steps.join(joiner)}, as separate commands.${suffix}`);
1616
+ }
1617
+ if (token === "&" && segments.length >= 1) {
1618
+ const steps = segments.map((segment) => show(segment.source));
1619
+ return new Refusal("metacharacter", `\`&\` is not supported in argv mode: ${steps.length === 1 ? `run ${steps[0] ?? ""} without it` : `run ${steps.join(", then ")} as separate commands`}; start a long-running command with background: true.${suffix}`);
1620
+ }
1621
+ if (token === "|") return new Refusal("metacharacter", `\`|\` (a pipe) is not supported in argv mode: run ${show(command.split("|").map((part) => part.trim()).filter(Boolean)[0] ?? command)} on its own and read its output yourself.${suffix}`);
1622
+ 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}`);
1623
+ 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}`);
1624
+ 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}`);
1625
+ 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}`);
1626
+ if (token === "(" || token === ")") return new Refusal("metacharacter", `\`${token}\` is not supported in argv mode: put it inside quotes to pass it as text.${suffix}`);
1627
+ 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}`);
1628
+ }
1629
+ /** Check a shell-mode (`interpreter: "sh"`) line. */
1630
+ function checkShellLine(checker, policy, command, dialect) {
1631
+ if (/^[-+]/.test(command)) throw new Refusal("unsupported-syntax", "A command line may not start with `-` or `+`.");
1632
+ const parsed = parseShellLine(command, dialect);
1633
+ if (!parsed.ok) throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
1634
+ 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.");
1635
+ let cwds = [node_path.default.resolve(policy.cwd)];
1636
+ for (const simple of parsed.commands) cwds = checkSimpleCommand(checker, simple, cwds, dialect);
1637
+ }
1638
+ /** Check one parsed simple command; returns the possible working directories after it. */
1639
+ function checkSimpleCommand(checker, simple, cwds, dialect) {
1640
+ for (const assignment of simple.assignments) {
1641
+ const name = assignment.assignmentName ?? "";
1642
+ 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.`);
1643
+ }
1644
+ for (const redirect of simple.redirects) if (redirect.type === "read" || redirect.type === "write") checker.checkRedirectTarget(redirect.target, redirect.type, cwds);
1645
+ const first = simple.words[0];
1646
+ if (first === void 0) return cwds;
1647
+ if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
1648
+ if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
1649
+ if (checker.hasArgumentHooks) {
1650
+ const dynamic = simple.words.find((word) => word.dynamic);
1651
+ 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).`);
1652
+ }
1653
+ const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
1654
+ if (executable === "cd") return checker.checkCd(simple.words, cwds);
1655
+ if (dialect === "posix" && (executable === "pushd" || executable === "popd")) throw new Refusal("unsupported-syntax", `\`${executable}\` is not supported; use \`cd\`.`);
1656
+ checker.checkArgv(simple.words.map((word) => word.dynamic ? word.raw : word.value), simple.words.map((word) => word.dynamic), 0);
1657
+ return cwds;
1658
+ }
1659
+ /**
1660
+ * The shell policy's verdict on a command line, without touching the
1661
+ * filesystem.
1662
+ *
1663
+ * - **argv mode** (`interpreter: "none"`, the default): the line must
1664
+ * tokenize into one argv; otherwise it is refused with a
1665
+ * `"metacharacter"` / `"unbalanced-quotes"` reason and a message that
1666
+ * names the operator and suggests the split.
1667
+ * - **shell mode** (`interpreter: "sh"`): the line is parsed and every
1668
+ * simple command — inside `;`, `&&`, `||`, pipes, subshells, `$( )`,
1669
+ * backticks and here-documents — is checked; redirection targets and `cd`
1670
+ * directories must stay in the jail (checked lexically here; `exec`
1671
+ * re-checks them with symlinks resolved).
1672
+ *
1673
+ * Every argv goes through `deny` (case-insensitive, wins), `allow`
1674
+ * (exhaustive when non-empty), `denyPatterns` and `allowCommand`.
1675
+ * Wrappers (`env`, `time`, `timeout`, `nice`, `nohup`, `command`,
1676
+ * `builtin`, `exec`) and `find -exec` are followed to the command they run.
1677
+ * A runner the policy cannot see into (`bash -c`, `eval`, `xargs`, `sudo`,
1678
+ * …) is refused while the policy has deny rules, unless `allow` names it.
1679
+ *
1680
+ * @example
1681
+ * checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
1682
+ * // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
1683
+ */
1684
+ function checkShellCommand(policy, command) {
1685
+ return checkShellCommandInDialect(policy, command, shellDialect());
1686
+ }
1687
+ /**
1688
+ * {@link checkShellCommand} with the shell-mode dialect chosen explicitly
1689
+ * instead of from the platform (used to test the cmd.exe rules anywhere).
1690
+ * Argv mode ignores `dialect`.
1691
+ */
1692
+ function checkShellCommandInDialect(policy, command, dialect) {
1693
+ const shell = policy.shell;
1694
+ if (!shell) return {
1695
+ allowed: false,
1696
+ reason: "not-allowed",
1697
+ message: "The workspace has no shell policy, so no command may run.",
1698
+ commands: [],
1699
+ paths: []
1700
+ };
1701
+ const interpreter = shell.interpreter ?? "none";
1702
+ const checker = new LineChecker(policy, shell, command, interpreter === "sh" ? dialect : "posix");
1703
+ try {
1704
+ if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
1705
+ if (interpreter === "sh") checkShellLine(checker, policy, command, dialect);
1706
+ else {
1707
+ const argv = tokenizeCommand(command);
1708
+ if (argv === null) throw argvModeRefusal(command);
1709
+ checker.checkArgv(argv, argv.map(() => false), 0);
1710
+ }
1711
+ return {
1712
+ allowed: true,
1713
+ commands: checker.commands,
1714
+ paths: checker.paths
1715
+ };
1716
+ } catch (error) {
1717
+ if (!(error instanceof Refusal)) throw error;
1718
+ return {
1719
+ allowed: false,
1720
+ reason: error.reason,
1721
+ message: error.message,
1722
+ ...error.executable !== void 0 ? { executable: error.executable } : {},
1723
+ ...error.refusedPath !== void 0 ? { path: error.refusedPath } : {},
1724
+ commands: checker.commands,
1725
+ paths: checker.paths
1726
+ };
162
1727
  }
163
- if (inToken) argv.push(current);
164
- return argv.length > 0 ? argv : null;
165
1728
  }
166
1729
 
167
1730
  //#endregion
168
- //#region ../ai-workspace/src/policy/policy.ts
1731
+ //#region ../../packages/ai-workspace/src/policy/policy.ts
169
1732
  /**
170
1733
  * Resolve the canonical absolute form of `target`, collapsing any
171
1734
  * symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),
@@ -188,55 +1751,6 @@ async function canonicalize$1(target) {
188
1751
  }
189
1752
  }
190
1753
  /**
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
1754
  * Resolve and jail a single input path against a {@link WorkspacePolicy}.
241
1755
  *
242
1756
  * The input is resolved against `policy.cwd`, canonicalized (symlinks in
@@ -246,6 +1760,17 @@ function matchesDeny(relativePath, denyPaths) {
246
1760
  * `denyPaths` glob even while inside `cwd`, is rejected with a
247
1761
  * {@link WorkspacePolicyError} of type `"path-escape"`.
248
1762
  *
1763
+ * Why this does not use `@warlock.js/fs`'s confined reader (`fs.confined`):
1764
+ * the two answer different questions. The confined reader refuses absolute
1765
+ * paths, refuses every symbolic link (or follows only links inside its one
1766
+ * root), requires the path to exist, and reads the file itself through a
1767
+ * no-follow, identity-checked open. This jail must instead accept absolute
1768
+ * paths inside `cwd`, several `allowPaths` roots, `denyPaths` globs, a
1769
+ * symlinked `cwd`, links whose target stays inside, and paths that do not
1770
+ * exist yet (`writeFile`, `mkdir`), and it returns a path that writes,
1771
+ * `exec`'s working directory and redirections use afterwards. Switching would
1772
+ * change what agents may touch, so the workspace keeps this resolver.
1773
+ *
249
1774
  * @param policy - The bounding policy (its `cwd` is the jail root).
250
1775
  * @param inputPath - A workspace-relative or absolute path to resolve.
251
1776
  * @returns The canonical absolute path plus its `/`-separated relative form.
@@ -272,7 +1797,7 @@ async function resolveInJail(policy, inputPath) {
272
1797
  });
273
1798
  const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, absolutePath).split(node_path.default.sep).join("/") : "";
274
1799
  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.`, {
1800
+ if (matchesAnyGlob(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
276
1801
  type: "path-escape",
277
1802
  path: inputPath
278
1803
  });
@@ -283,32 +1808,22 @@ async function resolveInJail(policy, inputPath) {
283
1808
  };
284
1809
  }
285
1810
  /**
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.
1811
+ * Whether a command line is permitted by the policy's `shell` sub-policy —
1812
+ * `checkShellCommand(policy, command).allowed`.
296
1813
  *
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.
1814
+ * In argv mode (the default) the command must tokenize into a single argv
1815
+ * (no unquoted `;`, `&&`, `|`, backticks, `$`, redirection, …): the backend
1816
+ * spawns it with no shell, and unquoted metacharacters were exactly how an
1817
+ * injected command chain used to ride past the allowlist. In shell mode
1818
+ * (`interpreter: "sh"`) every simple command in the line is checked. Each
1819
+ * argv's executable basename is matched against `shell.deny` (wins, case
1820
+ * insensitive) then `shell.allow` (exhaustive when non-empty), then
1821
+ * `shell.denyPatterns` and `shell.allowCommand`. An absent `shell` block
1822
+ * means no command may run at all.
308
1823
  *
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.
1824
+ * Returns a plain `boolean`; use {@link checkShellCommand} for the reason
1825
+ * and the model-facing message. Redirection targets are checked lexically
1826
+ * here; `exec` re-checks them with symlinks resolved.
312
1827
  *
313
1828
  * @example
314
1829
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
@@ -316,15 +1831,7 @@ function executableBasename(firstToken) {
316
1831
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
317
1832
  */
318
1833
  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;
1834
+ return checkShellCommand(policy, command).allowed;
328
1835
  }
329
1836
  /**
330
1837
  * Build the exact environment a spawned process receives — `process.env`
@@ -349,15 +1856,303 @@ function buildEnv(policy) {
349
1856
  if (shell.env) for (const [key, value] of Object.entries(shell.env)) env[key] = value;
350
1857
  return env;
351
1858
  }
1859
+ /**
1860
+ * Variables removed from the environment of a shell-mode (`interpreter:
1861
+ * "sh"`) command: each changes how the shell itself starts, parses or
1862
+ * resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
1863
+ * `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
1864
+ * shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
1865
+ */
1866
+ const SHELL_STARTUP_VARIABLES = new Set([
1867
+ "ENV",
1868
+ "BASH_ENV",
1869
+ "SHELLOPTS",
1870
+ "BASHOPTS",
1871
+ "CDPATH",
1872
+ "IFS",
1873
+ "PS4",
1874
+ "GLOBIGNORE"
1875
+ ]);
1876
+ /**
1877
+ * The environment a command actually receives: {@link buildEnv}, minus the
1878
+ * shell start-up variables when the policy runs lines through a shell.
1879
+ * Compared case-insensitively, because Windows environment names are.
1880
+ */
1881
+ function buildCommandEnv(policy) {
1882
+ const env = buildEnv(policy);
1883
+ if ((policy.shell?.interpreter ?? "none") !== "sh") return env;
1884
+ for (const key of Object.keys(env)) {
1885
+ const upper = key.toUpperCase();
1886
+ if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
1887
+ }
1888
+ return env;
1889
+ }
1890
+
1891
+ //#endregion
1892
+ //#region ../../packages/ai-workspace/src/shell-jobs.ts
1893
+ /** Finished commands kept for a last `shell_output` read before the oldest are pruned. */
1894
+ const MAX_FINISHED_JOBS = 16;
1895
+ /** Longest `waitMs` a `shellOutput` call may ask for. */
1896
+ const MAX_OUTPUT_WAIT_MS = 3e4;
1897
+ /** How long `kill()` / `dispose()` wait for a killed tree to report its exit. */
1898
+ const KILL_SETTLE_MS = 5e3;
1899
+ /** Resolve after `ms`, without keeping the event loop alive. */
1900
+ function delay(ms) {
1901
+ return new Promise((resolve) => {
1902
+ setTimeout(resolve, ms).unref?.();
1903
+ });
1904
+ }
1905
+ /**
1906
+ * Keep the last `maxBytes` UTF-8 bytes of `text`, cutting forward to a
1907
+ * character boundary so the kept tail never starts mid-character.
1908
+ */
1909
+ function tailUtf8(text, maxBytes) {
1910
+ const buffer = Buffer.from(text, "utf8");
1911
+ if (buffer.byteLength <= maxBytes) return {
1912
+ text,
1913
+ bytes: buffer.byteLength
1914
+ };
1915
+ let start = buffer.byteLength - maxBytes;
1916
+ while (start < buffer.byteLength && ((buffer[start] ?? 0) & 192) === 128) start++;
1917
+ const kept = buffer.subarray(start);
1918
+ return {
1919
+ text: kept.toString("utf8"),
1920
+ bytes: kept.byteLength
1921
+ };
1922
+ }
1923
+ /** Bytes of `text` from byte `skip` on, moved forward to a character boundary. */
1924
+ function skipUtf8(text, skip) {
1925
+ const buffer = Buffer.from(text, "utf8");
1926
+ let start = Math.max(0, skip);
1927
+ while (start < buffer.byteLength && ((buffer[start] ?? 0) & 192) === 128) start++;
1928
+ return buffer.subarray(start).toString("utf8");
1929
+ }
1930
+ /** One background command and its retained output. */
1931
+ var ShellJob = class {
1932
+ constructor(id, command, retainBytes) {
1933
+ this.id = id;
1934
+ this.command = command;
1935
+ this.retainBytes = retainBytes;
1936
+ this.status = "running";
1937
+ this.exitCode = null;
1938
+ this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
1939
+ this.produced = 0;
1940
+ this.readCursor = 0;
1941
+ this.chunks = [];
1942
+ this.retained = 0;
1943
+ this.listeners = /* @__PURE__ */ new Set();
1944
+ this.killRequested = false;
1945
+ this.timedOut = false;
1946
+ }
1947
+ append(stream, chunk) {
1948
+ if (chunk === "") return;
1949
+ const bytes = Buffer.byteLength(chunk, "utf8");
1950
+ this.chunks.push({
1951
+ stream,
1952
+ text: chunk,
1953
+ offset: this.produced,
1954
+ bytes
1955
+ });
1956
+ this.produced += bytes;
1957
+ this.retained += bytes;
1958
+ this.trim();
1959
+ this.notify();
1960
+ }
1961
+ /** Drop the oldest output until at most `retainBytes` are kept. */
1962
+ trim() {
1963
+ while (this.retained > this.retainBytes && this.chunks.length > 0) {
1964
+ const oldest = this.chunks[0];
1965
+ if (oldest === void 0) return;
1966
+ const excess = this.retained - this.retainBytes;
1967
+ if (oldest.bytes <= excess) {
1968
+ this.chunks.shift();
1969
+ this.retained -= oldest.bytes;
1970
+ continue;
1971
+ }
1972
+ const kept = tailUtf8(oldest.text, oldest.bytes - excess);
1973
+ this.retained -= oldest.bytes - kept.bytes;
1974
+ oldest.offset += oldest.bytes - kept.bytes;
1975
+ oldest.text = kept.text;
1976
+ oldest.bytes = kept.bytes;
1977
+ }
1978
+ }
1979
+ finish(exitCode) {
1980
+ if (this.status !== "running") return;
1981
+ this.exitCode = exitCode;
1982
+ this.status = this.timedOut ? "timed-out" : this.killRequested ? "killed" : "exited";
1983
+ this.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1984
+ if (this.timer !== void 0) clearTimeout(this.timer);
1985
+ this.notify();
1986
+ }
1987
+ notify() {
1988
+ for (const listener of [...this.listeners]) listener();
1989
+ }
1990
+ /** Resolve on the next output or state change (or after `ms`, or on abort). */
1991
+ waitForChange(ms, signal) {
1992
+ return new Promise((resolve) => {
1993
+ if (signal?.aborted) {
1994
+ resolve();
1995
+ return;
1996
+ }
1997
+ let timer;
1998
+ const done = () => {
1999
+ if (timer !== void 0) clearTimeout(timer);
2000
+ this.listeners.delete(done);
2001
+ signal?.removeEventListener("abort", done);
2002
+ resolve();
2003
+ };
2004
+ timer = setTimeout(done, ms);
2005
+ timer.unref?.();
2006
+ this.listeners.add(done);
2007
+ signal?.addEventListener("abort", done, { once: true });
2008
+ });
2009
+ }
2010
+ info() {
2011
+ return {
2012
+ id: this.id,
2013
+ command: this.command,
2014
+ status: this.status,
2015
+ exitCode: this.exitCode,
2016
+ ...this.process?.pid !== void 0 ? { pid: this.process.pid } : {},
2017
+ startedAt: this.startedAt,
2018
+ ...this.endedAt !== void 0 ? { endedAt: this.endedAt } : {}
2019
+ };
2020
+ }
2021
+ /** Output produced after byte `since`, plus how much of it is gone. */
2022
+ read(since) {
2023
+ const from = Math.min(Math.max(0, Math.floor(since)), this.produced);
2024
+ const firstRetained = this.chunks[0]?.offset ?? this.produced;
2025
+ const droppedBytes = Math.max(0, firstRetained - from);
2026
+ let stdout = "";
2027
+ let stderr = "";
2028
+ for (const chunk of this.chunks) {
2029
+ if (chunk.offset + chunk.bytes <= from) continue;
2030
+ const text = chunk.offset >= from ? chunk.text : skipUtf8(chunk.text, from - chunk.offset);
2031
+ if (chunk.stream === "stdout") stdout += text;
2032
+ else stderr += text;
2033
+ }
2034
+ this.readCursor = this.produced;
2035
+ return {
2036
+ ...this.info(),
2037
+ stdout,
2038
+ stderr,
2039
+ cursor: this.produced,
2040
+ droppedBytes,
2041
+ truncated: droppedBytes > 0
2042
+ };
2043
+ }
2044
+ };
2045
+ /**
2046
+ * The background commands one workspace owns. Shared by every projection
2047
+ * (`readonly()`, `scope()`) of the workspace, so `dispose()` on any of them
2048
+ * stops them all.
2049
+ */
2050
+ var ShellJobs = class {
2051
+ constructor() {
2052
+ this.jobs = /* @__PURE__ */ new Map();
2053
+ this.nextId = 1;
2054
+ this.disposed = false;
2055
+ }
2056
+ /** Whether {@link dispose} has run. */
2057
+ get isDisposed() {
2058
+ return this.disposed;
2059
+ }
2060
+ /** Start a command as a background job. */
2061
+ start(spec) {
2062
+ if (this.disposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot start new commands.", {
2063
+ type: "denied-command",
2064
+ reason: "not-allowed",
2065
+ command: spec.command
2066
+ });
2067
+ const running = [...this.jobs.values()].filter((job) => job.status === "running").length;
2068
+ if (running >= spec.maxJobs) throw new WorkspacePolicyError(`${running} background commands are already running (the limit is ${spec.maxJobs}). Stop one with shell_kill first.`, {
2069
+ type: "denied-command",
2070
+ reason: "not-allowed",
2071
+ command: spec.command
2072
+ });
2073
+ const job = new ShellJob(`job-${this.nextId++}`, spec.command, spec.retainBytes);
2074
+ this.jobs.set(job.id, job);
2075
+ const handle = spec.launch((stream, chunk) => job.append(stream, chunk));
2076
+ job.process = handle;
2077
+ if (spec.timeoutMs !== void 0 && spec.timeoutMs > 0) {
2078
+ job.timer = setTimeout(() => {
2079
+ if (job.status === "running") {
2080
+ job.timedOut = true;
2081
+ handle.kill();
2082
+ }
2083
+ }, spec.timeoutMs);
2084
+ job.timer.unref?.();
2085
+ }
2086
+ handle.done.then(({ exitCode }) => job.finish(exitCode));
2087
+ this.prune();
2088
+ return job.info();
2089
+ }
2090
+ /** Forget the oldest finished jobs beyond {@link MAX_FINISHED_JOBS}. */
2091
+ prune() {
2092
+ const finished = [...this.jobs.values()].filter((job) => job.status !== "running");
2093
+ for (const job of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_JOBS))) this.jobs.delete(job.id);
2094
+ }
2095
+ get(id) {
2096
+ const job = this.jobs.get(id);
2097
+ if (job === void 0) throw new WorkspaceJobError(`No background command with id "${id}" exists in this workspace.`, {
2098
+ type: "not-found",
2099
+ jobId: id
2100
+ });
2101
+ return job;
2102
+ }
2103
+ /** New output of a job (see {@link ShellOutputOptions}). */
2104
+ async output(id, opts) {
2105
+ const job = this.get(id);
2106
+ const since = opts?.since ?? job.readCursor;
2107
+ const waitMs = Math.min(Math.max(0, opts?.waitMs ?? 0), MAX_OUTPUT_WAIT_MS);
2108
+ if (waitMs > 0 && job.status === "running" && job.produced <= since) await job.waitForChange(waitMs, opts?.signal);
2109
+ return job.read(since);
2110
+ }
2111
+ /** Kill a job's process tree and wait (bounded) for it to stop. */
2112
+ async kill(id) {
2113
+ const job = this.get(id);
2114
+ await this.stop(job);
2115
+ return job.info();
2116
+ }
2117
+ async stop(job) {
2118
+ if (job.status !== "running" || job.process === void 0) return;
2119
+ job.killRequested = true;
2120
+ job.process.kill();
2121
+ await Promise.race([job.process.done, delay(KILL_SETTLE_MS)]);
2122
+ }
2123
+ /** Every job, oldest first. */
2124
+ list() {
2125
+ return [...this.jobs.values()].map((job) => job.info());
2126
+ }
2127
+ /** Kill every running job and refuse new ones. Idempotent. */
2128
+ async dispose() {
2129
+ this.disposed = true;
2130
+ await Promise.all([...this.jobs.values()].map((job) => this.stop(job)));
2131
+ }
2132
+ };
352
2133
 
353
2134
  //#endregion
354
- //#region ../ai-workspace/src/ops.ts
2135
+ //#region ../../packages/ai-workspace/src/ops.ts
355
2136
  /** Default line window a read returns when the policy sets no `defaultLines`. */
356
2137
  const DEFAULT_READ_LINES = 2e3;
357
2138
  /** Hard ceiling on grep matches returned, so a broad pattern can't flood. */
358
2139
  const DEFAULT_MAX_GREP_MATCHES = 1e3;
359
2140
  /** Default per-command output byte cap when the policy sets none. */
360
2141
  const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
2142
+ /** Default `list.ignore` when the policy sets none. */
2143
+ const DEFAULT_LIST_IGNORE = ["node_modules", ".git"];
2144
+ /** Default deepest `depth` a listing may ask for. */
2145
+ const DEFAULT_LIST_MAX_DEPTH = 5;
2146
+ /** Default most entries one listing returns. */
2147
+ const DEFAULT_LIST_MAX_ENTRIES = 500;
2148
+ /** Most edits one `multiEdit` call may carry. */
2149
+ const MAX_MULTI_EDITS = 100;
2150
+ /**
2151
+ * Exit code reported for a command refused because its signal was already
2152
+ * aborted (the shell convention for an interrupted command). Callers should
2153
+ * branch on `cancelled`, not on this number.
2154
+ */
2155
+ const CANCELLED_BEFORE_START_EXIT_CODE = 130;
361
2156
  /**
362
2157
  * Hard ceiling on `grep` pattern length. A model-controlled regex has no
363
2158
  * legitimate reason to be this long; longer patterns are rejected outright
@@ -372,57 +2167,74 @@ const MAX_GREP_PATTERN_LENGTH = 200;
372
2167
  * this are skipped rather than scanned.
373
2168
  */
374
2169
  const MAX_GREP_LINE_SCAN_LENGTH = 2e3;
375
- /**
376
- * Number the lines of `content` `cat -n` style: a right-aligned line
377
- * number (min width 6), a tab, then the line. `startLine` is the 1-based
378
- * number of the first line in the window.
379
- */
380
- function numberLines(content, startLine) {
381
- return content.split("\n").map((line, index) => {
382
- const lineNumber = startLine + index;
383
- return `${String(lineNumber).padStart(6, " ")}\t${line}`;
384
- }).join("\n");
2170
+ /** The `cat -n` prefix for a line: right-aligned number (min width 6) and a tab. */
2171
+ function linePrefix(lineNumber) {
2172
+ return `${String(lineNumber).padStart(6, " ")}\t`;
385
2173
  }
386
2174
  /**
387
- * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes).
388
- * Returns the possibly-clipped string plus whether clipping occurred.
2175
+ * Clip `value` to at most `maxBytes` UTF-8 bytes without splitting a
2176
+ * character: the cut backs off over continuation bytes (`10xxxxxx`) to the
2177
+ * start of the character it would have split, so the result never carries
2178
+ * a replacement character.
389
2179
  */
390
- function capOutput(value, maxBytes) {
2180
+ function clipUtf8(value, maxBytes) {
391
2181
  const bytes = Buffer.from(value, "utf8");
392
2182
  if (bytes.byteLength <= maxBytes) return {
393
2183
  value,
394
2184
  truncated: false
395
2185
  };
2186
+ let cut = Math.max(0, maxBytes);
2187
+ while (cut > 0 && ((bytes[cut] ?? 0) & 192) === 128) cut--;
396
2188
  return {
397
- value: bytes.subarray(0, maxBytes).toString("utf8"),
2189
+ value: bytes.subarray(0, cut).toString("utf8"),
398
2190
  truncated: true
399
2191
  };
400
2192
  }
401
2193
  /**
402
- * Minimal glob match over a `/`-separated relative path. Supports `**`
403
- * (spans separators), `*` (within a segment), and `?` (one non-separator
404
- * char) — enough for the workspace's `glob`/grep narrowing without
405
- * pulling in a runtime dependency.
2194
+ * Number a window of lines `cat -n` style, keeping the joined output within
2195
+ * `maxBytes` UTF-8 bytes (prefixes and newlines included) when a cap is set.
2196
+ * A line that does not fit whole is clipped on a character boundary; it is
2197
+ * still counted as returned when its prefix and at least one character fit.
406
2198
  */
407
- function globToRegExp(glob) {
408
- let source = "";
409
- for (let index = 0; index < glob.length; index++) {
410
- const char = glob[index];
411
- if (char === "*") {
412
- if (glob[index + 1] === "*") {
413
- source += ".*";
414
- index++;
415
- if (glob[index + 1] === "/") index++;
416
- } else source += "[^/]*";
2199
+ function numberWindow(lines, startLine, maxBytes) {
2200
+ const parts = [];
2201
+ let used = 0;
2202
+ for (let index = 0; index < lines.length; index++) {
2203
+ const prefix = linePrefix(startLine + index);
2204
+ const numbered = prefix + lines[index];
2205
+ const separator = parts.length > 0 ? 1 : 0;
2206
+ if (maxBytes === void 0) {
2207
+ parts.push(numbered);
417
2208
  continue;
418
2209
  }
419
- if (char === "?") {
420
- source += "[^/]";
2210
+ const cost = separator + Buffer.byteLength(numbered, "utf8");
2211
+ if (used + cost <= maxBytes) {
2212
+ parts.push(numbered);
2213
+ used += cost;
421
2214
  continue;
422
2215
  }
423
- source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
2216
+ const room = maxBytes - used - separator - Buffer.byteLength(prefix, "utf8");
2217
+ const clipped = room > 0 ? clipUtf8(lines[index] ?? "", room).value : "";
2218
+ if (clipped.length > 0) parts.push(prefix + clipped);
2219
+ return {
2220
+ content: parts.join("\n"),
2221
+ returnedLines: parts.length,
2222
+ bytesCapped: true
2223
+ };
424
2224
  }
425
- return new RegExp(`^${source}$`);
2225
+ return {
2226
+ content: parts.join("\n"),
2227
+ returnedLines: parts.length,
2228
+ bytesCapped: false
2229
+ };
2230
+ }
2231
+ /**
2232
+ * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes), on a
2233
+ * character boundary. Returns the possibly-clipped string plus whether
2234
+ * clipping occurred.
2235
+ */
2236
+ function capOutput(value, maxBytes) {
2237
+ return clipUtf8(value, maxBytes);
426
2238
  }
427
2239
  /**
428
2240
  * A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.
@@ -446,17 +2258,29 @@ function isSafeGrepPattern(pattern) {
446
2258
  if (pattern.length > MAX_GREP_PATTERN_LENGTH) return false;
447
2259
  return !NESTED_QUANTIFIER_PATTERN.test(pattern);
448
2260
  }
449
- /**
450
- * The internal, single-instance implementation of {@link WorkspaceOps}.
451
- * Holds the backend + policy and is the one place the jail, command
452
- * gating, read caps, and the read-before-edit guard are enforced — both
453
- * the agent-facing tools and the human-facing direct methods funnel
454
- * through this object, so there is exactly one set of rules.
455
- */
456
2261
  var Ops = class {
457
- constructor(backend, policy) {
2262
+ constructor(backend, policy, runtime) {
458
2263
  this.backend = backend;
459
2264
  this.policy = policy;
2265
+ this.runtime = runtime;
2266
+ this.pathLocks = /* @__PURE__ */ new Map();
2267
+ }
2268
+ /** Run `task` after every earlier mutation of `absolutePath` has settled. */
2269
+ async withPathLock(absolutePath, task) {
2270
+ const previous = this.pathLocks.get(absolutePath) ?? Promise.resolve();
2271
+ let release = () => {};
2272
+ const current = new Promise((resolve) => {
2273
+ release = resolve;
2274
+ });
2275
+ const tail = previous.then(() => current);
2276
+ this.pathLocks.set(absolutePath, tail);
2277
+ try {
2278
+ await previous;
2279
+ return await task();
2280
+ } finally {
2281
+ release();
2282
+ if (this.pathLocks.get(absolutePath) === tail) this.pathLocks.delete(absolutePath);
2283
+ }
460
2284
  }
461
2285
  /**
462
2286
  * Recursively collect every file under `absDir` as absolute paths,
@@ -491,25 +2315,39 @@ var Ops = class {
491
2315
  const totalLines = lines.length;
492
2316
  const offset = Math.max(1, opts?.offset ?? 1);
493
2317
  const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
2318
+ const maxBytes = this.policy.read?.maxBytes;
494
2319
  const startIndex = offset - 1;
2320
+ const { content, returnedLines, bytesCapped } = numberWindow(lines.slice(startIndex, startIndex + Math.max(0, limit)), offset, maxBytes === void 0 ? void 0 : Math.max(0, Math.floor(maxBytes)));
2321
+ const endLine = offset + returnedLines - 1;
495
2322
  return {
496
- content: numberLines(lines.slice(startIndex, startIndex + limit).join("\n"), offset),
497
- hash,
498
- totalLines
2323
+ content,
2324
+ startLine: offset,
2325
+ endLine,
2326
+ totalLines,
2327
+ truncated: bytesCapped || endLine < totalLines,
2328
+ bytesCapped,
2329
+ totalBytes: Buffer.byteLength(raw, "utf8"),
2330
+ hash
499
2331
  };
500
2332
  }
501
2333
  async writeFile(inputPath, content) {
502
2334
  const { absolutePath } = await resolveInJail(this.policy, inputPath);
503
- const parent = node_path.default.dirname(absolutePath);
504
- await this.backend.mkdir(parent);
505
- await this.backend.writeFile(absolutePath, content);
506
- return {
507
- hash: _warlock_js_fs.fs.hash.string(content),
508
- bytesWritten: Buffer.byteLength(content, "utf8")
509
- };
2335
+ return this.withPathLock(absolutePath, async () => {
2336
+ const parent = node_path.default.dirname(absolutePath);
2337
+ await this.backend.mkdir(parent);
2338
+ await this.backend.writeFile(absolutePath, content);
2339
+ return {
2340
+ hash: _warlock_js_fs.fs.hash.string(content),
2341
+ bytesWritten: Buffer.byteLength(content, "utf8")
2342
+ };
2343
+ });
510
2344
  }
511
2345
  async editFile(input) {
512
2346
  const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
2347
+ return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
2348
+ }
2349
+ /** The read-check-replace-write of {@link editFile}, run under its path lock. */
2350
+ async applyEdit(input, absolutePath, relativePath) {
513
2351
  const current = await this.backend.readFile(absolutePath);
514
2352
  const currentHash = _warlock_js_fs.fs.hash.string(current);
515
2353
  if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; the edit is stale.`, {
@@ -518,48 +2356,222 @@ var Ops = class {
518
2356
  expectedHash: input.expectHash,
519
2357
  actualHash: currentHash
520
2358
  });
521
- const occurrences = countOccurrences(current, input.oldString);
522
- if (occurrences === 0) throw new WorkspaceEditError(`The text to replace was not found in "${input.path}".`, {
523
- type: "not-found",
524
- path: relativePath || input.path,
525
- matches: 0
526
- });
527
- 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.`, {
528
- type: "not-unique",
529
- path: relativePath || input.path,
530
- matches: occurrences
531
- });
532
- const updated = input.replaceAll ? current.split(input.oldString).join(input.newString) : replaceFirst(current, input.oldString, input.newString);
2359
+ const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
533
2360
  await this.backend.writeFile(absolutePath, updated);
534
2361
  return {
535
2362
  path: relativePath || input.path,
536
- replacements: input.replaceAll ? occurrences : 1,
2363
+ replacements,
537
2364
  hash: _warlock_js_fs.fs.hash.string(updated)
538
2365
  };
539
2366
  }
540
- async exec(command, opts) {
541
- if (!isCommandAllowed(this.policy, command)) throw new WorkspacePolicyError(`Command is not permitted by the workspace shell policy: ${command}`, {
2367
+ async multiEdit(input) {
2368
+ const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
2369
+ const shownPath = relativePath || input.path;
2370
+ 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}).`);
2371
+ return this.withPathLock(absolutePath, async () => {
2372
+ const current = await this.backend.readFile(absolutePath);
2373
+ const currentHash = _warlock_js_fs.fs.hash.string(current);
2374
+ 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.`, {
2375
+ type: "stale-hash",
2376
+ path: shownPath,
2377
+ expectedHash: input.expectHash,
2378
+ actualHash: currentHash
2379
+ });
2380
+ let content = current;
2381
+ let replacements = 0;
2382
+ input.edits.forEach((edit, editIndex) => {
2383
+ const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
2384
+ content = applied.content;
2385
+ replacements += applied.replacements;
2386
+ });
2387
+ await this.backend.writeFile(absolutePath, content);
2388
+ return {
2389
+ path: shownPath,
2390
+ edits: input.edits.length,
2391
+ replacements,
2392
+ hash: _warlock_js_fs.fs.hash.string(content)
2393
+ };
2394
+ });
2395
+ }
2396
+ /**
2397
+ * Check a command line against the shell policy: every command in it
2398
+ * (argv or shell mode), then — shell mode — every redirection target and
2399
+ * `cd` directory against the jail with symlinks resolved.
2400
+ */
2401
+ async assertCommandAllowed(command) {
2402
+ const verdict = checkShellCommand(this.policy, command);
2403
+ if (!verdict.allowed) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
2404
+ type: "denied-command",
2405
+ command,
2406
+ ...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
2407
+ ...verdict.executable !== void 0 ? { executable: verdict.executable } : {},
2408
+ ...verdict.path !== void 0 ? { path: verdict.path } : {}
2409
+ });
2410
+ for (const target of verdict.paths) try {
2411
+ await resolveInJail(this.policy, target.path);
2412
+ } catch (error) {
2413
+ if (!(error instanceof WorkspacePolicyError)) throw error;
2414
+ 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.`, {
2415
+ type: "denied-command",
2416
+ reason: "path-escape",
2417
+ command,
2418
+ path: target.target
2419
+ });
2420
+ }
2421
+ if (this.runtime.jobs.isDisposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot run new commands.", {
542
2422
  type: "denied-command",
2423
+ reason: "not-allowed",
543
2424
  command
544
2425
  });
2426
+ }
2427
+ async exec(command, opts) {
2428
+ await this.assertCommandAllowed(command);
2429
+ const signal = opts?.signal;
2430
+ if (signal?.aborted) return {
2431
+ exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
2432
+ stdout: "",
2433
+ stderr: "Command was not executed: the invocation was cancelled before it started.",
2434
+ truncated: false,
2435
+ timedOut: false,
2436
+ cancelled: true
2437
+ };
545
2438
  const shell = this.policy.shell;
546
2439
  const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
547
2440
  const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
2441
+ const interpreter = shell?.interpreter ?? "none";
548
2442
  const result = await this.backend.exec(command, {
549
2443
  cwd: this.policy.cwd,
550
2444
  timeoutMs,
551
- env: buildEnv(this.policy)
2445
+ env: buildCommandEnv(this.policy),
2446
+ ...signal ? { signal } : {},
2447
+ ...interpreter !== "none" ? { interpreter } : {}
552
2448
  });
553
2449
  const stdout = capOutput(result.stdout, maxOutputBytes);
554
2450
  const stderr = capOutput(result.stderr, maxOutputBytes);
2451
+ const cancelled = result.cancelled === true;
555
2452
  return {
556
2453
  exitCode: result.exitCode,
557
2454
  stdout: stdout.value,
558
2455
  stderr: stderr.value,
559
2456
  truncated: stdout.truncated || stderr.truncated,
560
- timedOut: result.timedOut
2457
+ timedOut: result.timedOut && !cancelled,
2458
+ cancelled
2459
+ };
2460
+ }
2461
+ async startShell(command, opts) {
2462
+ const background = this.policy.shell?.background;
2463
+ 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.", {
2464
+ type: "denied-command",
2465
+ reason: "not-allowed",
2466
+ command
2467
+ });
2468
+ await this.assertCommandAllowed(command);
2469
+ const start = this.backend.start?.bind(this.backend);
2470
+ if (start === void 0) throw new WorkspacePolicyError("This workspace backend cannot run background commands.", {
2471
+ type: "denied-command",
2472
+ reason: "not-allowed",
2473
+ command
2474
+ });
2475
+ const shell = this.policy.shell;
2476
+ const interpreter = shell?.interpreter ?? "none";
2477
+ const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
2478
+ const env = buildCommandEnv(this.policy);
2479
+ return this.runtime.jobs.start({
2480
+ command,
2481
+ retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
2482
+ maxJobs: background.maxJobs ?? 4,
2483
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
2484
+ launch: (onOutput) => start(command, {
2485
+ cwd: this.policy.cwd,
2486
+ env,
2487
+ ...interpreter !== "none" ? { interpreter } : {},
2488
+ onOutput
2489
+ })
2490
+ });
2491
+ }
2492
+ shellOutput(id, opts) {
2493
+ return this.runtime.jobs.output(id, opts);
2494
+ }
2495
+ shellKill(id) {
2496
+ return this.runtime.jobs.kill(id);
2497
+ }
2498
+ listShells() {
2499
+ return this.runtime.jobs.list();
2500
+ }
2501
+ dispose() {
2502
+ return this.runtime.jobs.dispose();
2503
+ }
2504
+ async listDir(inputPath = ".", opts) {
2505
+ const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
2506
+ const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
2507
+ const listPolicy = this.policy.list;
2508
+ const maxDepth = Math.max(1, Math.floor(listPolicy?.maxDepth ?? DEFAULT_LIST_MAX_DEPTH));
2509
+ const depth = Math.min(Math.max(1, Math.floor(opts?.depth ?? 1)), maxDepth);
2510
+ const maxEntries = Math.max(1, Math.floor(listPolicy?.maxEntries ?? DEFAULT_LIST_MAX_ENTRIES));
2511
+ const ignore = [...listPolicy?.ignore ?? DEFAULT_LIST_IGNORE, ...opts?.ignore ?? []];
2512
+ const insideCwd = isInside(root, jailRoot);
2513
+ if (!await this.isDirectory(root)) throw new Error(`"${inputPath}" is not a directory in the workspace.`);
2514
+ const entries = [];
2515
+ let level = [root];
2516
+ let truncated = false;
2517
+ for (let currentDepth = 1; currentDepth <= depth && level.length > 0; currentDepth++) {
2518
+ const nextLevel = [];
2519
+ for (const directory of level) {
2520
+ let children;
2521
+ try {
2522
+ children = await this.backend.list(directory);
2523
+ } catch {
2524
+ continue;
2525
+ }
2526
+ children.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
2527
+ for (const child of children) {
2528
+ const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, child).split(node_path.default.sep).join("/") : child;
2529
+ if (insideCwd && (this.isDenied(relativeToJail) || matchesAnyGlob(relativeToJail, ignore))) continue;
2530
+ const relativeToRoot = node_path.default.relative(root, child).split(node_path.default.sep).join("/");
2531
+ if (!insideCwd && matchesAnyGlob(relativeToRoot, ignore)) continue;
2532
+ if (entries.length >= maxEntries) {
2533
+ truncated = true;
2534
+ break;
2535
+ }
2536
+ const described = await this.describe(child);
2537
+ entries.push({
2538
+ path: relativeToJail,
2539
+ type: described.type,
2540
+ ...described.size !== void 0 ? { size: described.size } : {},
2541
+ depth: currentDepth
2542
+ });
2543
+ if (described.type === "directory") nextLevel.push(child);
2544
+ }
2545
+ if (truncated) break;
2546
+ }
2547
+ if (truncated) break;
2548
+ level = nextLevel;
2549
+ }
2550
+ return {
2551
+ path: insideCwd ? rootRelative || "." : root,
2552
+ entries,
2553
+ truncated
561
2554
  };
562
2555
  }
2556
+ /** Kind and size of a listed entry; symlinks are reported, never followed. */
2557
+ async describe(absPath) {
2558
+ if (this.backend.stat) try {
2559
+ const stats = await this.backend.stat(absPath);
2560
+ return stats.type === "file" ? {
2561
+ type: "file",
2562
+ size: stats.size
2563
+ } : { type: stats.type };
2564
+ } catch {
2565
+ return { type: "other" };
2566
+ }
2567
+ if (await this.isDirectory(absPath)) try {
2568
+ await resolveInJail(this.policy, absPath);
2569
+ return { type: "directory" };
2570
+ } catch {
2571
+ return { type: "other" };
2572
+ }
2573
+ return { type: "file" };
2574
+ }
563
2575
  async grep(pattern, opts) {
564
2576
  if (!isSafeGrepPattern(pattern)) throw new WorkspacePolicyError(`Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`, {
565
2577
  type: "unsafe-pattern",
@@ -583,7 +2595,7 @@ var Ops = class {
583
2595
  }
584
2596
  const lines = content.split("\n");
585
2597
  for (let index = 0; index < lines.length; index++) {
586
- const line = lines[index];
2598
+ const line = lines[index] ?? "";
587
2599
  if (line.length > MAX_GREP_LINE_SCAN_LENGTH) continue;
588
2600
  if (regex.test(line)) {
589
2601
  matches.push({
@@ -625,22 +2637,45 @@ var Ops = class {
625
2637
  }
626
2638
  async remove(inputPath) {
627
2639
  const { absolutePath } = await resolveInJail(this.policy, inputPath);
628
- await this.backend.remove(absolutePath);
2640
+ await this.withPathLock(absolutePath, () => this.backend.remove(absolutePath));
629
2641
  }
630
2642
  /** Whether a `/`-separated relative path hits the policy deny list. */
631
2643
  isDenied(relativePath) {
632
2644
  const denyPaths = this.policy.denyPaths;
633
- if (!denyPaths || denyPaths.length === 0) return false;
634
- return denyPaths.some((glob) => {
635
- if (globToRegExp(glob).test(relativePath)) return true;
636
- if (!glob.includes("*") && !glob.includes("?")) {
637
- const prefix = glob.endsWith("/") ? glob : `${glob}/`;
638
- return relativePath.startsWith(prefix);
639
- }
640
- return false;
641
- });
2645
+ return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
642
2646
  }
643
2647
  };
2648
+ /** The lower of two optional limits (either may be absent). */
2649
+ function lowerOf(policyLimit, requested) {
2650
+ if (requested === void 0 || requested <= 0) return policyLimit;
2651
+ return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
2652
+ }
2653
+ /**
2654
+ * Apply one exact-string replacement to `content` under the edit rules:
2655
+ * `oldString` must occur, and exactly once unless `replaceAll`.
2656
+ */
2657
+ function applyReplacement(content, edit, inputPath, shownPath, editIndex) {
2658
+ const occurrences = countOccurrences(content, edit.oldString);
2659
+ const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
2660
+ const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
2661
+ const indexOption = editIndex === void 0 ? {} : { editIndex };
2662
+ if (occurrences === 0) throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".${nothingApplied}`, {
2663
+ type: "not-found",
2664
+ path: shownPath,
2665
+ matches: 0,
2666
+ ...indexOption
2667
+ });
2668
+ 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}`, {
2669
+ type: "not-unique",
2670
+ path: shownPath,
2671
+ matches: occurrences,
2672
+ ...indexOption
2673
+ });
2674
+ return {
2675
+ content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
2676
+ replacements: edit.replaceAll ? occurrences : 1
2677
+ };
2678
+ }
644
2679
  /** Count non-overlapping occurrences of `needle` in `haystack`. */
645
2680
  function countOccurrences(haystack, needle) {
646
2681
  if (needle === "") return 0;
@@ -672,17 +2707,19 @@ function replaceFirst(haystack, needle, replacement) {
672
2707
  *
673
2708
  * @param backend - The dumb IO executor (local disk or in-memory mock).
674
2709
  * @param policy - The policy that bounds every operation.
2710
+ * @param runtime - State shared with other views of the same workspace
2711
+ * (its background commands); a fresh one when omitted.
675
2712
  *
676
2713
  * @example
677
2714
  * const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
678
2715
  * const { content, hash } = await ops.readFile("src/index.ts");
679
2716
  */
680
- function createOps(backend, policy) {
681
- return new Ops(backend, policy);
2717
+ function createOps(backend, policy, runtime) {
2718
+ return new Ops(backend, policy, runtime ?? { jobs: new ShellJobs() });
682
2719
  }
683
2720
 
684
2721
  //#endregion
685
- //#region ../ai-workspace/src/backends/local.ts
2722
+ //#region ../../packages/ai-workspace/src/backends/local.ts
686
2723
  /**
687
2724
  * Hard ceiling on captured `stdout`/`stderr` per stream, in bytes. The raw
688
2725
  * executor truncates each stream once this many bytes have accumulated so a
@@ -729,7 +2766,9 @@ function killTree(pid, child) {
729
2766
  String(pid),
730
2767
  "/T",
731
2768
  "/F"
732
- ], { windowsHide: true });
2769
+ ], { windowsHide: true }).on("error", () => {
2770
+ child.kill("SIGKILL");
2771
+ });
733
2772
  return;
734
2773
  }
735
2774
  try {
@@ -771,6 +2810,74 @@ function toWin32CmdInvocation(argv) {
771
2810
  ]
772
2811
  };
773
2812
  }
2813
+ /** The shell a POSIX shell-mode line runs through — an absolute path, never a PATH lookup. */
2814
+ const POSIX_SHELL = "/bin/sh";
2815
+ /**
2816
+ * Work out the spawn for a command line.
2817
+ *
2818
+ * - argv mode: tokenize (no shell semantics) and spawn the argv directly —
2819
+ * on Windows through an individually-quoted `cmd.exe` wrapper.
2820
+ * - shell mode: `/bin/sh -c <line>`, or `cmd.exe /d /v:off /s /c "<line>"`
2821
+ * on Windows (`/d` skips AutoRun, `/v:off` disables delayed `!` expansion,
2822
+ * `/s` keeps the line verbatim inside the outer quotes). The ops layer has
2823
+ * already checked every command in the line.
2824
+ */
2825
+ function planSpawn(command, interpreter) {
2826
+ if (interpreter === "sh") {
2827
+ if (node_process.platform === "win32") return {
2828
+ file: process.env.ComSpec ?? "cmd.exe",
2829
+ args: [
2830
+ "/d",
2831
+ "/v:off",
2832
+ "/s",
2833
+ "/c",
2834
+ `"${command}"`
2835
+ ],
2836
+ windowsVerbatimArguments: true
2837
+ };
2838
+ return {
2839
+ file: POSIX_SHELL,
2840
+ args: ["-c", command],
2841
+ windowsVerbatimArguments: false
2842
+ };
2843
+ }
2844
+ const argv = tokenizeCommand(command);
2845
+ const executable = argv?.[0];
2846
+ 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." };
2847
+ if (node_process.platform === "win32") {
2848
+ const invocation = toWin32CmdInvocation(argv);
2849
+ if (invocation === null) return { refused: "Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely." };
2850
+ return {
2851
+ ...invocation,
2852
+ windowsVerbatimArguments: true
2853
+ };
2854
+ }
2855
+ return {
2856
+ file: executable,
2857
+ args: argv.slice(1),
2858
+ windowsVerbatimArguments: false
2859
+ };
2860
+ }
2861
+ /**
2862
+ * Kill functions of every background process still running, so they can
2863
+ * be reaped if the Node process exits without the workspace being disposed
2864
+ * (detached process groups would otherwise outlive it).
2865
+ */
2866
+ const liveBackgroundKills = /* @__PURE__ */ new Set();
2867
+ let exitHookInstalled = false;
2868
+ /** Track a live background tree; installs (once) the exit hook that kills them. */
2869
+ function trackBackgroundProcess(kill) {
2870
+ liveBackgroundKills.add(kill);
2871
+ if (!exitHookInstalled) {
2872
+ exitHookInstalled = true;
2873
+ process.on("exit", () => {
2874
+ for (const killTreeNow of liveBackgroundKills) killTreeNow();
2875
+ });
2876
+ }
2877
+ return () => {
2878
+ liveBackgroundKills.delete(kill);
2879
+ };
2880
+ }
774
2881
  /**
775
2882
  * The real-disk executor: every filesystem method delegates to
776
2883
  * `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a
@@ -827,6 +2934,17 @@ var LocalBackend = class {
827
2934
  return _warlock_js_fs.fs.dirs.list(absDir);
828
2935
  }
829
2936
  /**
2937
+ * Describe a path without following a final symlink (`lstat` — like
2938
+ * `realpath`, a filesystem operation `@warlock.js/fs` does not expose).
2939
+ */
2940
+ async stat(absPath) {
2941
+ const stats = await (0, node_fs_promises.lstat)(absPath);
2942
+ return {
2943
+ type: stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
2944
+ size: stats.size
2945
+ };
2946
+ }
2947
+ /**
830
2948
  * Resolve symlinks and `..` segments to a canonical absolute path — the
831
2949
  * primitive the ops-layer jail uses to detect escapes. Delegates to
832
2950
  * `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`
@@ -836,21 +2954,26 @@ var LocalBackend = class {
836
2954
  return (0, node_fs_promises.realpath)(absPath);
837
2955
  }
838
2956
  /**
839
- * Run a command and capture its outcome. The command line is tokenized
840
- * into an argv (quotes respected, NO shell semantics — see
841
- * `tokenizeCommand`) and spawned **without a shell**, so metacharacters
842
- * like `;`, `&&`, `|`, backticks, and `$()` can never chain extra
843
- * commands past the ops layer's allowlist; a command they appear
844
- * unquoted in is refused with exit code 127. On Windows the argv runs
845
- * through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
2957
+ * Run a command and capture its outcome. In argv mode (the default) the
2958
+ * command line is tokenized into an argv (quotes respected, NO shell
2959
+ * semantics — see `tokenizeCommand`) and spawned **without a shell**, so
2960
+ * metacharacters like `;`, `&&`, `|`, backticks, and `$()` can never
2961
+ * chain extra commands past the ops layer's allowlist; a command they
2962
+ * appear unquoted in is refused with exit code 127. On Windows the argv
2963
+ * runs through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
846
2964
  * cannot be spawned shell-less) with every element individually quoted.
2965
+ * With `interpreter: "sh"` the line runs through `/bin/sh -c` (or
2966
+ * `cmd.exe /d /v:off /s /c` on Windows) — only after the ops layer has
2967
+ * checked every command in it.
847
2968
  * `cwd`, `env`, and the timeout are taken verbatim from the ops layer
848
2969
  * (the environment is NOT merged with `process.env`). On timeout the
849
- * process is SIGKILLed and `timedOut` is set. `stdout`/`stderr` are
850
- * captured and byte-capped per {@link MAX_STREAM_BYTES}.
2970
+ * process tree is SIGKILLed and `timedOut` is set. When `signal` aborts
2971
+ * mid-run the process tree is SIGKILLed the same way and `cancelled` is
2972
+ * set; an already-aborted `signal` resolves `cancelled` without spawning.
2973
+ * `stdout`/`stderr` are captured and byte-capped per {@link MAX_STREAM_BYTES}.
851
2974
  *
852
2975
  * Never rejects for a non-zero exit, a missing executable, a refused
853
- * command line, or a timeout — those are reported through the resolved
2976
+ * command line, a timeout or a cancellation — those are reported through the resolved
854
2977
  * {@link WorkspaceBackendExecResult} so the ops layer can surface them
855
2978
  * as tool-error data.
856
2979
  */
@@ -862,30 +2985,28 @@ var LocalBackend = class {
862
2985
  stderr,
863
2986
  timedOut: false
864
2987
  });
865
- const argv = tokenizeCommand(command);
866
- if (argv === null) {
867
- 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.");
2988
+ const signal = opts.signal;
2989
+ if (signal?.aborted) {
2990
+ resolve({
2991
+ exitCode: 130,
2992
+ stdout: "",
2993
+ stderr: "Command was not executed: the invocation was cancelled before it started.",
2994
+ timedOut: false,
2995
+ cancelled: true
2996
+ });
868
2997
  return;
869
2998
  }
870
- let file = argv[0];
871
- let args = argv.slice(1);
872
- let windowsVerbatimArguments = false;
873
- if (node_process.platform === "win32") {
874
- const invocation = toWin32CmdInvocation(argv);
875
- if (invocation === null) {
876
- refuse("Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely.");
877
- return;
878
- }
879
- file = invocation.file;
880
- args = invocation.args;
881
- windowsVerbatimArguments = true;
2999
+ const plan = planSpawn(command, opts.interpreter ?? "none");
3000
+ if ("refused" in plan) {
3001
+ refuse(plan.refused);
3002
+ return;
882
3003
  }
883
- const child = (0, node_child_process.spawn)(file, args, {
3004
+ const child = (0, node_child_process.spawn)(plan.file, plan.args, {
884
3005
  cwd: opts.cwd,
885
3006
  env: opts.env,
886
3007
  shell: false,
887
3008
  windowsHide: true,
888
- windowsVerbatimArguments,
3009
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
889
3010
  detached: node_process.platform !== "win32"
890
3011
  });
891
3012
  const stdoutChunks = [];
@@ -893,20 +3014,31 @@ var LocalBackend = class {
893
3014
  let stdoutBytes = 0;
894
3015
  let stderrBytes = 0;
895
3016
  let timedOut = false;
3017
+ let cancelled = false;
896
3018
  let settled = false;
3019
+ let exited = false;
897
3020
  const timer = opts.timeoutMs !== void 0 && opts.timeoutMs > 0 ? setTimeout(() => {
3021
+ if (cancelled) return;
898
3022
  timedOut = true;
899
3023
  killTree(child.pid, child);
900
3024
  }, opts.timeoutMs) : void 0;
3025
+ const onAbort = () => {
3026
+ if (settled || timedOut) return;
3027
+ if (!exited) cancelled = true;
3028
+ killTree(child.pid, child);
3029
+ };
3030
+ signal?.addEventListener("abort", onAbort, { once: true });
901
3031
  const settle = (exitCode) => {
902
3032
  if (settled) return;
903
3033
  settled = true;
904
3034
  if (timer !== void 0) clearTimeout(timer);
3035
+ signal?.removeEventListener("abort", onAbort);
905
3036
  resolve({
906
3037
  exitCode,
907
3038
  stdout: Buffer.concat(stdoutChunks).toString("utf8"),
908
3039
  stderr: Buffer.concat(stderrChunks).toString("utf8"),
909
- timedOut
3040
+ timedOut,
3041
+ cancelled
910
3042
  });
911
3043
  };
912
3044
  child.stdout?.on("data", (chunk) => {
@@ -918,6 +3050,9 @@ var LocalBackend = class {
918
3050
  child.on("error", () => {
919
3051
  settle(127);
920
3052
  });
3053
+ child.on("exit", () => {
3054
+ exited = true;
3055
+ });
921
3056
  child.on("close", (code, signal) => {
922
3057
  if (code === null) {
923
3058
  settle(128 + (signal === "SIGKILL" ? 9 : 1));
@@ -927,6 +3062,75 @@ var LocalBackend = class {
927
3062
  });
928
3063
  });
929
3064
  }
3065
+ /**
3066
+ * Start a long-running command and return at once. Spawned like
3067
+ * {@link exec} (same argv/shell planning, own process group on POSIX), with
3068
+ * stdin closed. Output is decoded per stream with a `StringDecoder`, so a
3069
+ * chunk never ends inside a character. `kill()` kills the whole tree; a
3070
+ * still-running tree is also killed when the Node process exits.
3071
+ */
3072
+ start(command, opts) {
3073
+ const plan = planSpawn(command, opts.interpreter ?? "none");
3074
+ if ("refused" in plan) {
3075
+ opts.onOutput("stderr", plan.refused);
3076
+ return {
3077
+ done: Promise.resolve({ exitCode: 127 }),
3078
+ kill: () => {}
3079
+ };
3080
+ }
3081
+ const child = (0, node_child_process.spawn)(plan.file, plan.args, {
3082
+ cwd: opts.cwd,
3083
+ env: opts.env,
3084
+ shell: false,
3085
+ windowsHide: true,
3086
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
3087
+ detached: node_process.platform !== "win32",
3088
+ stdio: [
3089
+ "ignore",
3090
+ "pipe",
3091
+ "pipe"
3092
+ ]
3093
+ });
3094
+ const stdoutDecoder = new node_string_decoder.StringDecoder("utf8");
3095
+ const stderrDecoder = new node_string_decoder.StringDecoder("utf8");
3096
+ let finished = false;
3097
+ const kill = () => {
3098
+ if (!finished) killTree(child.pid, child);
3099
+ };
3100
+ const untrack = trackBackgroundProcess(kill);
3101
+ child.stdout?.on("data", (chunk) => {
3102
+ const text = stdoutDecoder.write(chunk);
3103
+ if (text !== "") opts.onOutput("stdout", text);
3104
+ });
3105
+ child.stderr?.on("data", (chunk) => {
3106
+ const text = stderrDecoder.write(chunk);
3107
+ if (text !== "") opts.onOutput("stderr", text);
3108
+ });
3109
+ const done = new Promise((resolve) => {
3110
+ const finish = (exitCode) => {
3111
+ if (finished) return;
3112
+ finished = true;
3113
+ untrack();
3114
+ const stdoutTail = stdoutDecoder.end();
3115
+ const stderrTail = stderrDecoder.end();
3116
+ if (stdoutTail !== "") opts.onOutput("stdout", stdoutTail);
3117
+ if (stderrTail !== "") opts.onOutput("stderr", stderrTail);
3118
+ resolve({ exitCode });
3119
+ };
3120
+ child.on("error", (error) => {
3121
+ opts.onOutput("stderr", `Command could not be started: ${error.message}`);
3122
+ finish(127);
3123
+ });
3124
+ child.on("close", (code, signal) => {
3125
+ finish(code === null ? 128 + (signal === "SIGKILL" ? 9 : 1) : code);
3126
+ });
3127
+ });
3128
+ return {
3129
+ ...child.pid !== void 0 ? { pid: child.pid } : {},
3130
+ done,
3131
+ kill
3132
+ };
3133
+ }
930
3134
  };
931
3135
  /**
932
3136
  * Create the **local** workspace backend — the default executor that runs the
@@ -948,7 +3152,7 @@ function createLocalBackend() {
948
3152
  }
949
3153
 
950
3154
  //#endregion
951
- //#region ../ai-workspace/src/backends/mock.ts
3155
+ //#region ../../packages/ai-workspace/src/backends/mock.ts
952
3156
  /**
953
3157
  * Normalize an absolute path to a stable in-memory key: forward slashes,
954
3158
  * collapsed duplicate separators, and resolved `.` / `..` segments. There
@@ -1048,13 +3252,55 @@ var MockBackend = class {
1048
3252
  async realpath(absPath) {
1049
3253
  return canonicalize(absPath);
1050
3254
  }
1051
- async exec(command, _opts) {
3255
+ async stat(absPath) {
3256
+ const canonical = canonicalize(absPath);
3257
+ const content = this.files.get(canonical);
3258
+ if (content !== void 0) return {
3259
+ type: "file",
3260
+ size: Buffer.byteLength(content, "utf8")
3261
+ };
3262
+ if (this.directories.has(canonical)) return {
3263
+ type: "directory",
3264
+ size: 0
3265
+ };
3266
+ throw new Error(`Mock backend: no such file or directory: ${canonical}`);
3267
+ }
3268
+ /**
3269
+ * Scripted background start: emits the registered `stdout` / `stderr`
3270
+ * synchronously, then exits with `exitCode` — or, when scripted with
3271
+ * `running: true`, stays running until `kill()` (exit code 137).
3272
+ */
3273
+ start(command, opts) {
3274
+ const scripted = this.commands.get(command);
3275
+ if (scripted?.stdout) opts.onOutput("stdout", scripted.stdout);
3276
+ if (scripted?.stderr) opts.onOutput("stderr", scripted.stderr);
3277
+ if (scripted?.running !== true) return {
3278
+ done: Promise.resolve({ exitCode: scripted?.exitCode ?? 0 }),
3279
+ kill: () => {}
3280
+ };
3281
+ let stop = () => {};
3282
+ return {
3283
+ done: new Promise((resolve) => {
3284
+ stop = resolve;
3285
+ }),
3286
+ kill: () => stop({ exitCode: 137 })
3287
+ };
3288
+ }
3289
+ async exec(command, opts) {
3290
+ if (opts?.signal?.aborted) return {
3291
+ exitCode: 130,
3292
+ stdout: "",
3293
+ stderr: "",
3294
+ timedOut: false,
3295
+ cancelled: true
3296
+ };
1052
3297
  const scripted = this.commands.get(command);
1053
3298
  return {
1054
3299
  exitCode: scripted?.exitCode ?? 0,
1055
3300
  stdout: scripted?.stdout ?? "",
1056
3301
  stderr: scripted?.stderr ?? "",
1057
- timedOut: scripted?.timedOut ?? false
3302
+ timedOut: scripted?.timedOut ?? false,
3303
+ cancelled: scripted?.cancelled ?? false
1058
3304
  };
1059
3305
  }
1060
3306
  };
@@ -1113,7 +3359,7 @@ function isMockBackendSeed(seed) {
1113
3359
  }
1114
3360
 
1115
3361
  //#endregion
1116
- //#region ../ai-workspace/src/tools/schema.ts
3362
+ //#region ../../packages/ai-workspace/src/tools/schema.ts
1117
3363
  /**
1118
3364
  * Tiny, dependency-free [Standard Schema](https://standardschema.dev)
1119
3365
  * builders for the workspace tools' input validation. The package pins
@@ -1130,6 +3376,10 @@ function isMockBackendSeed(seed) {
1130
3376
  */
1131
3377
  /** The vendor tag stamped on every issue these builders produce. */
1132
3378
  const VENDOR = "ai-workspace";
3379
+ /** Attach a JSON Schema description to a hand-rolled Standard Schema. */
3380
+ function withJsonSchema(schema, jsonSchema) {
3381
+ return Object.assign(schema, { jsonSchema });
3382
+ }
1133
3383
  /** Required string field — rejects anything that is not a string. */
1134
3384
  function stringField() {
1135
3385
  return (value, key) => {
@@ -1180,24 +3430,25 @@ function optionalBooleanField() {
1180
3430
  };
1181
3431
  }
1182
3432
  /**
1183
- * Build a {@link StandardSchemaV1} for a flat object whose every property
1184
- * is validated by a {@link FieldValidator}. The input must be a non-null
1185
- * object; each declared field is validated and the (possibly coerced)
1186
- * values are collected into the typed result. All field issues are merged
1187
- * so the caller sees every problem at once.
1188
- *
1189
- * `T` is constrained to `object` rather than `Record<string, unknown>` so
1190
- * the tool IO `interface`s (which carry no implicit string index
1191
- * signature) satisfy it directly — only the declared keys in `shape` are
1192
- * ever read, so a string index signature is never required.
1193
- *
1194
- * @example
1195
- * const schema = objectSchema<{ path: string; limit?: number }>({
1196
- * path: stringField(),
1197
- * limit: optionalNumberField(),
1198
- * });
3433
+ * Optional array-of-strings field — accepts `undefined` or an array whose
3434
+ * every element is a string (at most `maxItems` of them).
1199
3435
  */
1200
- function objectSchema(shape) {
3436
+ function optionalStringArrayField(maxItems = 50) {
3437
+ return (value, key) => {
3438
+ if (value === void 0) return { value: void 0 };
3439
+ if (Array.isArray(value) && value.length <= maxItems && value.every((item) => typeof item === "string")) return { value };
3440
+ return { issues: [{
3441
+ message: `"${key}" must be an array of at most ${maxItems} strings when provided`,
3442
+ path: [key]
3443
+ }] };
3444
+ };
3445
+ }
3446
+ function objectSchema(shape, jsonSchema) {
3447
+ const schema = buildObjectSchema(shape);
3448
+ return jsonSchema === void 0 ? schema : withJsonSchema(schema, jsonSchema);
3449
+ }
3450
+ /** The validator behind {@link objectSchema}. */
3451
+ function buildObjectSchema(shape) {
1201
3452
  return { "~standard": {
1202
3453
  version: 1,
1203
3454
  vendor: VENDOR,
@@ -1222,17 +3473,190 @@ function objectSchema(shape) {
1222
3473
  }
1223
3474
 
1224
3475
  //#endregion
1225
- //#region ../ai-workspace/src/tools/edit-file.ts
3476
+ //#region ../../packages/ai-workspace/src/tools/json-schemas.ts
3477
+ /**
3478
+ * The JSON Schemas the workspace tools publish for their input (read by
3479
+ * `extractJsonSchema` and sent to the provider with each tool), kept short
3480
+ * because they ride along on every model call. They describe what the
3481
+ * hand-rolled validators accept; the validators remain the authority.
3482
+ */
3483
+ const string = (description) => ({
3484
+ type: "string",
3485
+ description
3486
+ });
3487
+ const integer = (description) => ({
3488
+ type: "integer",
3489
+ description
3490
+ });
3491
+ const boolean = (description) => ({
3492
+ type: "boolean",
3493
+ description
3494
+ });
3495
+ /** `read_file`. */
3496
+ const READ_FILE_JSON_SCHEMA = {
3497
+ type: "object",
3498
+ properties: {
3499
+ path: string("Workspace-relative file path."),
3500
+ startLine: integer("1-based first line to return."),
3501
+ limit: integer("Most lines to return.")
3502
+ },
3503
+ required: ["path"]
3504
+ };
3505
+ /** `edit_file`. */
3506
+ const EDIT_FILE_JSON_SCHEMA = {
3507
+ type: "object",
3508
+ properties: {
3509
+ path: string("Workspace-relative file path."),
3510
+ oldString: string("Exact text to replace; must be unique unless replaceAll."),
3511
+ newString: string("Replacement text."),
3512
+ replaceAll: boolean("Replace every occurrence."),
3513
+ expectHash: string("Hash from read_file; rejects the edit if the file changed.")
3514
+ },
3515
+ required: [
3516
+ "path",
3517
+ "oldString",
3518
+ "newString"
3519
+ ]
3520
+ };
3521
+ /** `multi_edit`. */
3522
+ const MULTI_EDIT_JSON_SCHEMA = {
3523
+ type: "object",
3524
+ properties: {
3525
+ path: string("Workspace-relative file path."),
3526
+ edits: {
3527
+ type: "array",
3528
+ description: "Replacements applied in order, each to the result of the previous; all or none.",
3529
+ minItems: 1,
3530
+ maxItems: 100,
3531
+ items: {
3532
+ type: "object",
3533
+ properties: {
3534
+ oldString: string("Exact text to replace; must be unique unless replaceAll."),
3535
+ newString: string("Replacement text."),
3536
+ replaceAll: boolean("Replace every occurrence.")
3537
+ },
3538
+ required: ["oldString", "newString"]
3539
+ }
3540
+ },
3541
+ expectHash: string("Hash from read_file; rejects every edit if the file changed.")
3542
+ },
3543
+ required: ["path", "edits"]
3544
+ };
3545
+ /** `write_file`. */
3546
+ const WRITE_FILE_JSON_SCHEMA = {
3547
+ type: "object",
3548
+ properties: {
3549
+ path: string("Workspace-relative file path."),
3550
+ content: string("Full file content.")
3551
+ },
3552
+ required: ["path", "content"]
3553
+ };
3554
+ /** `list_dir`. */
3555
+ const LIST_DIR_JSON_SCHEMA = {
3556
+ type: "object",
3557
+ properties: {
3558
+ path: string("Workspace-relative directory (default \".\")."),
3559
+ depth: integer("Levels to list; 1 = direct children (default)."),
3560
+ ignore: {
3561
+ type: "array",
3562
+ items: { type: "string" },
3563
+ description: "Extra globs to leave out."
3564
+ }
3565
+ }
3566
+ };
3567
+ /** `run_shell` (the `background` property is only listed when background commands are enabled). */
3568
+ function runShellJsonSchema(background) {
3569
+ return {
3570
+ type: "object",
3571
+ properties: {
3572
+ command: string("The command line to run."),
3573
+ timeoutMs: integer("Wall-clock cap in ms (can only lower the workspace limit)."),
3574
+ ...background ? { background: boolean("Start it in the background and return its id at once (dev servers, watchers).") } : {}
3575
+ },
3576
+ required: ["command"]
3577
+ };
3578
+ }
3579
+ /** `shell_output`. */
3580
+ const SHELL_OUTPUT_JSON_SCHEMA = {
3581
+ type: "object",
3582
+ properties: {
3583
+ id: string("Background command id from run_shell."),
3584
+ since: integer("A cursor from an earlier result; omit for output since the last read."),
3585
+ waitMs: integer("Wait up to this long (max 30000) for new output.")
3586
+ },
3587
+ required: ["id"]
3588
+ };
3589
+ /** `shell_kill`. */
3590
+ const SHELL_KILL_JSON_SCHEMA = {
3591
+ type: "object",
3592
+ properties: { id: string("Background command id from run_shell.") },
3593
+ required: ["id"]
3594
+ };
3595
+ /** `run_tests`. */
3596
+ const RUN_TESTS_JSON_SCHEMA = {
3597
+ type: "object",
3598
+ properties: { pattern: string("Optional test path or name filter.") }
3599
+ };
3600
+ /** `grep`. */
3601
+ const GREP_JSON_SCHEMA = {
3602
+ type: "object",
3603
+ properties: {
3604
+ pattern: string("Regular expression to search for."),
3605
+ glob: string("Only search files matching this glob (e.g. \"src/**/*.ts\")."),
3606
+ ignoreCase: boolean("Case-insensitive match.")
3607
+ },
3608
+ required: ["pattern"]
3609
+ };
3610
+ /** `glob`. */
3611
+ const GLOB_JSON_SCHEMA = {
3612
+ type: "object",
3613
+ properties: { pattern: string("Glob such as \"src/**/*.ts\".") },
3614
+ required: ["pattern"]
3615
+ };
3616
+ /** `todo_write`. */
3617
+ const TODO_WRITE_JSON_SCHEMA = {
3618
+ type: "object",
3619
+ properties: { todos: {
3620
+ type: "array",
3621
+ description: "The complete updated list (replaces the old one).",
3622
+ maxItems: 100,
3623
+ items: {
3624
+ type: "object",
3625
+ properties: {
3626
+ id: string("Stable id; omit to number items by position."),
3627
+ content: string("The step."),
3628
+ status: {
3629
+ type: "string",
3630
+ enum: [
3631
+ "pending",
3632
+ "in_progress",
3633
+ "completed"
3634
+ ]
3635
+ }
3636
+ },
3637
+ required: ["content", "status"]
3638
+ }
3639
+ } },
3640
+ required: ["todos"]
3641
+ };
3642
+ /** `todo_read`. */
3643
+ const TODO_READ_JSON_SCHEMA = {
3644
+ type: "object",
3645
+ properties: {}
3646
+ };
3647
+
3648
+ //#endregion
3649
+ //#region ../../packages/ai-workspace/src/tools/edit-file.ts
1226
3650
  /** Default tool name exposed to the LLM. */
1227
- const DEFAULT_NAME$2 = "edit_file";
3651
+ const DEFAULT_NAME$6 = "edit_file";
1228
3652
  /** Input schema for the `edit_file` tool. */
1229
- const inputSchema$2 = objectSchema({
3653
+ const inputSchema$6 = objectSchema({
1230
3654
  path: stringField(),
1231
3655
  oldString: stringField(),
1232
3656
  newString: stringField(),
1233
3657
  replaceAll: optionalBooleanField(),
1234
3658
  expectHash: optionalStringField()
1235
- });
3659
+ }, EDIT_FILE_JSON_SCHEMA);
1236
3660
  /**
1237
3661
  * Build the agent-facing `edit_file` tool over a workspace's policy-
1238
3662
  * enforced {@link WorkspaceOps}.
@@ -1264,9 +3688,9 @@ const inputSchema$2 = objectSchema({
1264
3688
  */
1265
3689
  function makeEditFileTool(ops, options) {
1266
3690
  return (0, _warlock_js_ai.tool)({
1267
- name: options?.name ?? DEFAULT_NAME$2,
3691
+ name: options?.name ?? DEFAULT_NAME$6,
1268
3692
  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.",
1269
- input: inputSchema$2,
3693
+ input: inputSchema$6,
1270
3694
  async execute(input) {
1271
3695
  return ops.editFile(input);
1272
3696
  }
@@ -1274,14 +3698,14 @@ function makeEditFileTool(ops, options) {
1274
3698
  }
1275
3699
 
1276
3700
  //#endregion
1277
- //#region ../ai-workspace/src/tools/glob.ts
3701
+ //#region ../../packages/ai-workspace/src/tools/glob.ts
1278
3702
  /**
1279
3703
  * Standard Schema for {@link GlobInput} — a single required `pattern`
1280
3704
  * string. Built on the package's shared, dependency-free schema builders
1281
3705
  * (no schema library, matching the validator idiom the `@warlock.js/ai`
1282
3706
  * tool runtime expects).
1283
3707
  */
1284
- const globInputSchema = objectSchema({ pattern: stringField() });
3708
+ const globInputSchema = objectSchema({ pattern: stringField() }, GLOB_JSON_SCHEMA);
1285
3709
  /**
1286
3710
  * Build the agent-facing `glob` tool — resolve a glob pattern to the
1287
3711
  * matching workspace-relative paths within the jail. The returned
@@ -1314,7 +3738,7 @@ function makeGlobTool(ops, options) {
1314
3738
  }
1315
3739
 
1316
3740
  //#endregion
1317
- //#region ../ai-workspace/src/tools/grep.ts
3741
+ //#region ../../packages/ai-workspace/src/tools/grep.ts
1318
3742
  /**
1319
3743
  * Standard Schema for {@link GrepInput} — `pattern` is a required string;
1320
3744
  * `glob` and `ignoreCase` are optional. Built on the package's shared,
@@ -1325,7 +3749,7 @@ const grepInputSchema = objectSchema({
1325
3749
  pattern: stringField(),
1326
3750
  glob: optionalStringField(),
1327
3751
  ignoreCase: optionalBooleanField()
1328
- });
3752
+ }, GREP_JSON_SCHEMA);
1329
3753
  /**
1330
3754
  * Build the agent-facing `grep` tool — a regex content search across the
1331
3755
  * jailed file set. The returned {@link ToolContract} validates the LLM's
@@ -1360,25 +3784,183 @@ function makeGrepTool(ops, options) {
1360
3784
  }
1361
3785
 
1362
3786
  //#endregion
1363
- //#region ../ai-workspace/src/tools/read-file.ts
3787
+ //#region ../../packages/ai-workspace/src/tools/list-dir.ts
3788
+ /** Default tool name exposed to the LLM. */
3789
+ const DEFAULT_NAME$5 = "list_dir";
3790
+ /** Input schema for the `list_dir` tool. */
3791
+ const inputSchema$5 = objectSchema({
3792
+ path: optionalStringField(),
3793
+ depth: optionalNumberField(),
3794
+ ignore: optionalStringArrayField()
3795
+ }, LIST_DIR_JSON_SCHEMA);
3796
+ /**
3797
+ * Build the agent-facing `list_dir` tool over a workspace's policy-enforced
3798
+ * {@link WorkspaceOps}: a bounded, jailed listing of one directory.
3799
+ *
3800
+ * Entries come back breadth-first (each level sorted by name) with their
3801
+ * type and — for files — size, down to `depth` levels (capped by
3802
+ * `list.maxDepth`) and at most `list.maxEntries` entries (`truncated` says
3803
+ * when the cap hit). `denyPaths` are always left out, as are the
3804
+ * `list.ignore` globs (default `node_modules`, `.git`) and any `ignore`
3805
+ * globs the model passes. Symlinks are reported as `"symlink"` and never
3806
+ * followed. A path outside the jail surfaces as a `WorkspacePolicyError` in
3807
+ * the result's `error` field.
3808
+ *
3809
+ * @param ops - The shared, policy-enforced operation layer.
3810
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
3811
+ *
3812
+ * @example
3813
+ * const listDir = makeListDirTool(ops);
3814
+ * const { data } = await listDir.invoke({ path: "src", depth: 2 });
3815
+ * for (const entry of data?.entries ?? []) console.log(entry.type, entry.path, entry.size);
3816
+ */
3817
+ function makeListDirTool(ops, options) {
3818
+ return (0, _warlock_js_ai.tool)({
3819
+ name: options?.name ?? DEFAULT_NAME$5,
3820
+ 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.",
3821
+ action: (input) => `Listing ${typeof input.path === "string" ? input.path : "."}`,
3822
+ input: inputSchema$5,
3823
+ execute: (input) => ops.listDir(input.path ?? ".", {
3824
+ ...input.depth !== void 0 ? { depth: input.depth } : {},
3825
+ ...input.ignore !== void 0 ? { ignore: input.ignore } : {}
3826
+ })
3827
+ });
3828
+ }
3829
+
3830
+ //#endregion
3831
+ //#region ../../packages/ai-workspace/src/tools/multi-edit.ts
3832
+ /** Default tool name exposed to the LLM. */
3833
+ const DEFAULT_NAME$4 = "multi_edit";
3834
+ /** Validate one element of `edits`. */
3835
+ function validateEdit(value, index) {
3836
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{
3837
+ message: `edits[${index}] must be an object`,
3838
+ path: ["edits", index]
3839
+ }] };
3840
+ const candidate = value;
3841
+ const issues = [];
3842
+ if (typeof candidate.oldString !== "string" || candidate.oldString === "") issues.push({
3843
+ message: `edits[${index}].oldString must be a non-empty string`,
3844
+ path: [
3845
+ "edits",
3846
+ index,
3847
+ "oldString"
3848
+ ]
3849
+ });
3850
+ if (typeof candidate.newString !== "string") issues.push({
3851
+ message: `edits[${index}].newString must be a string`,
3852
+ path: [
3853
+ "edits",
3854
+ index,
3855
+ "newString"
3856
+ ]
3857
+ });
3858
+ if (candidate.replaceAll !== void 0 && typeof candidate.replaceAll !== "boolean") issues.push({
3859
+ message: `edits[${index}].replaceAll must be a boolean when provided`,
3860
+ path: [
3861
+ "edits",
3862
+ index,
3863
+ "replaceAll"
3864
+ ]
3865
+ });
3866
+ if (issues.length > 0 || typeof candidate.oldString !== "string" || typeof candidate.newString !== "string") return { issues };
3867
+ return { edit: {
3868
+ oldString: candidate.oldString,
3869
+ newString: candidate.newString,
3870
+ ...candidate.replaceAll === true ? { replaceAll: true } : {}
3871
+ } };
3872
+ }
3873
+ /** Input schema for the `multi_edit` tool. */
3874
+ const inputSchema$4 = withJsonSchema({ "~standard": {
3875
+ version: 1,
3876
+ vendor: "ai-workspace",
3877
+ validate(value) {
3878
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
3879
+ const candidate = value;
3880
+ const issues = [];
3881
+ const edits = [];
3882
+ if (typeof candidate.path !== "string" || candidate.path === "") issues.push({
3883
+ message: "\"path\" must be a non-empty string",
3884
+ path: ["path"]
3885
+ });
3886
+ if (candidate.expectHash !== void 0 && typeof candidate.expectHash !== "string") issues.push({
3887
+ message: "\"expectHash\" must be a string when provided",
3888
+ path: ["expectHash"]
3889
+ });
3890
+ if (!Array.isArray(candidate.edits) || candidate.edits.length === 0 || candidate.edits.length > 100) issues.push({
3891
+ message: `"edits" must be an array of 1 to ${100} edits`,
3892
+ path: ["edits"]
3893
+ });
3894
+ else candidate.edits.forEach((item, index) => {
3895
+ const outcome = validateEdit(item, index);
3896
+ if ("issues" in outcome) issues.push(...outcome.issues);
3897
+ else edits.push(outcome.edit);
3898
+ });
3899
+ if (issues.length > 0 || typeof candidate.path !== "string") return { issues };
3900
+ return { value: {
3901
+ path: candidate.path,
3902
+ edits,
3903
+ ...typeof candidate.expectHash === "string" ? { expectHash: candidate.expectHash } : {}
3904
+ } };
3905
+ }
3906
+ } }, MULTI_EDIT_JSON_SCHEMA);
3907
+ /**
3908
+ * Build the agent-facing `multi_edit` tool over a workspace's policy-
3909
+ * enforced {@link WorkspaceOps}: several exact-string replacements in one
3910
+ * file, **atomic** — every edit applies, in order (each against the result
3911
+ * of the previous ones), and the file is written once; or one edit fails
3912
+ * and nothing is written.
3913
+ *
3914
+ * Each edit follows the `edit_file` rules: `oldString` must be found, and
3915
+ * found exactly once unless `replaceAll`. An optional `expectHash` (from
3916
+ * `read_file`) rejects the whole call when the file changed since it was
3917
+ * read. A failure surfaces as a `WorkspaceEditError` in the result's
3918
+ * `error` field, with `editIndex` naming the failing edit.
3919
+ *
3920
+ * @param ops - The shared, policy-enforced operation layer.
3921
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
3922
+ *
3923
+ * @example
3924
+ * const multiEdit = makeMultiEditTool(ops);
3925
+ * const { data, error } = await multiEdit.invoke({
3926
+ * path: "src/cart.ts",
3927
+ * expectHash,
3928
+ * edits: [
3929
+ * { oldString: "let total = 0;", newString: "let total = 0n;" },
3930
+ * { oldString: "total += price;", newString: "total += BigInt(price);" },
3931
+ * ],
3932
+ * });
3933
+ */
3934
+ function makeMultiEditTool(ops, options) {
3935
+ return (0, _warlock_js_ai.tool)({
3936
+ name: options?.name ?? DEFAULT_NAME$4,
3937
+ 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.",
3938
+ action: (input) => `Editing ${typeof input.path === "string" ? input.path : "a file"}`,
3939
+ input: inputSchema$4,
3940
+ execute: (input) => ops.multiEdit(input)
3941
+ });
3942
+ }
3943
+
3944
+ //#endregion
3945
+ //#region ../../packages/ai-workspace/src/tools/read-file.ts
1364
3946
  /** Default tool name exposed to the LLM. */
1365
- const DEFAULT_NAME$1 = "read_file";
3947
+ const DEFAULT_NAME$3 = "read_file";
1366
3948
  /** Input schema for the `read_file` tool. */
1367
- const inputSchema$1 = objectSchema({
3949
+ const inputSchema$3 = objectSchema({
1368
3950
  path: stringField(),
1369
3951
  startLine: optionalNumberField(),
1370
3952
  limit: optionalNumberField()
1371
- });
3953
+ }, READ_FILE_JSON_SCHEMA);
1372
3954
  /**
1373
3955
  * Build the agent-facing `read_file` tool over a workspace's policy-
1374
3956
  * enforced {@link WorkspaceOps}.
1375
3957
  *
1376
3958
  * The tool validates `{ path, startLine?, limit? }` against a Standard
1377
3959
  * Schema, then delegates to `ops.readFile`, mapping the result into the
1378
- * agent wire shape {@link ReadFileResult} — the `hash` an agent must
1379
- * carry into a later `edit_file` (read-before-edit), plus the `startLine`
1380
- * / `endLine` / `truncated` window metadata derived from the requested
1381
- * range and the file's `totalLines`.
3960
+ * agent wire shape {@link ReadFileResult} — the full file's `hash` an
3961
+ * agent must carry into a later `edit_file` (read-before-edit), plus the
3962
+ * `startLine` / `endLine` / `truncated` / `bytesCapped` window metadata.
3963
+ * The returned `content` never exceeds the policy's `read.maxBytes`.
1382
3964
  *
1383
3965
  * **Errors flow as data.** Policy violations (a jail escape) are thrown
1384
3966
  * by `ops`; the `tool()` wrapper catches them and surfaces them in the
@@ -1395,92 +3977,91 @@ const inputSchema$1 = objectSchema({
1395
3977
  */
1396
3978
  function makeReadFileTool(ops, options) {
1397
3979
  return (0, _warlock_js_ai.tool)({
1398
- name: options?.name ?? DEFAULT_NAME$1,
1399
- 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.",
1400
- input: inputSchema$1,
3980
+ name: options?.name ?? DEFAULT_NAME$3,
3981
+ 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.",
3982
+ input: inputSchema$3,
1401
3983
  async execute(input) {
1402
- const startLine = input.startLine !== void 0 ? Math.max(1, input.startLine) : 1;
1403
- const { content, hash, totalLines } = await ops.readFile(input.path, {
1404
- offset: startLine,
3984
+ return ops.readFile(input.path, {
3985
+ offset: input.startLine !== void 0 ? Math.max(1, input.startLine) : 1,
1405
3986
  limit: input.limit
1406
3987
  });
1407
- const returnedLines = content.length === 0 ? 0 : content.split("\n").length;
1408
- const endLine = Math.min(totalLines, startLine + Math.max(returnedLines, 1) - 1);
1409
- return {
1410
- content,
1411
- startLine,
1412
- endLine,
1413
- totalLines,
1414
- truncated: endLine < totalLines,
1415
- hash
1416
- };
1417
3988
  }
1418
3989
  });
1419
3990
  }
1420
3991
 
1421
3992
  //#endregion
1422
- //#region ../ai-workspace/src/tools/run-shell.ts
3993
+ //#region ../../packages/ai-workspace/src/tools/run-shell.ts
1423
3994
  /** The default tool name `run_shell` is exposed to the LLM under. */
1424
3995
  const DEFAULT_RUN_SHELL_TOOL_NAME = "run_shell";
1425
3996
  /**
1426
3997
  * Hand-rolled Standard Schema for {@link RunShellInput}. We validate the
1427
3998
  * model's arguments without a runtime schema dependency: `command` must be
1428
- * a non-empty string, and `timeoutMs` (when present) a positive number.
1429
- * Invalid args surface as a `SchemaValidationError` in the tool result's
1430
- * `error` field rather than reaching `ops.exec`.
1431
- */
1432
- const runShellInputSchema = { "~standard": {
1433
- version: 1,
1434
- vendor: "@warlock.js/ai-workspace",
1435
- validate: (value) => {
1436
- if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
1437
- const candidate = value;
1438
- if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
1439
- message: "command must be a non-empty string",
1440
- path: ["command"]
1441
- }] };
1442
- if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
1443
- message: "timeoutMs must be a positive number",
1444
- path: ["timeoutMs"]
1445
- }] };
1446
- const result = { command: candidate.command };
1447
- if (candidate.timeoutMs !== void 0) result.timeoutMs = candidate.timeoutMs;
1448
- return { value: result };
1449
- }
1450
- } };
1451
- /**
1452
- * Build the `run_shell` tool — a {@link ToolContract} that runs a single
1453
- * shell command through the policy-enforced {@link WorkspaceOps} layer.
1454
- *
1455
- * The command's leading executable basename is gated against the shell
1456
- * allow/deny policy by `ops.exec`; a blocked command throws a
1457
- * `WorkspacePolicyError` which the `tool()` runtime catches and surfaces
1458
- * in the result's `error` field (never a thrown run-killer), so the agent
1459
- * reads the refusal as tool data and self-corrects. A command that runs
1460
- * but exits non-zero is *not* an error — its `exitCode`/`stderr` come back
1461
- * in `data` for the agent to inspect.
1462
- *
1463
- * @param ops - The policy-enforced operation layer to delegate `exec` to.
1464
- * @param options - Optional tool-name override.
1465
- *
1466
- * @example
1467
- * const runShell = makeRunShellTool(ops);
1468
- * const { data, error } = await runShell.invoke({ command: "npm run build" });
1469
- * if (error) handleDenied(error);
1470
- * else console.log(data.exitCode, data.stdout);
3999
+ * a non-empty string, `timeoutMs` (when present) a positive number and
4000
+ * `background` (when present) a boolean. Invalid args surface as a
4001
+ * `SchemaValidationError` in the tool result's `error` field rather than
4002
+ * reaching `ops.exec`.
1471
4003
  */
4004
+ function runShellInputSchema(background) {
4005
+ return withJsonSchema({ "~standard": {
4006
+ version: 1,
4007
+ vendor: "@warlock.js/ai-workspace",
4008
+ validate: (value) => {
4009
+ if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
4010
+ const candidate = value;
4011
+ if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
4012
+ message: "command must be a non-empty string",
4013
+ path: ["command"]
4014
+ }] };
4015
+ if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
4016
+ message: "timeoutMs must be a positive number",
4017
+ path: ["timeoutMs"]
4018
+ }] };
4019
+ if (candidate.background !== void 0 && typeof candidate.background !== "boolean") return { issues: [{
4020
+ message: "background must be a boolean",
4021
+ path: ["background"]
4022
+ }] };
4023
+ const result = { command: candidate.command };
4024
+ if (typeof candidate.timeoutMs === "number") result.timeoutMs = candidate.timeoutMs;
4025
+ if (candidate.background === true) result.background = true;
4026
+ return { value: result };
4027
+ }
4028
+ } }, runShellJsonSchema(background));
4029
+ }
4030
+ /** The lower of the model's timeout and the cap (either may be absent). */
4031
+ function cappedTimeout(requested, cap) {
4032
+ if (requested === void 0) return cap;
4033
+ return cap === void 0 ? requested : Math.min(requested, cap);
4034
+ }
1472
4035
  function makeRunShellTool(ops, options) {
4036
+ const background = options?.background === true;
1473
4037
  return (0, _warlock_js_ai.tool)({
1474
4038
  name: options?.name ?? DEFAULT_RUN_SHELL_TOOL_NAME,
1475
- 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.",
4039
+ 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." : ""),
1476
4040
  action: (input) => `Running \`${input.command}\``,
1477
- input: runShellInputSchema,
1478
- execute: (input) => ops.exec(input.command, { timeoutMs: input.timeoutMs })
4041
+ input: runShellInputSchema(background),
4042
+ execute: async (input, ctx) => {
4043
+ if (input.background === true) {
4044
+ if (!background) throw new WorkspacePolicyError("Background commands are not enabled for this workspace; run the command without background.", {
4045
+ type: "denied-command",
4046
+ reason: "not-allowed",
4047
+ command: input.command
4048
+ });
4049
+ return {
4050
+ ...await ops.startShell(input.command, input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : void 0),
4051
+ background: true
4052
+ };
4053
+ }
4054
+ const timeoutMs = cappedTimeout(input.timeoutMs, options?.maxTimeoutMs);
4055
+ return ops.exec(input.command, {
4056
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
4057
+ ...ctx?.signal ? { signal: ctx.signal } : {}
4058
+ });
4059
+ }
1479
4060
  });
1480
4061
  }
1481
4062
 
1482
4063
  //#endregion
1483
- //#region ../ai-workspace/src/tools/run-tests.ts
4064
+ //#region ../../packages/ai-workspace/src/tools/run-tests.ts
1484
4065
  /** The default tool name `run_tests` is exposed to the LLM under. */
1485
4066
  const DEFAULT_RUN_TESTS_TOOL_NAME = "run_tests";
1486
4067
  /** The default command run when no `command` override is configured. */
@@ -1494,7 +4075,7 @@ const DEFAULT_TEST_COMMAND = "npm test";
1494
4075
  * happens without a runtime schema dependency, mirroring the wider tool
1495
4076
  * layer.
1496
4077
  */
1497
- const runTestsInputSchema = { "~standard": {
4078
+ const runTestsInputSchema = withJsonSchema({ "~standard": {
1498
4079
  version: 1,
1499
4080
  vendor: "@warlock.js/ai-workspace",
1500
4081
  validate: (value) => {
@@ -1513,7 +4094,12 @@ const runTestsInputSchema = { "~standard": {
1513
4094
  if (candidate.pattern !== void 0) result.pattern = candidate.pattern;
1514
4095
  return { value: result };
1515
4096
  }
1516
- } };
4097
+ } }, RUN_TESTS_JSON_SCHEMA);
4098
+ /** Quote the test pattern as one literal argument for the line's interpreter. */
4099
+ function quotePattern(pattern, interpreter) {
4100
+ if (interpreter === "sh" && process.platform !== "win32") return `'${pattern.replace(/'/g, "'\\''")}'`;
4101
+ return `"${pattern}"`;
4102
+ }
1517
4103
  /**
1518
4104
  * Build the `run_tests` tool — a {@link ToolContract} convenience over
1519
4105
  * `run_shell` that runs the workspace's configured test command through
@@ -1527,7 +4113,8 @@ const runTestsInputSchema = { "~standard": {
1527
4113
  * data, never a second command. Like `run_shell`, the resolved command's
1528
4114
  * executable is gated by the shell policy — a denial surfaces in the
1529
4115
  * result's `error` field — and a non-zero exit (failing tests) comes back
1530
- * as `data` for the agent to read and fix.
4116
+ * as `data` for the agent to read and fix. The agent run's cancellation
4117
+ * `signal` is threaded per invocation, exactly as for `run_shell`.
1531
4118
  *
1532
4119
  * @param ops - The policy-enforced operation layer to delegate `exec` to.
1533
4120
  * @param options - Optional tool-name and base-command overrides.
@@ -1544,22 +4131,285 @@ function makeRunTestsTool(ops, options) {
1544
4131
  description: "Run the workspace's test suite, optionally narrowed to a path or name pattern forwarded to the test runner. Failing tests return a non-zero exit code as data, not an error.",
1545
4132
  action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
1546
4133
  input: runTestsInputSchema,
1547
- execute: (input) => {
1548
- const command = input.pattern ? `${baseCommand} "${input.pattern}"` : baseCommand;
1549
- return ops.exec(command);
4134
+ execute: (input, ctx) => {
4135
+ const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none")}` : baseCommand;
4136
+ return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
1550
4137
  }
1551
4138
  });
1552
4139
  }
1553
4140
 
1554
4141
  //#endregion
1555
- //#region ../ai-workspace/src/tools/write-file.ts
4142
+ //#region ../../packages/ai-workspace/src/tools/shell-kill.ts
4143
+ /** Default tool name exposed to the LLM. */
4144
+ const DEFAULT_NAME$2 = "shell_kill";
4145
+ /** Input schema for the `shell_kill` tool. */
4146
+ const inputSchema$2 = objectSchema({ id: stringField() }, SHELL_KILL_JSON_SCHEMA);
4147
+ /**
4148
+ * Build the agent-facing `shell_kill` tool: stop a background command
4149
+ * started with `run_shell({ background: true })`, killing its whole process
4150
+ * tree, and return its final state. Killing a command that already stopped
4151
+ * just reports its state. An unknown id surfaces as a `WorkspaceJobError`
4152
+ * in the result's `error` field.
4153
+ *
4154
+ * @param ops - The shared, policy-enforced operation layer.
4155
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4156
+ *
4157
+ * @example
4158
+ * const { data } = await makeShellKillTool(ops).invoke({ id: "job-1" });
4159
+ * console.log(data?.status); // "killed"
4160
+ */
4161
+ function makeShellKillTool(ops, options) {
4162
+ return (0, _warlock_js_ai.tool)({
4163
+ name: options?.name ?? DEFAULT_NAME$2,
4164
+ description: "Stop a background command started with run_shell (background: true), killing it and everything it started. Returns its final state.",
4165
+ action: (input) => `Stopping ${typeof input.id === "string" ? input.id : "a background command"}`,
4166
+ input: inputSchema$2,
4167
+ execute: (input) => ops.shellKill(input.id)
4168
+ });
4169
+ }
4170
+
4171
+ //#endregion
4172
+ //#region ../../packages/ai-workspace/src/tools/shell-output.ts
4173
+ /** Default tool name exposed to the LLM. */
4174
+ const DEFAULT_NAME$1 = "shell_output";
4175
+ /** Input schema for the `shell_output` tool. */
4176
+ const inputSchema$1 = objectSchema({
4177
+ id: stringField(),
4178
+ since: optionalNumberField(),
4179
+ waitMs: optionalNumberField()
4180
+ }, SHELL_OUTPUT_JSON_SCHEMA);
4181
+ /**
4182
+ * Build the agent-facing `shell_output` tool: the new output of a
4183
+ * background command started with `run_shell({ background: true })`, plus
4184
+ * its state (`running` / `exited` / `killed` / `timed-out`, `exitCode`).
4185
+ *
4186
+ * Without `since` it returns everything produced since the previous read;
4187
+ * `cursor` in the result is the position to pass as `since` next time.
4188
+ * `waitMs` (at most 30 s) waits for new output or for the command to stop
4189
+ * when there is nothing new yet — the agent run's signal ends the wait
4190
+ * early. Only the newest `shell.maxOutputBytes` of a command's output are
4191
+ * kept; `droppedBytes` says how much the reader missed.
4192
+ *
4193
+ * @param ops - The shared, policy-enforced operation layer.
4194
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4195
+ *
4196
+ * @example
4197
+ * const { data } = await makeShellOutputTool(ops).invoke({ id: "job-1", waitMs: 2_000 });
4198
+ * if (data?.stdout.includes("ready")) console.log("dev server is up");
4199
+ */
4200
+ function makeShellOutputTool(ops, options) {
4201
+ return (0, _warlock_js_ai.tool)({
4202
+ name: options?.name ?? DEFAULT_NAME$1,
4203
+ 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.",
4204
+ action: (input) => `Reading output of ${typeof input.id === "string" ? input.id : "a background command"}`,
4205
+ input: inputSchema$1,
4206
+ execute: (input, ctx) => ops.shellOutput(input.id, {
4207
+ ...input.since !== void 0 ? { since: input.since } : {},
4208
+ ...input.waitMs !== void 0 ? { waitMs: input.waitMs } : {},
4209
+ ...ctx?.signal ? { signal: ctx.signal } : {}
4210
+ })
4211
+ });
4212
+ }
4213
+
4214
+ //#endregion
4215
+ //#region ../../packages/ai-workspace/src/todos.ts
4216
+ /** Most items a todo list may hold. */
4217
+ const MAX_TODOS = 100;
4218
+ /** Longest `content` one todo item may have. */
4219
+ const MAX_TODO_CONTENT_LENGTH = 1e3;
4220
+ /** The statuses a todo item may have, in display order. */
4221
+ const TODO_STATUSES = [
4222
+ "pending",
4223
+ "in_progress",
4224
+ "completed"
4225
+ ];
4226
+ /**
4227
+ * Validate a whole replacement list. Returns the problems found (empty when
4228
+ * the list is valid) so the tool schema and the direct method share one
4229
+ * set of rules.
4230
+ */
4231
+ function todoListProblems(todos) {
4232
+ const problems = [];
4233
+ const ids = /* @__PURE__ */ new Set();
4234
+ if (todos.length > 100) problems.push(`a todo list holds at most ${100} items (got ${todos.length})`);
4235
+ todos.forEach((todo, index) => {
4236
+ if (todo.content.trim() === "") problems.push(`todos[${index}].content must not be empty`);
4237
+ if (todo.content.length > 1e3) problems.push(`todos[${index}].content is longer than ${MAX_TODO_CONTENT_LENGTH} characters`);
4238
+ if (!TODO_STATUSES.includes(todo.status)) problems.push(`todos[${index}].status must be one of ${TODO_STATUSES.join(", ")}`);
4239
+ const id = todo.id ?? String(index + 1);
4240
+ if (ids.has(id)) problems.push(`todos[${index}].id "${id}" is used more than once`);
4241
+ ids.add(id);
4242
+ });
4243
+ return problems;
4244
+ }
4245
+ /**
4246
+ * The in-memory todo list of one workspace (shared by its projections).
4247
+ * `write` replaces the whole list, the way an agent's plan tool usually
4248
+ * works: the model sends the full updated list every time.
4249
+ */
4250
+ var TodoStore = class {
4251
+ constructor() {
4252
+ this.items = [];
4253
+ }
4254
+ /** The current list and a count per status. */
4255
+ read() {
4256
+ const counts = {
4257
+ pending: 0,
4258
+ in_progress: 0,
4259
+ completed: 0
4260
+ };
4261
+ for (const item of this.items) counts[item.status]++;
4262
+ return {
4263
+ todos: this.items.map((item) => ({ ...item })),
4264
+ counts
4265
+ };
4266
+ }
4267
+ /**
4268
+ * Replace the list. Items without an `id` get their 1-based position as
4269
+ * one. Throws a `RangeError` naming every problem when the list is invalid
4270
+ * (too long, empty content, unknown status, duplicate id); the old list is
4271
+ * kept in that case.
4272
+ */
4273
+ write(todos) {
4274
+ const problems = todoListProblems(todos);
4275
+ if (problems.length > 0) throw new RangeError(`Invalid todo list: ${problems.join("; ")}.`);
4276
+ this.items = todos.map((todo, index) => ({
4277
+ id: todo.id ?? String(index + 1),
4278
+ content: todo.content.trim(),
4279
+ status: todo.status
4280
+ }));
4281
+ return this.read();
4282
+ }
4283
+ };
4284
+
4285
+ //#endregion
4286
+ //#region ../../packages/ai-workspace/src/tools/todo.ts
4287
+ /** Whether `value` is a todo status. */
4288
+ function isTodoStatus(value) {
4289
+ return typeof value === "string" && TODO_STATUSES.includes(value);
4290
+ }
4291
+ /** Input schema for the `todo_write` tool. */
4292
+ const todoWriteSchema = withJsonSchema({ "~standard": {
4293
+ version: 1,
4294
+ vendor: "ai-workspace",
4295
+ validate(value) {
4296
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
4297
+ const raw = value.todos;
4298
+ if (!Array.isArray(raw) || raw.length > 100) return { issues: [{
4299
+ message: `"todos" must be an array of at most ${100} items`,
4300
+ path: ["todos"]
4301
+ }] };
4302
+ const issues = [];
4303
+ const todos = [];
4304
+ raw.forEach((item, index) => {
4305
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
4306
+ issues.push({
4307
+ message: `todos[${index}] must be an object`,
4308
+ path: ["todos", index]
4309
+ });
4310
+ return;
4311
+ }
4312
+ const candidate = item;
4313
+ if (typeof candidate.content !== "string") issues.push({
4314
+ message: `todos[${index}].content must be a string`,
4315
+ path: [
4316
+ "todos",
4317
+ index,
4318
+ "content"
4319
+ ]
4320
+ });
4321
+ if (!isTodoStatus(candidate.status)) issues.push({
4322
+ message: `todos[${index}].status must be one of ${TODO_STATUSES.join(", ")}`,
4323
+ path: [
4324
+ "todos",
4325
+ index,
4326
+ "status"
4327
+ ]
4328
+ });
4329
+ if (candidate.id !== void 0 && typeof candidate.id !== "string") issues.push({
4330
+ message: `todos[${index}].id must be a string when provided`,
4331
+ path: [
4332
+ "todos",
4333
+ index,
4334
+ "id"
4335
+ ]
4336
+ });
4337
+ if (typeof candidate.content === "string" && isTodoStatus(candidate.status)) todos.push({
4338
+ content: candidate.content,
4339
+ status: candidate.status,
4340
+ ...typeof candidate.id === "string" ? { id: candidate.id } : {}
4341
+ });
4342
+ });
4343
+ if (issues.length === 0) for (const problem of todoListProblems(todos)) issues.push({
4344
+ message: problem,
4345
+ path: ["todos"]
4346
+ });
4347
+ return issues.length > 0 ? { issues } : { value: { todos } };
4348
+ }
4349
+ } }, TODO_WRITE_JSON_SCHEMA);
4350
+ /** Input schema for the `todo_read` tool (no arguments). */
4351
+ const todoReadSchema = withJsonSchema({ "~standard": {
4352
+ version: 1,
4353
+ vendor: "ai-workspace",
4354
+ validate: () => ({ value: {} })
4355
+ } }, TODO_READ_JSON_SCHEMA);
4356
+ /**
4357
+ * Build the agent-facing `todo_write` tool over a workspace's todo list:
4358
+ * the model sends the **complete** updated list (each item `content` +
4359
+ * `status` of `pending` / `in_progress` / `completed`, optional `id`) and it
4360
+ * replaces the old one. The list lives in memory, per workspace (shared by
4361
+ * its `readonly()` / `scope()` projections), for the length of the run.
4362
+ *
4363
+ * @param store - The workspace's todo list.
4364
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4365
+ *
4366
+ * @example
4367
+ * await makeTodoWriteTool(store).invoke({
4368
+ * todos: [
4369
+ * { content: "Reproduce the failing test", status: "completed" },
4370
+ * { content: "Fix the rounding bug", status: "in_progress" },
4371
+ * ],
4372
+ * });
4373
+ */
4374
+ function makeTodoWriteTool(store, options) {
4375
+ return (0, _warlock_js_ai.tool)({
4376
+ name: options?.name ?? "todo_write",
4377
+ 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.",
4378
+ action: "Updating the todo list",
4379
+ input: todoWriteSchema,
4380
+ execute: async (input) => store.write(input.todos)
4381
+ });
4382
+ }
4383
+ /**
4384
+ * Build the agent-facing `todo_read` tool: the workspace's current todo
4385
+ * list and a count per status.
4386
+ *
4387
+ * @param store - The workspace's todo list.
4388
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4389
+ *
4390
+ * @example
4391
+ * const { data } = await makeTodoReadTool(store).invoke({});
4392
+ * console.log(data?.counts.pending);
4393
+ */
4394
+ function makeTodoReadTool(store, options) {
4395
+ return (0, _warlock_js_ai.tool)({
4396
+ name: options?.name ?? "todo_read",
4397
+ description: "Read your current todo list for this task.",
4398
+ action: "Reading the todo list",
4399
+ input: todoReadSchema,
4400
+ execute: async () => store.read()
4401
+ });
4402
+ }
4403
+
4404
+ //#endregion
4405
+ //#region ../../packages/ai-workspace/src/tools/write-file.ts
1556
4406
  /** Default tool name exposed to the LLM. */
1557
4407
  const DEFAULT_NAME = "write_file";
1558
4408
  /** Input schema for the `write_file` tool. */
1559
4409
  const inputSchema = objectSchema({
1560
4410
  path: stringField(),
1561
4411
  content: stringField()
1562
- });
4412
+ }, WRITE_FILE_JSON_SCHEMA);
1563
4413
  /**
1564
4414
  * Build the agent-facing `write_file` tool over a workspace's policy-
1565
4415
  * enforced {@link WorkspaceOps}.
@@ -1599,7 +4449,7 @@ function makeWriteFileTool(ops, options) {
1599
4449
  }
1600
4450
 
1601
4451
  //#endregion
1602
- //#region ../ai-workspace/src/workspace.ts
4452
+ //#region ../../packages/ai-workspace/src/workspace.ts
1603
4453
  /**
1604
4454
  * The full set of tool names a writable workspace vends, in a stable
1605
4455
  * canonical order so `tools.all()` is deterministic.
@@ -1607,23 +4457,35 @@ function makeWriteFileTool(ops, options) {
1607
4457
  const ALL_TOOL_NAMES = [
1608
4458
  "readFile",
1609
4459
  "editFile",
4460
+ "multiEdit",
1610
4461
  "writeFile",
4462
+ "listDir",
1611
4463
  "runShell",
4464
+ "shellOutput",
4465
+ "shellKill",
1612
4466
  "runTests",
1613
4467
  "grep",
1614
- "glob"
4468
+ "glob",
4469
+ "todoWrite",
4470
+ "todoRead"
1615
4471
  ];
1616
4472
  /**
1617
4473
  * The subset a {@link Workspace.readonly} projection exposes — the
1618
- * non-mutating tools only. `editFile` / `writeFile` / `runShell` /
1619
- * `runTests` are deliberately omitted so a reviewer agent has no path to
1620
- * change the tree.
4474
+ * tools that cannot change the tree. `editFile` / `multiEdit` /
4475
+ * `writeFile` / the shell tools / `runTests` are deliberately omitted so a
4476
+ * reviewer agent has no path to change it. The todo tools stay: they only
4477
+ * touch the in-memory plan.
1621
4478
  */
1622
4479
  const READONLY_TOOL_NAMES = [
1623
4480
  "readFile",
4481
+ "listDir",
1624
4482
  "grep",
1625
- "glob"
4483
+ "glob",
4484
+ "todoWrite",
4485
+ "todoRead"
1626
4486
  ];
4487
+ /** Tools vended only when the policy enables background commands. */
4488
+ const BACKGROUND_TOOL_NAMES = new Set(["shellOutput", "shellKill"]);
1627
4489
  /**
1628
4490
  * Choose the dumb IO executor for a policy. `"mock"` selects the
1629
4491
  * in-memory backend (hermetic tests); anything else — including the
@@ -1648,11 +4510,15 @@ function selectBackend(policy) {
1648
4510
  * Constructed via {@link workspace}; the class itself is internal.
1649
4511
  */
1650
4512
  var WorkspaceImpl = class WorkspaceImpl {
1651
- constructor(policy, allowedTools = ALL_TOOL_NAMES) {
4513
+ constructor(policy, allowedTools = ALL_TOOL_NAMES, state) {
1652
4514
  this.policy = policy;
1653
4515
  this.allowedTools = new Set(allowedTools);
4516
+ this.state = state ?? {
4517
+ jobs: new ShellJobs(),
4518
+ todos: new TodoStore()
4519
+ };
1654
4520
  const backend = selectBackend(policy);
1655
- this.ops = createOps(backend, policy);
4521
+ this.ops = createOps(backend, policy, { jobs: this.state.jobs });
1656
4522
  this.tools = this.buildTools();
1657
4523
  }
1658
4524
  /**
@@ -1664,26 +4530,49 @@ var WorkspaceImpl = class WorkspaceImpl {
1664
4530
  */
1665
4531
  buildTools() {
1666
4532
  const erase = (contract) => contract;
4533
+ const shell = this.policy.shell;
4534
+ const background = shell?.background !== void 0;
4535
+ const interpreter = shell?.interpreter ?? "none";
1667
4536
  const factories = {
1668
4537
  readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
1669
4538
  editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
4539
+ multiEdit: (opts) => erase(makeMultiEditTool(this.ops, opts)),
1670
4540
  writeFile: (opts) => erase(makeWriteFileTool(this.ops, opts)),
1671
- runShell: (opts) => erase(makeRunShellTool(this.ops, opts)),
1672
- runTests: (opts) => erase(makeRunTestsTool(this.ops, opts)),
4541
+ listDir: (opts) => erase(makeListDirTool(this.ops, opts)),
4542
+ runShell: (opts) => erase(makeRunShellTool(this.ops, {
4543
+ ...opts?.name !== void 0 ? { name: opts.name } : {},
4544
+ ...shell?.timeoutMs !== void 0 ? { maxTimeoutMs: shell.timeoutMs } : {},
4545
+ background
4546
+ })),
4547
+ shellOutput: (opts) => erase(makeShellOutputTool(this.ops, opts)),
4548
+ shellKill: (opts) => erase(makeShellKillTool(this.ops, opts)),
4549
+ runTests: (opts) => erase(makeRunTestsTool(this.ops, {
4550
+ ...opts,
4551
+ interpreter
4552
+ })),
1673
4553
  grep: (opts) => erase(makeGrepTool(this.ops, opts)),
1674
- glob: (opts) => erase(makeGlobTool(this.ops, opts))
4554
+ glob: (opts) => erase(makeGlobTool(this.ops, opts)),
4555
+ todoWrite: (opts) => erase(makeTodoWriteTool(this.state.todos, opts)),
4556
+ todoRead: (opts) => erase(makeTodoReadTool(this.state.todos, opts))
1675
4557
  };
1676
4558
  const build = (name, opts) => factories[name](opts);
4559
+ const vendedByAll = (name) => this.allowedTools.has(name) && (background || !BACKGROUND_TOOL_NAMES.has(name));
1677
4560
  return {
1678
- all: () => ALL_TOOL_NAMES.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
4561
+ all: () => ALL_TOOL_NAMES.filter(vendedByAll).map((name) => build(name)),
1679
4562
  pick: (...names) => names.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
1680
4563
  readFile: (opts) => build("readFile", opts),
1681
4564
  editFile: (opts) => build("editFile", opts),
4565
+ multiEdit: (opts) => build("multiEdit", opts),
1682
4566
  writeFile: (opts) => build("writeFile", opts),
4567
+ listDir: (opts) => build("listDir", opts),
1683
4568
  runShell: (opts) => build("runShell", opts),
4569
+ shellOutput: (opts) => build("shellOutput", opts),
4570
+ shellKill: (opts) => build("shellKill", opts),
1684
4571
  runTests: (opts) => build("runTests", opts),
1685
4572
  grep: (opts) => build("grep", opts),
1686
- glob: (opts) => build("glob", opts)
4573
+ glob: (opts) => build("glob", opts),
4574
+ todoWrite: (opts) => build("todoWrite", opts),
4575
+ todoRead: (opts) => build("todoRead", opts)
1687
4576
  };
1688
4577
  }
1689
4578
  /**
@@ -1709,10 +4598,40 @@ var WorkspaceImpl = class WorkspaceImpl {
1709
4598
  this.assertWritable("editFile");
1710
4599
  return this.ops.editFile(input);
1711
4600
  }
4601
+ async multiEdit(input) {
4602
+ this.assertWritable("multiEdit");
4603
+ return this.ops.multiEdit(input);
4604
+ }
4605
+ listDir(dirPath, opts) {
4606
+ return this.ops.listDir(dirPath, opts);
4607
+ }
1712
4608
  async exec(command, opts) {
1713
4609
  this.assertWritable("exec");
1714
4610
  return this.ops.exec(command, opts);
1715
4611
  }
4612
+ async startShell(command, opts) {
4613
+ this.assertWritable("startShell");
4614
+ return this.ops.startShell(command, opts);
4615
+ }
4616
+ shellOutput(id, opts) {
4617
+ return this.ops.shellOutput(id, opts);
4618
+ }
4619
+ async shellKill(id) {
4620
+ this.assertWritable("shellKill");
4621
+ return this.ops.shellKill(id);
4622
+ }
4623
+ listShells() {
4624
+ return this.ops.listShells();
4625
+ }
4626
+ readTodos() {
4627
+ return this.state.todos.read();
4628
+ }
4629
+ writeTodos(todos) {
4630
+ return this.state.todos.write(todos);
4631
+ }
4632
+ dispose() {
4633
+ return this.ops.dispose();
4634
+ }
1716
4635
  grep(pattern, opts) {
1717
4636
  return this.ops.grep(pattern, opts);
1718
4637
  }
@@ -1731,44 +4650,49 @@ var WorkspaceImpl = class WorkspaceImpl {
1731
4650
  return this.ops.remove(filePath);
1732
4651
  }
1733
4652
  /**
1734
- * A read-only projection over the SAME policy — only the read/grep/glob
1735
- * tools are vended and every mutating direct method rejects with a
1736
- * {@link WorkspacePolicyError}. A fresh ops/backend is built from the
1737
- * identical policy, so the projection sees the same jailed tree.
4653
+ * A read-only projection over the SAME policy — only the read / list /
4654
+ * grep / glob / todo tools are vended and every mutating direct method
4655
+ * rejects with a {@link WorkspacePolicyError}. A fresh ops/backend is
4656
+ * built from the identical policy, so the projection sees the same jailed
4657
+ * tree; it shares this workspace's background commands and todo list.
1738
4658
  */
1739
4659
  readonly() {
1740
- return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES);
4660
+ return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES.filter((name) => this.allowedTools.has(name)), this.state);
1741
4661
  }
1742
4662
  /**
1743
4663
  * A sub-jailed view rooted at `subdir` (relative to this workspace's
1744
4664
  * `cwd`). Returns a brand-new workspace whose policy is this policy
1745
4665
  * with `cwd` narrowed to `join(cwd, subdir)` — same backend selection,
1746
- * same allow/deny/shell/read sub-policies, but a tighter jail root.
4666
+ * same allow/deny/shell/read sub-policies, but a tighter jail root. It
4667
+ * shares this workspace's background commands and todo list.
1747
4668
  */
1748
4669
  scope(subdir) {
1749
4670
  return new WorkspaceImpl({
1750
4671
  ...this.policy,
1751
4672
  cwd: node_path.default.join(this.policy.cwd, subdir)
1752
- }, [...this.allowedTools]);
4673
+ }, [...this.allowedTools], this.state);
1753
4674
  }
1754
4675
  };
1755
4676
  /**
1756
4677
  * Build a {@link Workspace} — the integrator that wires a
1757
4678
  * {@link WorkspacePolicy} to a backend, the shared policy-enforced ops
1758
- * layer, and the seven agent-facing tool factories.
4679
+ * layer, and the agent-facing tool factories.
1759
4680
  *
1760
4681
  * The backend is chosen from `policy.backend`: `"mock"` runs in memory
1761
4682
  * (hermetic tests); the `"local"` default (and any absent value) runs
1762
4683
  * over the real disk via `@warlock.js/fs` + `node:child_process`. The
1763
4684
  * returned workspace exposes:
1764
4685
  *
1765
- * - **`tools.*`** — `readFile` / `editFile` / `writeFile` / `runShell` /
1766
- * `runTests` / `grep` / `glob`, plus `all()` (every tool) and
1767
- * `pick(...)` (a least-privilege subset).
1768
- * - **direct methods** — `readFile` / `writeFile` / `editFile` / `exec` /
1769
- * `grep` / `glob` / `exists` / `mkdir` / `remove`, each delegating 1:1
1770
- * to the shared ops layer.
1771
- * - **`readonly()`** — a projection that vends only read/grep/glob and
4686
+ * - **`tools.*`** — `readFile` / `editFile` / `multiEdit` / `writeFile` /
4687
+ * `listDir` / `runShell` / `shellOutput` / `shellKill` / `runTests` /
4688
+ * `grep` / `glob` / `todoWrite` / `todoRead`, plus `all()` (every tool)
4689
+ * and `pick(...)` (a least-privilege subset).
4690
+ * - **direct methods** — `readFile` / `writeFile` / `editFile` /
4691
+ * `multiEdit` / `listDir` / `exec` / `startShell` / `shellOutput` /
4692
+ * `shellKill` / `listShells` / `grep` / `glob` / `exists` / `mkdir` /
4693
+ * `remove` / `readTodos` / `writeTodos`, each delegating to the shared
4694
+ * ops layer, and `dispose()`.
4695
+ * - **`readonly()`** — a projection that vends only the read-side tools and
1772
4696
  * rejects every mutating direct method.
1773
4697
  * - **`scope(subdir)`** — a sub-jailed workspace rooted at `subdir`.
1774
4698
  *
@@ -1793,9 +4717,13 @@ function workspace(policy) {
1793
4717
  _warlock_js_ai.ai.workspace = workspace;
1794
4718
 
1795
4719
  //#endregion
4720
+ exports.TodoStore = TodoStore;
1796
4721
  exports.WorkspaceEditError = WorkspaceEditError;
4722
+ exports.WorkspaceJobError = WorkspaceJobError;
1797
4723
  exports.WorkspacePolicyError = WorkspacePolicyError;
4724
+ exports.analyzeArgvCommand = analyzeArgvCommand;
1798
4725
  exports.buildEnv = buildEnv;
4726
+ exports.checkShellCommand = checkShellCommand;
1799
4727
  exports.createLocalBackend = createLocalBackend;
1800
4728
  exports.createMockBackend = createMockBackend;
1801
4729
  exports.createOps = createOps;
@@ -1803,10 +4731,17 @@ exports.isCommandAllowed = isCommandAllowed;
1803
4731
  exports.makeEditFileTool = makeEditFileTool;
1804
4732
  exports.makeGlobTool = makeGlobTool;
1805
4733
  exports.makeGrepTool = makeGrepTool;
4734
+ exports.makeListDirTool = makeListDirTool;
4735
+ exports.makeMultiEditTool = makeMultiEditTool;
1806
4736
  exports.makeReadFileTool = makeReadFileTool;
1807
4737
  exports.makeRunShellTool = makeRunShellTool;
1808
4738
  exports.makeRunTestsTool = makeRunTestsTool;
4739
+ exports.makeShellKillTool = makeShellKillTool;
4740
+ exports.makeShellOutputTool = makeShellOutputTool;
4741
+ exports.makeTodoReadTool = makeTodoReadTool;
4742
+ exports.makeTodoWriteTool = makeTodoWriteTool;
1809
4743
  exports.makeWriteFileTool = makeWriteFileTool;
1810
4744
  exports.resolveInJail = resolveInJail;
4745
+ exports.tokenizeCommand = tokenizeCommand;
1811
4746
  exports.workspace = workspace;
1812
4747
  //# sourceMappingURL=index.cjs.map