@warlock.js/ai-workspace 5.28.0 → 5.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +6 -0
  3. package/cjs/index.cjs +201 -73
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts +1 -1
  6. package/esm/backends/local.d.mts.map +1 -1
  7. package/esm/backends/local.mjs +38 -8
  8. package/esm/backends/local.mjs.map +1 -1
  9. package/esm/backends/mock.d.mts +1 -1
  10. package/esm/backends/mock.d.mts.map +1 -1
  11. package/esm/backends/mock.mjs +11 -3
  12. package/esm/backends/mock.mjs.map +1 -1
  13. package/esm/contracts/index.d.mts +1 -1
  14. package/esm/contracts/tool-io.type.d.mts +47 -8
  15. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  16. package/esm/contracts/workspace-backend.contract.d.mts +17 -1
  17. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  18. package/esm/contracts/workspace-ops.contract.d.mts +10 -14
  19. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  20. package/esm/contracts/workspace-policy.type.d.mts +9 -2
  21. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  22. package/esm/contracts/workspace.contract.d.mts +13 -14
  23. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  24. package/esm/errors.d.mts +1 -1
  25. package/esm/errors.d.mts.map +1 -1
  26. package/esm/errors.mjs +1 -1
  27. package/esm/errors.mjs.map +1 -1
  28. package/esm/index.d.mts +2 -2
  29. package/esm/ops.d.mts +1 -1
  30. package/esm/ops.d.mts.map +1 -1
  31. package/esm/ops.mjs +121 -28
  32. package/esm/ops.mjs.map +1 -1
  33. package/esm/policy/policy.d.mts +1 -1
  34. package/esm/policy/policy.d.mts.map +1 -1
  35. package/esm/policy/policy.mjs +1 -1
  36. package/esm/policy/policy.mjs.map +1 -1
  37. package/esm/policy/tokenize-command.mjs +1 -1
  38. package/esm/policy/tokenize-command.mjs.map +1 -1
  39. package/esm/tools/edit-file.d.mts +1 -1
  40. package/esm/tools/edit-file.d.mts.map +1 -1
  41. package/esm/tools/edit-file.mjs +1 -1
  42. package/esm/tools/edit-file.mjs.map +1 -1
  43. package/esm/tools/glob.d.mts +1 -1
  44. package/esm/tools/glob.d.mts.map +1 -1
  45. package/esm/tools/glob.mjs +1 -1
  46. package/esm/tools/glob.mjs.map +1 -1
  47. package/esm/tools/grep.d.mts +1 -1
  48. package/esm/tools/grep.d.mts.map +1 -1
  49. package/esm/tools/grep.mjs +1 -1
  50. package/esm/tools/grep.mjs.map +1 -1
  51. package/esm/tools/read-file.d.mts +5 -5
  52. package/esm/tools/read-file.d.mts.map +1 -1
  53. package/esm/tools/read-file.mjs +8 -19
  54. package/esm/tools/read-file.mjs.map +1 -1
  55. package/esm/tools/run-shell.d.mts +5 -1
  56. package/esm/tools/run-shell.d.mts.map +1 -1
  57. package/esm/tools/run-shell.mjs +9 -2
  58. package/esm/tools/run-shell.mjs.map +1 -1
  59. package/esm/tools/run-tests.d.mts +3 -2
  60. package/esm/tools/run-tests.d.mts.map +1 -1
  61. package/esm/tools/run-tests.mjs +5 -4
  62. package/esm/tools/run-tests.mjs.map +1 -1
  63. package/esm/tools/schema.mjs +1 -1
  64. package/esm/tools/schema.mjs.map +1 -1
  65. package/esm/tools/write-file.d.mts +1 -1
  66. package/esm/tools/write-file.d.mts.map +1 -1
  67. package/esm/tools/write-file.mjs +1 -1
  68. package/esm/tools/write-file.mjs.map +1 -1
  69. package/esm/workspace.d.mts +1 -1
  70. package/esm/workspace.d.mts.map +1 -1
  71. package/esm/workspace.mjs +1 -1
  72. package/esm/workspace.mjs.map +1 -1
  73. package/llms-full.txt +48 -12
  74. package/llms.txt +2 -2
  75. package/package.json +5 -4
  76. package/skills/README.md +3 -3
  77. package/skills/build-loop-agent/SKILL.md +4 -4
  78. package/skills/use-a-workspace/SKILL.md +44 -8
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to `@warlock.js/ai-workspace` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.30.0 - 2026-10-16
8
+
9
+ ### Upgrading
10
+
11
+ - `RunShellResult.cancelled` is now a required field: a custom `WorkspaceOps`/`Workspace` implementation or a test double that builds a `RunShellResult` must set it (`false` unless the command was cancelled).
12
+ - `Workspace.readFile()` and `WorkspaceOps.readFile()` now return `ReadFileResult` (adds `startLine`, `endLine`, `truncated`, `bytesCapped`, `totalBytes`); custom implementations must return those fields.
13
+ - A read window that starts past the end of the file now returns empty `content` instead of one numbered empty line.
14
+ - `read.maxBytes` now actually caps the returned `content`; a read that used to return a whole long file under a small cap now comes back cut, with `bytesCapped: true`.
15
+
16
+ ### Added
17
+
18
+ - `exec()` takes a per-call `signal` (`ExecOptions`); `run_shell` and `run_tests` pass the agent run's `ctx.signal`. An already-aborted signal never starts the command; an abort mid-run kills the process tree. Both resolve with the new `cancelled: true` on `RunShellResult`, never together with `timedOut` (WR-010).
19
+ - `WorkspaceBackendExecOptions.signal` and `WorkspaceBackendExecResult.cancelled?` for custom backends; the local backend removes its abort listener when the command settles, and the mock backend honours a pre-aborted signal.
20
+ - Read results carry `startLine`, `endLine`, `truncated`, `bytesCapped` and `totalBytes`; `readFile()` and the `read_file` tool now return the same `ReadFileResult` for the same arguments.
21
+
22
+ ### Fixed
23
+
24
+ - `read.maxBytes` is enforced: the returned `content` (line-number prefixes and newlines included) never exceeds it, so a single long line no longer bypasses the cap. Lines are cut on a character boundary, never mid-character; `hash` remains the SHA-256 of the whole file (WR-009).
25
+ - Command output clipped at `shell.maxOutputBytes` is cut on a character boundary instead of ending in a replacement character.
26
+ - A read window past the end of the file returns empty `content` instead of one numbered empty line.
27
+ - Writes, edits and removes of the same path are serialized, so two concurrent `edit_file` calls (the agent now dispatches a trip's tool calls concurrently) can no longer both pass the stale-hash guard and silently drop one edit; the second re-reads and is rejected as stale, or applies on top when it pinned no hash.
28
+ - Aborting a command after it has already exited (while its stdio is still draining) no longer reports it as `cancelled`; the tree is still reaped so the result settles.
29
+ - On Windows, a `taskkill` that cannot be started no longer crashes the process with an unhandled `'error'` event; the direct child is killed instead.
30
+
31
+ ## 5.29.0 - 2026-10-01
32
+
33
+ ### Changed
34
+
35
+ - Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after `agent-kit` copies them into an app.
36
+
7
37
  ## 5.28.0 - 2026-10-01
8
38
 
9
39
  ### Changed
package/README.md CHANGED
@@ -133,6 +133,12 @@ for (const file of await ws.glob("src/models/**/*.ts")) {
133
133
  - **Read-before-edit guard** — `read_file` returns a SHA-256 `hash`; `edit_file`
134
134
  requires an exact, unique `oldString` (or `replaceAll`) and rejects on an
135
135
  `expectHash` mismatch.
136
+ - **Bounded reads** — `read.maxBytes` caps the UTF-8 bytes a read returns (line
137
+ numbers included), cutting on a character boundary and flagging `bytesCapped`;
138
+ the `hash` still covers the whole file.
139
+ - **Cancellable commands** — `exec(cmd, { signal })`, and the agent run's signal
140
+ in `run_shell` / `run_tests`, stop the command (process tree killed, or never
141
+ started when already aborted) and report `cancelled: true`.
136
142
 
137
143
  **Deferred to later phases** (intentionally absent here): the transactional
138
144
  lifecycle (`snapshot` / `restore` / `diff` / `changes`), `on()` hooks, `dispose`,
package/cjs/index.cjs CHANGED
@@ -34,7 +34,7 @@ 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
36
 
37
- //#region ../ai-workspace/src/errors.ts
37
+ //#region ../../packages/ai-workspace/src/errors.ts
38
38
  /**
39
39
  * The workspace policy engine refused an operation — a path escaped the
40
40
  * jail (or hit a deny glob), or a shell command's executable was not
@@ -89,7 +89,7 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
89
89
  };
90
90
 
91
91
  //#endregion
92
- //#region ../ai-workspace/src/policy/tokenize-command.ts
92
+ //#region ../../packages/ai-workspace/src/policy/tokenize-command.ts
93
93
  /**
94
94
  * Characters that are refused when they appear UNQUOTED in a command line.
95
95
  * Workspace commands are executed as a direct argv spawn — never through a
@@ -165,7 +165,7 @@ function tokenizeCommand(command) {
165
165
  }
166
166
 
167
167
  //#endregion
168
- //#region ../ai-workspace/src/policy/policy.ts
168
+ //#region ../../packages/ai-workspace/src/policy/policy.ts
169
169
  /**
170
170
  * Resolve the canonical absolute form of `target`, collapsing any
171
171
  * symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),
@@ -351,7 +351,7 @@ function buildEnv(policy) {
351
351
  }
352
352
 
353
353
  //#endregion
354
- //#region ../ai-workspace/src/ops.ts
354
+ //#region ../../packages/ai-workspace/src/ops.ts
355
355
  /** Default line window a read returns when the policy sets no `defaultLines`. */
356
356
  const DEFAULT_READ_LINES = 2e3;
357
357
  /** Hard ceiling on grep matches returned, so a broad pattern can't flood. */
@@ -359,6 +359,12 @@ const DEFAULT_MAX_GREP_MATCHES = 1e3;
359
359
  /** Default per-command output byte cap when the policy sets none. */
360
360
  const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
361
361
  /**
362
+ * Exit code reported for a command refused because its signal was already
363
+ * aborted (the shell convention for an interrupted command). Callers should
364
+ * branch on `cancelled`, not on this number.
365
+ */
366
+ const CANCELLED_BEFORE_START_EXIT_CODE = 130;
367
+ /**
362
368
  * Hard ceiling on `grep` pattern length. A model-controlled regex has no
363
369
  * legitimate reason to be this long; longer patterns are rejected outright
364
370
  * rather than compiled.
@@ -372,33 +378,76 @@ const MAX_GREP_PATTERN_LENGTH = 200;
372
378
  * this are skipped rather than scanned.
373
379
  */
374
380
  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");
381
+ /** The `cat -n` prefix for a line: right-aligned number (min width 6) and a tab. */
382
+ function linePrefix(lineNumber) {
383
+ return `${String(lineNumber).padStart(6, " ")}\t`;
385
384
  }
386
385
  /**
387
- * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes).
388
- * Returns the possibly-clipped string plus whether clipping occurred.
386
+ * Clip `value` to at most `maxBytes` UTF-8 bytes without splitting a
387
+ * character: the cut backs off over continuation bytes (`10xxxxxx`) to the
388
+ * start of the character it would have split, so the result never carries
389
+ * a replacement character.
389
390
  */
390
- function capOutput(value, maxBytes) {
391
+ function clipUtf8(value, maxBytes) {
391
392
  const bytes = Buffer.from(value, "utf8");
392
393
  if (bytes.byteLength <= maxBytes) return {
393
394
  value,
394
395
  truncated: false
395
396
  };
397
+ let cut = Math.max(0, maxBytes);
398
+ while (cut > 0 && ((bytes[cut] ?? 0) & 192) === 128) cut--;
396
399
  return {
397
- value: bytes.subarray(0, maxBytes).toString("utf8"),
400
+ value: bytes.subarray(0, cut).toString("utf8"),
398
401
  truncated: true
399
402
  };
400
403
  }
401
404
  /**
405
+ * Number a window of lines `cat -n` style, keeping the joined output within
406
+ * `maxBytes` UTF-8 bytes (prefixes and newlines included) when a cap is set.
407
+ * A line that does not fit whole is clipped on a character boundary; it is
408
+ * still counted as returned when its prefix and at least one character fit.
409
+ */
410
+ function numberWindow(lines, startLine, maxBytes) {
411
+ const parts = [];
412
+ let used = 0;
413
+ for (let index = 0; index < lines.length; index++) {
414
+ const prefix = linePrefix(startLine + index);
415
+ const numbered = prefix + lines[index];
416
+ const separator = parts.length > 0 ? 1 : 0;
417
+ if (maxBytes === void 0) {
418
+ parts.push(numbered);
419
+ continue;
420
+ }
421
+ const cost = separator + Buffer.byteLength(numbered, "utf8");
422
+ if (used + cost <= maxBytes) {
423
+ parts.push(numbered);
424
+ used += cost;
425
+ continue;
426
+ }
427
+ const room = maxBytes - used - separator - Buffer.byteLength(prefix, "utf8");
428
+ const clipped = room > 0 ? clipUtf8(lines[index] ?? "", room).value : "";
429
+ if (clipped.length > 0) parts.push(prefix + clipped);
430
+ return {
431
+ content: parts.join("\n"),
432
+ returnedLines: parts.length,
433
+ bytesCapped: true
434
+ };
435
+ }
436
+ return {
437
+ content: parts.join("\n"),
438
+ returnedLines: parts.length,
439
+ bytesCapped: false
440
+ };
441
+ }
442
+ /**
443
+ * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes), on a
444
+ * character boundary. Returns the possibly-clipped string plus whether
445
+ * clipping occurred.
446
+ */
447
+ function capOutput(value, maxBytes) {
448
+ return clipUtf8(value, maxBytes);
449
+ }
450
+ /**
402
451
  * Minimal glob match over a `/`-separated relative path. Supports `**`
403
452
  * (spans separators), `*` (within a segment), and `?` (one non-separator
404
453
  * char) — enough for the workspace's `glob`/grep narrowing without
@@ -457,6 +506,24 @@ var Ops = class {
457
506
  constructor(backend, policy) {
458
507
  this.backend = backend;
459
508
  this.policy = policy;
509
+ this.pathLocks = /* @__PURE__ */ new Map();
510
+ }
511
+ /** Run `task` after every earlier mutation of `absolutePath` has settled. */
512
+ async withPathLock(absolutePath, task) {
513
+ const previous = this.pathLocks.get(absolutePath) ?? Promise.resolve();
514
+ let release = () => {};
515
+ const current = new Promise((resolve) => {
516
+ release = resolve;
517
+ });
518
+ const tail = previous.then(() => current);
519
+ this.pathLocks.set(absolutePath, tail);
520
+ try {
521
+ await previous;
522
+ return await task();
523
+ } finally {
524
+ release();
525
+ if (this.pathLocks.get(absolutePath) === tail) this.pathLocks.delete(absolutePath);
526
+ }
460
527
  }
461
528
  /**
462
529
  * Recursively collect every file under `absDir` as absolute paths,
@@ -491,25 +558,39 @@ var Ops = class {
491
558
  const totalLines = lines.length;
492
559
  const offset = Math.max(1, opts?.offset ?? 1);
493
560
  const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
561
+ const maxBytes = this.policy.read?.maxBytes;
494
562
  const startIndex = offset - 1;
563
+ 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)));
564
+ const endLine = offset + returnedLines - 1;
495
565
  return {
496
- content: numberLines(lines.slice(startIndex, startIndex + limit).join("\n"), offset),
497
- hash,
498
- totalLines
566
+ content,
567
+ startLine: offset,
568
+ endLine,
569
+ totalLines,
570
+ truncated: bytesCapped || endLine < totalLines,
571
+ bytesCapped,
572
+ totalBytes: Buffer.byteLength(raw, "utf8"),
573
+ hash
499
574
  };
500
575
  }
501
576
  async writeFile(inputPath, content) {
502
577
  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
- };
578
+ return this.withPathLock(absolutePath, async () => {
579
+ const parent = node_path.default.dirname(absolutePath);
580
+ await this.backend.mkdir(parent);
581
+ await this.backend.writeFile(absolutePath, content);
582
+ return {
583
+ hash: _warlock_js_fs.fs.hash.string(content),
584
+ bytesWritten: Buffer.byteLength(content, "utf8")
585
+ };
586
+ });
510
587
  }
511
588
  async editFile(input) {
512
589
  const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
590
+ return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
591
+ }
592
+ /** The read-check-replace-write of {@link editFile}, run under its path lock. */
593
+ async applyEdit(input, absolutePath, relativePath) {
513
594
  const current = await this.backend.readFile(absolutePath);
514
595
  const currentHash = _warlock_js_fs.fs.hash.string(current);
515
596
  if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; the edit is stale.`, {
@@ -542,22 +623,34 @@ var Ops = class {
542
623
  type: "denied-command",
543
624
  command
544
625
  });
626
+ const signal = opts?.signal;
627
+ if (signal?.aborted) return {
628
+ exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
629
+ stdout: "",
630
+ stderr: "Command was not executed: the invocation was cancelled before it started.",
631
+ truncated: false,
632
+ timedOut: false,
633
+ cancelled: true
634
+ };
545
635
  const shell = this.policy.shell;
546
636
  const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
547
637
  const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
548
638
  const result = await this.backend.exec(command, {
549
639
  cwd: this.policy.cwd,
550
640
  timeoutMs,
551
- env: buildEnv(this.policy)
641
+ env: buildEnv(this.policy),
642
+ ...signal ? { signal } : {}
552
643
  });
553
644
  const stdout = capOutput(result.stdout, maxOutputBytes);
554
645
  const stderr = capOutput(result.stderr, maxOutputBytes);
646
+ const cancelled = result.cancelled === true;
555
647
  return {
556
648
  exitCode: result.exitCode,
557
649
  stdout: stdout.value,
558
650
  stderr: stderr.value,
559
651
  truncated: stdout.truncated || stderr.truncated,
560
- timedOut: result.timedOut
652
+ timedOut: result.timedOut && !cancelled,
653
+ cancelled
561
654
  };
562
655
  }
563
656
  async grep(pattern, opts) {
@@ -625,7 +718,7 @@ var Ops = class {
625
718
  }
626
719
  async remove(inputPath) {
627
720
  const { absolutePath } = await resolveInJail(this.policy, inputPath);
628
- await this.backend.remove(absolutePath);
721
+ await this.withPathLock(absolutePath, () => this.backend.remove(absolutePath));
629
722
  }
630
723
  /** Whether a `/`-separated relative path hits the policy deny list. */
631
724
  isDenied(relativePath) {
@@ -682,7 +775,7 @@ function createOps(backend, policy) {
682
775
  }
683
776
 
684
777
  //#endregion
685
- //#region ../ai-workspace/src/backends/local.ts
778
+ //#region ../../packages/ai-workspace/src/backends/local.ts
686
779
  /**
687
780
  * Hard ceiling on captured `stdout`/`stderr` per stream, in bytes. The raw
688
781
  * executor truncates each stream once this many bytes have accumulated so a
@@ -729,7 +822,9 @@ function killTree(pid, child) {
729
822
  String(pid),
730
823
  "/T",
731
824
  "/F"
732
- ], { windowsHide: true });
825
+ ], { windowsHide: true }).on("error", () => {
826
+ child.kill("SIGKILL");
827
+ });
733
828
  return;
734
829
  }
735
830
  try {
@@ -846,11 +941,13 @@ var LocalBackend = class {
846
941
  * cannot be spawned shell-less) with every element individually quoted.
847
942
  * `cwd`, `env`, and the timeout are taken verbatim from the ops layer
848
943
  * (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}.
944
+ * process tree is SIGKILLed and `timedOut` is set. When `signal` aborts
945
+ * mid-run the process tree is SIGKILLed the same way and `cancelled` is
946
+ * set; an already-aborted `signal` resolves `cancelled` without spawning.
947
+ * `stdout`/`stderr` are captured and byte-capped per {@link MAX_STREAM_BYTES}.
851
948
  *
852
949
  * Never rejects for a non-zero exit, a missing executable, a refused
853
- * command line, or a timeout — those are reported through the resolved
950
+ * command line, a timeout or a cancellation — those are reported through the resolved
854
951
  * {@link WorkspaceBackendExecResult} so the ops layer can surface them
855
952
  * as tool-error data.
856
953
  */
@@ -862,12 +959,24 @@ var LocalBackend = class {
862
959
  stderr,
863
960
  timedOut: false
864
961
  });
962
+ const signal = opts.signal;
963
+ if (signal?.aborted) {
964
+ resolve({
965
+ exitCode: 130,
966
+ stdout: "",
967
+ stderr: "Command was not executed: the invocation was cancelled before it started.",
968
+ timedOut: false,
969
+ cancelled: true
970
+ });
971
+ return;
972
+ }
865
973
  const argv = tokenizeCommand(command);
866
- if (argv === null) {
974
+ const executable = argv?.[0];
975
+ if (argv === null || executable === void 0) {
867
976
  refuse("Command was not executed: it is empty, has unbalanced quotes, or contains unquoted shell metacharacters (;, &, |, `, $, <, >, parentheses). Commands run without a shell — pass metacharacters inside quotes as literal arguments, or run one command at a time.");
868
977
  return;
869
978
  }
870
- let file = argv[0];
979
+ let file = executable;
871
980
  let args = argv.slice(1);
872
981
  let windowsVerbatimArguments = false;
873
982
  if (node_process.platform === "win32") {
@@ -893,20 +1002,31 @@ var LocalBackend = class {
893
1002
  let stdoutBytes = 0;
894
1003
  let stderrBytes = 0;
895
1004
  let timedOut = false;
1005
+ let cancelled = false;
896
1006
  let settled = false;
1007
+ let exited = false;
897
1008
  const timer = opts.timeoutMs !== void 0 && opts.timeoutMs > 0 ? setTimeout(() => {
1009
+ if (cancelled) return;
898
1010
  timedOut = true;
899
1011
  killTree(child.pid, child);
900
1012
  }, opts.timeoutMs) : void 0;
1013
+ const onAbort = () => {
1014
+ if (settled || timedOut) return;
1015
+ if (!exited) cancelled = true;
1016
+ killTree(child.pid, child);
1017
+ };
1018
+ signal?.addEventListener("abort", onAbort, { once: true });
901
1019
  const settle = (exitCode) => {
902
1020
  if (settled) return;
903
1021
  settled = true;
904
1022
  if (timer !== void 0) clearTimeout(timer);
1023
+ signal?.removeEventListener("abort", onAbort);
905
1024
  resolve({
906
1025
  exitCode,
907
1026
  stdout: Buffer.concat(stdoutChunks).toString("utf8"),
908
1027
  stderr: Buffer.concat(stderrChunks).toString("utf8"),
909
- timedOut
1028
+ timedOut,
1029
+ cancelled
910
1030
  });
911
1031
  };
912
1032
  child.stdout?.on("data", (chunk) => {
@@ -918,6 +1038,9 @@ var LocalBackend = class {
918
1038
  child.on("error", () => {
919
1039
  settle(127);
920
1040
  });
1041
+ child.on("exit", () => {
1042
+ exited = true;
1043
+ });
921
1044
  child.on("close", (code, signal) => {
922
1045
  if (code === null) {
923
1046
  settle(128 + (signal === "SIGKILL" ? 9 : 1));
@@ -948,7 +1071,7 @@ function createLocalBackend() {
948
1071
  }
949
1072
 
950
1073
  //#endregion
951
- //#region ../ai-workspace/src/backends/mock.ts
1074
+ //#region ../../packages/ai-workspace/src/backends/mock.ts
952
1075
  /**
953
1076
  * Normalize an absolute path to a stable in-memory key: forward slashes,
954
1077
  * collapsed duplicate separators, and resolved `.` / `..` segments. There
@@ -1048,13 +1171,21 @@ var MockBackend = class {
1048
1171
  async realpath(absPath) {
1049
1172
  return canonicalize(absPath);
1050
1173
  }
1051
- async exec(command, _opts) {
1174
+ async exec(command, opts) {
1175
+ if (opts?.signal?.aborted) return {
1176
+ exitCode: 130,
1177
+ stdout: "",
1178
+ stderr: "",
1179
+ timedOut: false,
1180
+ cancelled: true
1181
+ };
1052
1182
  const scripted = this.commands.get(command);
1053
1183
  return {
1054
1184
  exitCode: scripted?.exitCode ?? 0,
1055
1185
  stdout: scripted?.stdout ?? "",
1056
1186
  stderr: scripted?.stderr ?? "",
1057
- timedOut: scripted?.timedOut ?? false
1187
+ timedOut: scripted?.timedOut ?? false,
1188
+ cancelled: scripted?.cancelled ?? false
1058
1189
  };
1059
1190
  }
1060
1191
  };
@@ -1113,7 +1244,7 @@ function isMockBackendSeed(seed) {
1113
1244
  }
1114
1245
 
1115
1246
  //#endregion
1116
- //#region ../ai-workspace/src/tools/schema.ts
1247
+ //#region ../../packages/ai-workspace/src/tools/schema.ts
1117
1248
  /**
1118
1249
  * Tiny, dependency-free [Standard Schema](https://standardschema.dev)
1119
1250
  * builders for the workspace tools' input validation. The package pins
@@ -1222,7 +1353,7 @@ function objectSchema(shape) {
1222
1353
  }
1223
1354
 
1224
1355
  //#endregion
1225
- //#region ../ai-workspace/src/tools/edit-file.ts
1356
+ //#region ../../packages/ai-workspace/src/tools/edit-file.ts
1226
1357
  /** Default tool name exposed to the LLM. */
1227
1358
  const DEFAULT_NAME$2 = "edit_file";
1228
1359
  /** Input schema for the `edit_file` tool. */
@@ -1274,7 +1405,7 @@ function makeEditFileTool(ops, options) {
1274
1405
  }
1275
1406
 
1276
1407
  //#endregion
1277
- //#region ../ai-workspace/src/tools/glob.ts
1408
+ //#region ../../packages/ai-workspace/src/tools/glob.ts
1278
1409
  /**
1279
1410
  * Standard Schema for {@link GlobInput} — a single required `pattern`
1280
1411
  * string. Built on the package's shared, dependency-free schema builders
@@ -1314,7 +1445,7 @@ function makeGlobTool(ops, options) {
1314
1445
  }
1315
1446
 
1316
1447
  //#endregion
1317
- //#region ../ai-workspace/src/tools/grep.ts
1448
+ //#region ../../packages/ai-workspace/src/tools/grep.ts
1318
1449
  /**
1319
1450
  * Standard Schema for {@link GrepInput} — `pattern` is a required string;
1320
1451
  * `glob` and `ignoreCase` are optional. Built on the package's shared,
@@ -1360,7 +1491,7 @@ function makeGrepTool(ops, options) {
1360
1491
  }
1361
1492
 
1362
1493
  //#endregion
1363
- //#region ../ai-workspace/src/tools/read-file.ts
1494
+ //#region ../../packages/ai-workspace/src/tools/read-file.ts
1364
1495
  /** Default tool name exposed to the LLM. */
1365
1496
  const DEFAULT_NAME$1 = "read_file";
1366
1497
  /** Input schema for the `read_file` tool. */
@@ -1375,10 +1506,10 @@ const inputSchema$1 = objectSchema({
1375
1506
  *
1376
1507
  * The tool validates `{ path, startLine?, limit? }` against a Standard
1377
1508
  * 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`.
1509
+ * agent wire shape {@link ReadFileResult} — the full file's `hash` an
1510
+ * agent must carry into a later `edit_file` (read-before-edit), plus the
1511
+ * `startLine` / `endLine` / `truncated` / `bytesCapped` window metadata.
1512
+ * The returned `content` never exceeds the policy's `read.maxBytes`.
1382
1513
  *
1383
1514
  * **Errors flow as data.** Policy violations (a jail escape) are thrown
1384
1515
  * by `ops`; the `tool()` wrapper catches them and surfaces them in the
@@ -1396,30 +1527,19 @@ const inputSchema$1 = objectSchema({
1396
1527
  function makeReadFileTool(ops, options) {
1397
1528
  return (0, _warlock_js_ai.tool)({
1398
1529
  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.",
1530
+ 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.",
1400
1531
  input: inputSchema$1,
1401
1532
  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,
1533
+ return ops.readFile(input.path, {
1534
+ offset: input.startLine !== void 0 ? Math.max(1, input.startLine) : 1,
1405
1535
  limit: input.limit
1406
1536
  });
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
1537
  }
1418
1538
  });
1419
1539
  }
1420
1540
 
1421
1541
  //#endregion
1422
- //#region ../ai-workspace/src/tools/run-shell.ts
1542
+ //#region ../../packages/ai-workspace/src/tools/run-shell.ts
1423
1543
  /** The default tool name `run_shell` is exposed to the LLM under. */
1424
1544
  const DEFAULT_RUN_SHELL_TOOL_NAME = "run_shell";
1425
1545
  /**
@@ -1460,6 +1580,10 @@ const runShellInputSchema = { "~standard": {
1460
1580
  * but exits non-zero is *not* an error — its `exitCode`/`stderr` come back
1461
1581
  * in `data` for the agent to inspect.
1462
1582
  *
1583
+ * The agent run's cancellation `signal` (from the dispatch `ctx`) is passed
1584
+ * to `ops.exec` per invocation: a cancelled run never starts the command,
1585
+ * and one cancelled mid-command kills its process tree (`cancelled: true`).
1586
+ *
1463
1587
  * @param ops - The policy-enforced operation layer to delegate `exec` to.
1464
1588
  * @param options - Optional tool-name override.
1465
1589
  *
@@ -1475,12 +1599,15 @@ function makeRunShellTool(ops, options) {
1475
1599
  description: "Run a single shell command inside the workspace. The command's executable must be permitted by the shell policy; output is byte-capped and the run is time-limited. A non-zero exit code is returned as data, not an error.",
1476
1600
  action: (input) => `Running \`${input.command}\``,
1477
1601
  input: runShellInputSchema,
1478
- execute: (input) => ops.exec(input.command, { timeoutMs: input.timeoutMs })
1602
+ execute: (input, ctx) => ops.exec(input.command, {
1603
+ timeoutMs: input.timeoutMs,
1604
+ ...ctx?.signal ? { signal: ctx.signal } : {}
1605
+ })
1479
1606
  });
1480
1607
  }
1481
1608
 
1482
1609
  //#endregion
1483
- //#region ../ai-workspace/src/tools/run-tests.ts
1610
+ //#region ../../packages/ai-workspace/src/tools/run-tests.ts
1484
1611
  /** The default tool name `run_tests` is exposed to the LLM under. */
1485
1612
  const DEFAULT_RUN_TESTS_TOOL_NAME = "run_tests";
1486
1613
  /** The default command run when no `command` override is configured. */
@@ -1527,7 +1654,8 @@ const runTestsInputSchema = { "~standard": {
1527
1654
  * data, never a second command. Like `run_shell`, the resolved command's
1528
1655
  * executable is gated by the shell policy — a denial surfaces in the
1529
1656
  * result's `error` field — and a non-zero exit (failing tests) comes back
1530
- * as `data` for the agent to read and fix.
1657
+ * as `data` for the agent to read and fix. The agent run's cancellation
1658
+ * `signal` is threaded per invocation, exactly as for `run_shell`.
1531
1659
  *
1532
1660
  * @param ops - The policy-enforced operation layer to delegate `exec` to.
1533
1661
  * @param options - Optional tool-name and base-command overrides.
@@ -1544,15 +1672,15 @@ function makeRunTestsTool(ops, options) {
1544
1672
  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
1673
  action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
1546
1674
  input: runTestsInputSchema,
1547
- execute: (input) => {
1675
+ execute: (input, ctx) => {
1548
1676
  const command = input.pattern ? `${baseCommand} "${input.pattern}"` : baseCommand;
1549
- return ops.exec(command);
1677
+ return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
1550
1678
  }
1551
1679
  });
1552
1680
  }
1553
1681
 
1554
1682
  //#endregion
1555
- //#region ../ai-workspace/src/tools/write-file.ts
1683
+ //#region ../../packages/ai-workspace/src/tools/write-file.ts
1556
1684
  /** Default tool name exposed to the LLM. */
1557
1685
  const DEFAULT_NAME = "write_file";
1558
1686
  /** Input schema for the `write_file` tool. */
@@ -1599,7 +1727,7 @@ function makeWriteFileTool(ops, options) {
1599
1727
  }
1600
1728
 
1601
1729
  //#endregion
1602
- //#region ../ai-workspace/src/workspace.ts
1730
+ //#region ../../packages/ai-workspace/src/workspace.ts
1603
1731
  /**
1604
1732
  * The full set of tool names a writable workspace vends, in a stable
1605
1733
  * canonical order so `tools.all()` is deterministic.