@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/esm/ops.mjs CHANGED
@@ -1,15 +1,32 @@
1
1
  import { WorkspaceEditError, WorkspacePolicyError } from "./errors.mjs";
2
- import { buildEnv, isCommandAllowed, resolveInJail } from "./policy/policy.mjs";
2
+ import { globToRegExp, isInside, matchesAnyGlob } from "./policy/jail.mjs";
3
+ import { checkShellCommand } from "./policy/shell-check.mjs";
4
+ import { buildCommandEnv, resolveInJail } from "./policy/policy.mjs";
5
+ import { ShellJobs } from "./shell-jobs.mjs";
3
6
  import path from "node:path";
4
7
  import { fs } from "@warlock.js/fs";
5
8
 
6
- //#region ../ai-workspace/src/ops.ts
9
+ //#region ../../packages/ai-workspace/src/ops.ts
7
10
  /** Default line window a read returns when the policy sets no `defaultLines`. */
8
11
  const DEFAULT_READ_LINES = 2e3;
9
12
  /** Hard ceiling on grep matches returned, so a broad pattern can't flood. */
10
13
  const DEFAULT_MAX_GREP_MATCHES = 1e3;
11
14
  /** Default per-command output byte cap when the policy sets none. */
12
15
  const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
16
+ /** Default `list.ignore` when the policy sets none. */
17
+ const DEFAULT_LIST_IGNORE = ["node_modules", ".git"];
18
+ /** Default deepest `depth` a listing may ask for. */
19
+ const DEFAULT_LIST_MAX_DEPTH = 5;
20
+ /** Default most entries one listing returns. */
21
+ const DEFAULT_LIST_MAX_ENTRIES = 500;
22
+ /** Most edits one `multiEdit` call may carry. */
23
+ const MAX_MULTI_EDITS = 100;
24
+ /**
25
+ * Exit code reported for a command refused because its signal was already
26
+ * aborted (the shell convention for an interrupted command). Callers should
27
+ * branch on `cancelled`, not on this number.
28
+ */
29
+ const CANCELLED_BEFORE_START_EXIT_CODE = 130;
13
30
  /**
14
31
  * Hard ceiling on `grep` pattern length. A model-controlled regex has no
15
32
  * legitimate reason to be this long; longer patterns are rejected outright
@@ -24,57 +41,74 @@ const MAX_GREP_PATTERN_LENGTH = 200;
24
41
  * this are skipped rather than scanned.
25
42
  */
26
43
  const MAX_GREP_LINE_SCAN_LENGTH = 2e3;
27
- /**
28
- * Number the lines of `content` `cat -n` style: a right-aligned line
29
- * number (min width 6), a tab, then the line. `startLine` is the 1-based
30
- * number of the first line in the window.
31
- */
32
- function numberLines(content, startLine) {
33
- return content.split("\n").map((line, index) => {
34
- const lineNumber = startLine + index;
35
- return `${String(lineNumber).padStart(6, " ")}\t${line}`;
36
- }).join("\n");
44
+ /** The `cat -n` prefix for a line: right-aligned number (min width 6) and a tab. */
45
+ function linePrefix(lineNumber) {
46
+ return `${String(lineNumber).padStart(6, " ")}\t`;
37
47
  }
38
48
  /**
39
- * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes).
40
- * Returns the possibly-clipped string plus whether clipping occurred.
49
+ * Clip `value` to at most `maxBytes` UTF-8 bytes without splitting a
50
+ * character: the cut backs off over continuation bytes (`10xxxxxx`) to the
51
+ * start of the character it would have split, so the result never carries
52
+ * a replacement character.
41
53
  */
42
- function capOutput(value, maxBytes) {
54
+ function clipUtf8(value, maxBytes) {
43
55
  const bytes = Buffer.from(value, "utf8");
44
56
  if (bytes.byteLength <= maxBytes) return {
45
57
  value,
46
58
  truncated: false
47
59
  };
60
+ let cut = Math.max(0, maxBytes);
61
+ while (cut > 0 && ((bytes[cut] ?? 0) & 192) === 128) cut--;
48
62
  return {
49
- value: bytes.subarray(0, maxBytes).toString("utf8"),
63
+ value: bytes.subarray(0, cut).toString("utf8"),
50
64
  truncated: true
51
65
  };
52
66
  }
53
67
  /**
54
- * Minimal glob match over a `/`-separated relative path. Supports `**`
55
- * (spans separators), `*` (within a segment), and `?` (one non-separator
56
- * char) — enough for the workspace's `glob`/grep narrowing without
57
- * pulling in a runtime dependency.
68
+ * Number a window of lines `cat -n` style, keeping the joined output within
69
+ * `maxBytes` UTF-8 bytes (prefixes and newlines included) when a cap is set.
70
+ * A line that does not fit whole is clipped on a character boundary; it is
71
+ * still counted as returned when its prefix and at least one character fit.
58
72
  */
59
- function globToRegExp(glob) {
60
- let source = "";
61
- for (let index = 0; index < glob.length; index++) {
62
- const char = glob[index];
63
- if (char === "*") {
64
- if (glob[index + 1] === "*") {
65
- source += ".*";
66
- index++;
67
- if (glob[index + 1] === "/") index++;
68
- } else source += "[^/]*";
73
+ function numberWindow(lines, startLine, maxBytes) {
74
+ const parts = [];
75
+ let used = 0;
76
+ for (let index = 0; index < lines.length; index++) {
77
+ const prefix = linePrefix(startLine + index);
78
+ const numbered = prefix + lines[index];
79
+ const separator = parts.length > 0 ? 1 : 0;
80
+ if (maxBytes === void 0) {
81
+ parts.push(numbered);
69
82
  continue;
70
83
  }
71
- if (char === "?") {
72
- source += "[^/]";
84
+ const cost = separator + Buffer.byteLength(numbered, "utf8");
85
+ if (used + cost <= maxBytes) {
86
+ parts.push(numbered);
87
+ used += cost;
73
88
  continue;
74
89
  }
75
- source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
90
+ const room = maxBytes - used - separator - Buffer.byteLength(prefix, "utf8");
91
+ const clipped = room > 0 ? clipUtf8(lines[index] ?? "", room).value : "";
92
+ if (clipped.length > 0) parts.push(prefix + clipped);
93
+ return {
94
+ content: parts.join("\n"),
95
+ returnedLines: parts.length,
96
+ bytesCapped: true
97
+ };
76
98
  }
77
- return new RegExp(`^${source}$`);
99
+ return {
100
+ content: parts.join("\n"),
101
+ returnedLines: parts.length,
102
+ bytesCapped: false
103
+ };
104
+ }
105
+ /**
106
+ * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes), on a
107
+ * character boundary. Returns the possibly-clipped string plus whether
108
+ * clipping occurred.
109
+ */
110
+ function capOutput(value, maxBytes) {
111
+ return clipUtf8(value, maxBytes);
78
112
  }
79
113
  /**
80
114
  * A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.
@@ -98,17 +132,29 @@ function isSafeGrepPattern(pattern) {
98
132
  if (pattern.length > MAX_GREP_PATTERN_LENGTH) return false;
99
133
  return !NESTED_QUANTIFIER_PATTERN.test(pattern);
100
134
  }
101
- /**
102
- * The internal, single-instance implementation of {@link WorkspaceOps}.
103
- * Holds the backend + policy and is the one place the jail, command
104
- * gating, read caps, and the read-before-edit guard are enforced — both
105
- * the agent-facing tools and the human-facing direct methods funnel
106
- * through this object, so there is exactly one set of rules.
107
- */
108
135
  var Ops = class {
109
- constructor(backend, policy) {
136
+ constructor(backend, policy, runtime) {
110
137
  this.backend = backend;
111
138
  this.policy = policy;
139
+ this.runtime = runtime;
140
+ this.pathLocks = /* @__PURE__ */ new Map();
141
+ }
142
+ /** Run `task` after every earlier mutation of `absolutePath` has settled. */
143
+ async withPathLock(absolutePath, task) {
144
+ const previous = this.pathLocks.get(absolutePath) ?? Promise.resolve();
145
+ let release = () => {};
146
+ const current = new Promise((resolve) => {
147
+ release = resolve;
148
+ });
149
+ const tail = previous.then(() => current);
150
+ this.pathLocks.set(absolutePath, tail);
151
+ try {
152
+ await previous;
153
+ return await task();
154
+ } finally {
155
+ release();
156
+ if (this.pathLocks.get(absolutePath) === tail) this.pathLocks.delete(absolutePath);
157
+ }
112
158
  }
113
159
  /**
114
160
  * Recursively collect every file under `absDir` as absolute paths,
@@ -143,25 +189,39 @@ var Ops = class {
143
189
  const totalLines = lines.length;
144
190
  const offset = Math.max(1, opts?.offset ?? 1);
145
191
  const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
192
+ const maxBytes = this.policy.read?.maxBytes;
146
193
  const startIndex = offset - 1;
194
+ 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)));
195
+ const endLine = offset + returnedLines - 1;
147
196
  return {
148
- content: numberLines(lines.slice(startIndex, startIndex + limit).join("\n"), offset),
149
- hash,
150
- totalLines
197
+ content,
198
+ startLine: offset,
199
+ endLine,
200
+ totalLines,
201
+ truncated: bytesCapped || endLine < totalLines,
202
+ bytesCapped,
203
+ totalBytes: Buffer.byteLength(raw, "utf8"),
204
+ hash
151
205
  };
152
206
  }
153
207
  async writeFile(inputPath, content) {
154
208
  const { absolutePath } = await resolveInJail(this.policy, inputPath);
155
- const parent = path.dirname(absolutePath);
156
- await this.backend.mkdir(parent);
157
- await this.backend.writeFile(absolutePath, content);
158
- return {
159
- hash: fs.hash.string(content),
160
- bytesWritten: Buffer.byteLength(content, "utf8")
161
- };
209
+ return this.withPathLock(absolutePath, async () => {
210
+ const parent = path.dirname(absolutePath);
211
+ await this.backend.mkdir(parent);
212
+ await this.backend.writeFile(absolutePath, content);
213
+ return {
214
+ hash: fs.hash.string(content),
215
+ bytesWritten: Buffer.byteLength(content, "utf8")
216
+ };
217
+ });
162
218
  }
163
219
  async editFile(input) {
164
220
  const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
221
+ return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
222
+ }
223
+ /** The read-check-replace-write of {@link editFile}, run under its path lock. */
224
+ async applyEdit(input, absolutePath, relativePath) {
165
225
  const current = await this.backend.readFile(absolutePath);
166
226
  const currentHash = fs.hash.string(current);
167
227
  if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; the edit is stale.`, {
@@ -170,48 +230,222 @@ var Ops = class {
170
230
  expectedHash: input.expectHash,
171
231
  actualHash: currentHash
172
232
  });
173
- const occurrences = countOccurrences(current, input.oldString);
174
- if (occurrences === 0) throw new WorkspaceEditError(`The text to replace was not found in "${input.path}".`, {
175
- type: "not-found",
176
- path: relativePath || input.path,
177
- matches: 0
178
- });
179
- 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.`, {
180
- type: "not-unique",
181
- path: relativePath || input.path,
182
- matches: occurrences
183
- });
184
- const updated = input.replaceAll ? current.split(input.oldString).join(input.newString) : replaceFirst(current, input.oldString, input.newString);
233
+ const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
185
234
  await this.backend.writeFile(absolutePath, updated);
186
235
  return {
187
236
  path: relativePath || input.path,
188
- replacements: input.replaceAll ? occurrences : 1,
237
+ replacements,
189
238
  hash: fs.hash.string(updated)
190
239
  };
191
240
  }
192
- async exec(command, opts) {
193
- if (!isCommandAllowed(this.policy, command)) throw new WorkspacePolicyError(`Command is not permitted by the workspace shell policy: ${command}`, {
241
+ async multiEdit(input) {
242
+ const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
243
+ const shownPath = relativePath || input.path;
244
+ 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}).`);
245
+ return this.withPathLock(absolutePath, async () => {
246
+ const current = await this.backend.readFile(absolutePath);
247
+ const currentHash = fs.hash.string(current);
248
+ 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.`, {
249
+ type: "stale-hash",
250
+ path: shownPath,
251
+ expectedHash: input.expectHash,
252
+ actualHash: currentHash
253
+ });
254
+ let content = current;
255
+ let replacements = 0;
256
+ input.edits.forEach((edit, editIndex) => {
257
+ const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
258
+ content = applied.content;
259
+ replacements += applied.replacements;
260
+ });
261
+ await this.backend.writeFile(absolutePath, content);
262
+ return {
263
+ path: shownPath,
264
+ edits: input.edits.length,
265
+ replacements,
266
+ hash: fs.hash.string(content)
267
+ };
268
+ });
269
+ }
270
+ /**
271
+ * Check a command line against the shell policy: every command in it
272
+ * (argv or shell mode), then — shell mode — every redirection target and
273
+ * `cd` directory against the jail with symlinks resolved.
274
+ */
275
+ async assertCommandAllowed(command) {
276
+ const verdict = checkShellCommand(this.policy, command);
277
+ if (!verdict.allowed) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
194
278
  type: "denied-command",
279
+ command,
280
+ ...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
281
+ ...verdict.executable !== void 0 ? { executable: verdict.executable } : {},
282
+ ...verdict.path !== void 0 ? { path: verdict.path } : {}
283
+ });
284
+ for (const target of verdict.paths) try {
285
+ await resolveInJail(this.policy, target.path);
286
+ } catch (error) {
287
+ if (!(error instanceof WorkspacePolicyError)) throw error;
288
+ 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.`, {
289
+ type: "denied-command",
290
+ reason: "path-escape",
291
+ command,
292
+ path: target.target
293
+ });
294
+ }
295
+ if (this.runtime.jobs.isDisposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot run new commands.", {
296
+ type: "denied-command",
297
+ reason: "not-allowed",
195
298
  command
196
299
  });
300
+ }
301
+ async exec(command, opts) {
302
+ await this.assertCommandAllowed(command);
303
+ const signal = opts?.signal;
304
+ if (signal?.aborted) return {
305
+ exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
306
+ stdout: "",
307
+ stderr: "Command was not executed: the invocation was cancelled before it started.",
308
+ truncated: false,
309
+ timedOut: false,
310
+ cancelled: true
311
+ };
197
312
  const shell = this.policy.shell;
198
313
  const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
199
314
  const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
315
+ const interpreter = shell?.interpreter ?? "none";
200
316
  const result = await this.backend.exec(command, {
201
317
  cwd: this.policy.cwd,
202
318
  timeoutMs,
203
- env: buildEnv(this.policy)
319
+ env: buildCommandEnv(this.policy),
320
+ ...signal ? { signal } : {},
321
+ ...interpreter !== "none" ? { interpreter } : {}
204
322
  });
205
323
  const stdout = capOutput(result.stdout, maxOutputBytes);
206
324
  const stderr = capOutput(result.stderr, maxOutputBytes);
325
+ const cancelled = result.cancelled === true;
207
326
  return {
208
327
  exitCode: result.exitCode,
209
328
  stdout: stdout.value,
210
329
  stderr: stderr.value,
211
330
  truncated: stdout.truncated || stderr.truncated,
212
- timedOut: result.timedOut
331
+ timedOut: result.timedOut && !cancelled,
332
+ cancelled
213
333
  };
214
334
  }
335
+ async startShell(command, opts) {
336
+ const background = this.policy.shell?.background;
337
+ 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.", {
338
+ type: "denied-command",
339
+ reason: "not-allowed",
340
+ command
341
+ });
342
+ await this.assertCommandAllowed(command);
343
+ const start = this.backend.start?.bind(this.backend);
344
+ if (start === void 0) throw new WorkspacePolicyError("This workspace backend cannot run background commands.", {
345
+ type: "denied-command",
346
+ reason: "not-allowed",
347
+ command
348
+ });
349
+ const shell = this.policy.shell;
350
+ const interpreter = shell?.interpreter ?? "none";
351
+ const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
352
+ const env = buildCommandEnv(this.policy);
353
+ return this.runtime.jobs.start({
354
+ command,
355
+ retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
356
+ maxJobs: background.maxJobs ?? 4,
357
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
358
+ launch: (onOutput) => start(command, {
359
+ cwd: this.policy.cwd,
360
+ env,
361
+ ...interpreter !== "none" ? { interpreter } : {},
362
+ onOutput
363
+ })
364
+ });
365
+ }
366
+ shellOutput(id, opts) {
367
+ return this.runtime.jobs.output(id, opts);
368
+ }
369
+ shellKill(id) {
370
+ return this.runtime.jobs.kill(id);
371
+ }
372
+ listShells() {
373
+ return this.runtime.jobs.list();
374
+ }
375
+ dispose() {
376
+ return this.runtime.jobs.dispose();
377
+ }
378
+ async listDir(inputPath = ".", opts) {
379
+ const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
380
+ const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
381
+ const listPolicy = this.policy.list;
382
+ const maxDepth = Math.max(1, Math.floor(listPolicy?.maxDepth ?? DEFAULT_LIST_MAX_DEPTH));
383
+ const depth = Math.min(Math.max(1, Math.floor(opts?.depth ?? 1)), maxDepth);
384
+ const maxEntries = Math.max(1, Math.floor(listPolicy?.maxEntries ?? DEFAULT_LIST_MAX_ENTRIES));
385
+ const ignore = [...listPolicy?.ignore ?? DEFAULT_LIST_IGNORE, ...opts?.ignore ?? []];
386
+ const insideCwd = isInside(root, jailRoot);
387
+ if (!await this.isDirectory(root)) throw new Error(`"${inputPath}" is not a directory in the workspace.`);
388
+ const entries = [];
389
+ let level = [root];
390
+ let truncated = false;
391
+ for (let currentDepth = 1; currentDepth <= depth && level.length > 0; currentDepth++) {
392
+ const nextLevel = [];
393
+ for (const directory of level) {
394
+ let children;
395
+ try {
396
+ children = await this.backend.list(directory);
397
+ } catch {
398
+ continue;
399
+ }
400
+ children.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
401
+ for (const child of children) {
402
+ const relativeToJail = insideCwd ? path.relative(jailRoot, child).split(path.sep).join("/") : child;
403
+ if (insideCwd && (this.isDenied(relativeToJail) || matchesAnyGlob(relativeToJail, ignore))) continue;
404
+ const relativeToRoot = path.relative(root, child).split(path.sep).join("/");
405
+ if (!insideCwd && matchesAnyGlob(relativeToRoot, ignore)) continue;
406
+ if (entries.length >= maxEntries) {
407
+ truncated = true;
408
+ break;
409
+ }
410
+ const described = await this.describe(child);
411
+ entries.push({
412
+ path: relativeToJail,
413
+ type: described.type,
414
+ ...described.size !== void 0 ? { size: described.size } : {},
415
+ depth: currentDepth
416
+ });
417
+ if (described.type === "directory") nextLevel.push(child);
418
+ }
419
+ if (truncated) break;
420
+ }
421
+ if (truncated) break;
422
+ level = nextLevel;
423
+ }
424
+ return {
425
+ path: insideCwd ? rootRelative || "." : root,
426
+ entries,
427
+ truncated
428
+ };
429
+ }
430
+ /** Kind and size of a listed entry; symlinks are reported, never followed. */
431
+ async describe(absPath) {
432
+ if (this.backend.stat) try {
433
+ const stats = await this.backend.stat(absPath);
434
+ return stats.type === "file" ? {
435
+ type: "file",
436
+ size: stats.size
437
+ } : { type: stats.type };
438
+ } catch {
439
+ return { type: "other" };
440
+ }
441
+ if (await this.isDirectory(absPath)) try {
442
+ await resolveInJail(this.policy, absPath);
443
+ return { type: "directory" };
444
+ } catch {
445
+ return { type: "other" };
446
+ }
447
+ return { type: "file" };
448
+ }
215
449
  async grep(pattern, opts) {
216
450
  if (!isSafeGrepPattern(pattern)) throw new WorkspacePolicyError(`Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`, {
217
451
  type: "unsafe-pattern",
@@ -235,7 +469,7 @@ var Ops = class {
235
469
  }
236
470
  const lines = content.split("\n");
237
471
  for (let index = 0; index < lines.length; index++) {
238
- const line = lines[index];
472
+ const line = lines[index] ?? "";
239
473
  if (line.length > MAX_GREP_LINE_SCAN_LENGTH) continue;
240
474
  if (regex.test(line)) {
241
475
  matches.push({
@@ -277,22 +511,45 @@ var Ops = class {
277
511
  }
278
512
  async remove(inputPath) {
279
513
  const { absolutePath } = await resolveInJail(this.policy, inputPath);
280
- await this.backend.remove(absolutePath);
514
+ await this.withPathLock(absolutePath, () => this.backend.remove(absolutePath));
281
515
  }
282
516
  /** Whether a `/`-separated relative path hits the policy deny list. */
283
517
  isDenied(relativePath) {
284
518
  const denyPaths = this.policy.denyPaths;
285
- if (!denyPaths || denyPaths.length === 0) return false;
286
- return denyPaths.some((glob) => {
287
- if (globToRegExp(glob).test(relativePath)) return true;
288
- if (!glob.includes("*") && !glob.includes("?")) {
289
- const prefix = glob.endsWith("/") ? glob : `${glob}/`;
290
- return relativePath.startsWith(prefix);
291
- }
292
- return false;
293
- });
519
+ return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
294
520
  }
295
521
  };
522
+ /** The lower of two optional limits (either may be absent). */
523
+ function lowerOf(policyLimit, requested) {
524
+ if (requested === void 0 || requested <= 0) return policyLimit;
525
+ return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
526
+ }
527
+ /**
528
+ * Apply one exact-string replacement to `content` under the edit rules:
529
+ * `oldString` must occur, and exactly once unless `replaceAll`.
530
+ */
531
+ function applyReplacement(content, edit, inputPath, shownPath, editIndex) {
532
+ const occurrences = countOccurrences(content, edit.oldString);
533
+ const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
534
+ const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
535
+ const indexOption = editIndex === void 0 ? {} : { editIndex };
536
+ if (occurrences === 0) throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".${nothingApplied}`, {
537
+ type: "not-found",
538
+ path: shownPath,
539
+ matches: 0,
540
+ ...indexOption
541
+ });
542
+ 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}`, {
543
+ type: "not-unique",
544
+ path: shownPath,
545
+ matches: occurrences,
546
+ ...indexOption
547
+ });
548
+ return {
549
+ content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
550
+ replacements: edit.replaceAll ? occurrences : 1
551
+ };
552
+ }
296
553
  /** Count non-overlapping occurrences of `needle` in `haystack`. */
297
554
  function countOccurrences(haystack, needle) {
298
555
  if (needle === "") return 0;
@@ -324,13 +581,15 @@ function replaceFirst(haystack, needle, replacement) {
324
581
  *
325
582
  * @param backend - The dumb IO executor (local disk or in-memory mock).
326
583
  * @param policy - The policy that bounds every operation.
584
+ * @param runtime - State shared with other views of the same workspace
585
+ * (its background commands); a fresh one when omitted.
327
586
  *
328
587
  * @example
329
588
  * const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
330
589
  * const { content, hash } = await ops.readFile("src/index.ts");
331
590
  */
332
- function createOps(backend, policy) {
333
- return new Ops(backend, policy);
591
+ function createOps(backend, policy, runtime) {
592
+ return new Ops(backend, policy, runtime ?? { jobs: new ShellJobs() });
334
593
  }
335
594
 
336
595
  //#endregion