@warlock.js/ai-workspace 5.45.0 → 5.47.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 (48) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -0
  3. package/cjs/index.cjs +514 -54
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts.map +1 -1
  6. package/esm/backends/local.mjs +24 -1
  7. package/esm/backends/local.mjs.map +1 -1
  8. package/esm/contracts/index.d.mts +4 -4
  9. package/esm/contracts/tool-io.type.d.mts +6 -0
  10. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  11. package/esm/contracts/workspace-backend.contract.d.mts +20 -0
  12. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  13. package/esm/contracts/workspace-edit.type.d.mts +30 -0
  14. package/esm/contracts/workspace-edit.type.d.mts.map +1 -1
  15. package/esm/contracts/workspace-ops.contract.d.mts +7 -2
  16. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  17. package/esm/contracts/workspace-policy.type.d.mts +23 -0
  18. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  19. package/esm/contracts/workspace.contract.d.mts +4 -1
  20. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  21. package/esm/hooks/shell-output.mjs +113 -0
  22. package/esm/hooks/shell-output.mjs.map +1 -0
  23. package/esm/index.d.mts +4 -4
  24. package/esm/ops.d.mts.map +1 -1
  25. package/esm/ops.mjs +108 -40
  26. package/esm/ops.mjs.map +1 -1
  27. package/esm/policy/shell-check.d.mts +2 -0
  28. package/esm/policy/shell-check.d.mts.map +1 -1
  29. package/esm/policy/shell-check.mjs +59 -5
  30. package/esm/policy/shell-check.mjs.map +1 -1
  31. package/esm/search/walk.mjs +209 -0
  32. package/esm/search/walk.mjs.map +1 -0
  33. package/esm/shell-jobs.d.mts +1 -1
  34. package/esm/shell-jobs.d.mts.map +1 -1
  35. package/esm/shell-jobs.mjs +1 -1
  36. package/esm/shell-jobs.mjs.map +1 -1
  37. package/esm/tools/glob.mjs +5 -4
  38. package/esm/tools/glob.mjs.map +1 -1
  39. package/esm/tools/grep.mjs +3 -2
  40. package/esm/tools/grep.mjs.map +1 -1
  41. package/esm/tools/run-shell.d.mts.map +1 -1
  42. package/esm/tools/run-shell.mjs +2 -1
  43. package/esm/tools/run-shell.mjs.map +1 -1
  44. package/esm/workspace.mjs +2 -2
  45. package/esm/workspace.mjs.map +1 -1
  46. package/llms-full.txt +36 -17
  47. package/package.json +3 -3
  48. package/skills/use-a-workspace/SKILL.md +36 -17
package/cjs/index.cjs CHANGED
@@ -2147,6 +2147,53 @@ var Refusal = class extends Error {
2147
2147
  this.refusedPath = refusedPath;
2148
2148
  }
2149
2149
  };
2150
+ /**
2151
+ * The first word of `command` that has an unquoted backslash before a
2152
+ * letter, digit or dot inside it (a Windows-style path bash reads as
2153
+ * escapes), with the same word spelled with forward slashes. Quoted text
2154
+ * and `\ ` style escapes (a backslash before a space, quote, `$` and the
2155
+ * like) are left alone.
2156
+ */
2157
+ function findWindowsStylePath(command) {
2158
+ let quote = null;
2159
+ let start = 0;
2160
+ let found = false;
2161
+ let suggestion = "";
2162
+ const finish = (end) => found ? {
2163
+ word: command.slice(start, end),
2164
+ suggestion
2165
+ } : null;
2166
+ for (let index = 0; index < command.length; index++) {
2167
+ const char = command[index];
2168
+ if (quote === null && /[\s;&|<>()]/.test(char)) {
2169
+ const hit = finish(index);
2170
+ if (hit !== null) return hit;
2171
+ start = index + 1;
2172
+ found = false;
2173
+ suggestion = "";
2174
+ continue;
2175
+ }
2176
+ if (quote === "'") {
2177
+ suggestion += char;
2178
+ quote = char === "'" ? null : quote;
2179
+ continue;
2180
+ }
2181
+ if (char === "\\") {
2182
+ const next = command[index + 1] ?? "";
2183
+ if (quote === null && index > start && /[A-Za-z0-9.]/.test(next)) {
2184
+ found = true;
2185
+ suggestion += "/";
2186
+ continue;
2187
+ }
2188
+ suggestion += char + next;
2189
+ index++;
2190
+ continue;
2191
+ }
2192
+ if (char === "\"" || char === "'") quote = quote === null ? char : quote === char ? null : quote;
2193
+ suggestion += char;
2194
+ }
2195
+ return finish(command.length);
2196
+ }
2150
2197
  /** Per-line checker state. */
2151
2198
  var LineChecker = class {
2152
2199
  constructor(policy, shell, line, dialect, interpreter, remembered = []) {
@@ -2636,9 +2683,14 @@ function checkSimpleCommand(checker, simple, cwds, dialect) {
2636
2683
  */
2637
2684
  function checkShellCommand(policy, command, options) {
2638
2685
  const resolved = options?.resolved ?? resolveShell(policy.shell, options?.host ?? currentShellHost());
2639
- return runCheck(policy, command, resolved.interpreter, resolved.dialect, options?.remembered);
2686
+ return runCheck(policy, command, resolved.interpreter, resolved.dialect, options?.remembered, options?.platform ?? options?.host?.platform ?? process.platform);
2640
2687
  }
2641
- function runCheck(policy, command, interpreter, dialect, remembered) {
2688
+ /** Bash on Windows reads `ab\\x` as an escape, not a path: refuse and show the forward-slash form. */
2689
+ function refuseWindowsStylePath(command) {
2690
+ const hit = findWindowsStylePath(command);
2691
+ if (hit !== null) throw new Refusal("unsupported-syntax", `\`${hit.word}\` uses Windows-style backslashes, which bash reads as escapes and so runs a different path. Write the path with forward slashes: \`${hit.suggestion}\` (or quote it).`);
2692
+ }
2693
+ function runCheck(policy, command, interpreter, dialect, remembered, platform = process.platform) {
2642
2694
  const shell = policy.shell;
2643
2695
  if (!shell) return {
2644
2696
  allowed: false,
@@ -2652,8 +2704,10 @@ function runCheck(policy, command, interpreter, dialect, remembered) {
2652
2704
  const checker = new LineChecker(policy, shell, command, shellMode ? dialect : "posix", interpreter, remembered);
2653
2705
  try {
2654
2706
  if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
2655
- if (shellMode) checkShellLine(checker, policy, command, dialect);
2656
- else {
2707
+ if (shellMode) {
2708
+ if (interpreter === "bash" && platform === "win32") refuseWindowsStylePath(command);
2709
+ checkShellLine(checker, policy, command, dialect);
2710
+ } else {
2657
2711
  const argv = tokenizeCommand(command);
2658
2712
  if (argv === null) throw argvModeRefusal(command);
2659
2713
  checker.checkArgv(argv, argv.map(() => false), 0);
@@ -3667,6 +3721,117 @@ async function runAfterEdit(policy, event) {
3667
3721
  }
3668
3722
  }
3669
3723
 
3724
+ //#endregion
3725
+ //#region ../../packages/ai-workspace/src/hooks/shell-output.ts
3726
+ /** Shortest gap between two deliveries to the hook (about 20 a second). */
3727
+ const MIN_INTERVAL_MS = 50;
3728
+ /** A line this long without a newline is delivered anyway (minus the tail kept back). */
3729
+ const MAX_PENDING_CHARS = 16384;
3730
+ /** Characters of an over-long line held back, so a secret on the cut is still whole next time. */
3731
+ const HOLD_BACK_CHARS = 512;
3732
+ /** Shortest `shell.env` value masked automatically. */
3733
+ const MIN_SECRET_LENGTH = 8;
3734
+ /**
3735
+ * Feeds a command's output to `hooks.onShellOutput`.
3736
+ *
3737
+ * Redaction needs a whole secret in one piece, and a process writes in
3738
+ * arbitrary pieces, so output is buffered to line boundaries before it is
3739
+ * redacted and delivered; the unfinished last line waits until its newline
3740
+ * arrives or the command ends (an over-long line is cut, keeping its last
3741
+ * characters back). Deliveries are merged to at most one per
3742
+ * {@link MIN_INTERVAL_MS} and the hook can never throw into the command.
3743
+ */
3744
+ var ShellOutputStreamer = class ShellOutputStreamer {
3745
+ constructor(hooks, source, env) {
3746
+ this.hooks = hooks;
3747
+ this.source = source;
3748
+ this.received = false;
3749
+ this.pending = {
3750
+ stdout: "",
3751
+ stderr: ""
3752
+ };
3753
+ this.ready = {
3754
+ stdout: "",
3755
+ stderr: ""
3756
+ };
3757
+ this.lastEmit = 0;
3758
+ this.ended = false;
3759
+ this.secrets = Object.values(env ?? {}).filter((value) => value.length >= MIN_SECRET_LENGTH).sort((a, b) => b.length - a.length);
3760
+ }
3761
+ /** A streamer when the policy has `onShellOutput`, otherwise `undefined`. */
3762
+ static create(hooks, source, env) {
3763
+ return hooks?.onShellOutput === void 0 ? void 0 : new ShellOutputStreamer(hooks, source, env);
3764
+ }
3765
+ /** Add decoded output of one stream. */
3766
+ push(stream, text) {
3767
+ if (this.ended || text === "") return;
3768
+ this.received = true;
3769
+ let pending = this.pending[stream] + text;
3770
+ const newline = pending.lastIndexOf("\n");
3771
+ if (newline >= 0) {
3772
+ this.ready[stream] += pending.slice(0, newline + 1);
3773
+ pending = pending.slice(newline + 1);
3774
+ }
3775
+ if (pending.length > MAX_PENDING_CHARS) {
3776
+ this.ready[stream] += pending.slice(0, pending.length - HOLD_BACK_CHARS);
3777
+ pending = pending.slice(pending.length - HOLD_BACK_CHARS);
3778
+ }
3779
+ this.pending[stream] = pending;
3780
+ this.schedule();
3781
+ }
3782
+ /** Deliver everything left, including unfinished lines, and stop. */
3783
+ end() {
3784
+ if (this.ended) return;
3785
+ for (const stream of ["stdout", "stderr"]) {
3786
+ this.ready[stream] += this.pending[stream];
3787
+ this.pending[stream] = "";
3788
+ }
3789
+ this.clearTimer();
3790
+ this.flush();
3791
+ this.ended = true;
3792
+ }
3793
+ schedule() {
3794
+ if (this.timer !== void 0) return;
3795
+ const wait = Math.max(0, this.lastEmit + MIN_INTERVAL_MS - Date.now());
3796
+ this.timer = setTimeout(() => {
3797
+ this.timer = void 0;
3798
+ this.flush();
3799
+ }, wait);
3800
+ this.timer.unref?.();
3801
+ }
3802
+ clearTimer() {
3803
+ if (this.timer !== void 0) {
3804
+ clearTimeout(this.timer);
3805
+ this.timer = void 0;
3806
+ }
3807
+ }
3808
+ flush() {
3809
+ this.lastEmit = Date.now();
3810
+ for (const stream of ["stdout", "stderr"]) {
3811
+ const text = this.ready[stream];
3812
+ if (text === "") continue;
3813
+ this.ready[stream] = "";
3814
+ this.deliver(stream, text);
3815
+ }
3816
+ }
3817
+ deliver(stream, text) {
3818
+ try {
3819
+ let chunk = text;
3820
+ for (const secret of this.secrets) chunk = chunk.split(secret).join("[redacted]");
3821
+ chunk = this.hooks.redactShellOutput?.(chunk) ?? chunk;
3822
+ if (chunk === "") return;
3823
+ const event = {
3824
+ command: this.source.command,
3825
+ stream,
3826
+ chunk,
3827
+ ...this.source.callId !== void 0 ? { callId: this.source.callId } : {},
3828
+ ...this.source.jobId !== void 0 ? { jobId: this.source.jobId } : {}
3829
+ };
3830
+ this.hooks.onShellOutput?.(event);
3831
+ } catch {}
3832
+ }
3833
+ };
3834
+
3670
3835
  //#endregion
3671
3836
  //#region ../../packages/ai-workspace/src/output/spill.ts
3672
3837
  const DEFAULT_MAX_LINES = 2e3;
@@ -4089,7 +4254,7 @@ var ShellJobs = class {
4089
4254
  });
4090
4255
  const job = new ShellJob(`job-${this.nextId++}`, spec.command, spec.retainBytes);
4091
4256
  this.jobs.set(job.id, job);
4092
- const handle = spec.launch((stream, chunk) => job.append(stream, chunk));
4257
+ const handle = spec.launch((stream, chunk) => job.append(stream, chunk), job.id);
4093
4258
  job.process = handle;
4094
4259
  if (spec.timeoutMs !== void 0 && spec.timeoutMs > 0) {
4095
4260
  job.timer = setTimeout(() => {
@@ -4148,6 +4313,210 @@ var ShellJobs = class {
4148
4313
  }
4149
4314
  };
4150
4315
 
4316
+ //#endregion
4317
+ //#region ../../packages/ai-workspace/src/search/walk.ts
4318
+ /** Directories `glob` and `grep` never descend into unless `search.skipDirs` says otherwise. */
4319
+ const DEFAULT_SEARCH_SKIP_DIRS = [".git", "node_modules"];
4320
+ /** Default most files one `glob` or `grep` walk looks at. */
4321
+ const DEFAULT_SEARCH_MAX_FILES = 5e4;
4322
+ /**
4323
+ * The directory a glob can be anchored at: its leading segments that hold no
4324
+ * wildcard, never counting the last segment (it may be a file). `""` is the root.
4325
+ */
4326
+ function staticGlobPrefix(pattern) {
4327
+ const segments = pattern.split("/");
4328
+ const kept = [];
4329
+ for (let index = 0; index < segments.length - 1; index++) {
4330
+ const segment = segments[index] ?? "";
4331
+ if (segment === "" || segment === "." || segment === ".." || /[*?[\]{}\\]/.test(segment)) break;
4332
+ kept.push(segment);
4333
+ }
4334
+ return kept.join("/");
4335
+ }
4336
+ /** Parse the lines of a `.gitignore` located at workspace-relative `base`. */
4337
+ function parseGitignore(content, base) {
4338
+ const rules = [];
4339
+ for (const raw of content.split(/\r?\n/)) {
4340
+ let line = raw.trimEnd();
4341
+ if (line === "" || line.startsWith("#")) continue;
4342
+ const negate = line.startsWith("!");
4343
+ if (negate) line = line.slice(1);
4344
+ const dirOnly = line.endsWith("/");
4345
+ if (dirOnly) line = line.slice(0, -1);
4346
+ const anywhere = line.startsWith("**/");
4347
+ if (anywhere) line = line.slice(3);
4348
+ if (line.startsWith("/")) line = line.slice(1);
4349
+ if (line === "") continue;
4350
+ rules.push({
4351
+ base,
4352
+ negate,
4353
+ dirOnly,
4354
+ anywhere,
4355
+ nameOnly: !anywhere && !line.includes("/") && !raw.trimStart().startsWith("/"),
4356
+ regex: globToRegExp(line)
4357
+ });
4358
+ }
4359
+ return rules;
4360
+ }
4361
+ /** Whether the ordered rules (last match wins) ignore the entry at `rel`. */
4362
+ function isIgnored(rel, name, isDir, rules) {
4363
+ let ignored = false;
4364
+ for (const rule of rules) {
4365
+ if (rule.dirOnly && !isDir) continue;
4366
+ const local = rule.base === "" ? rel : rel.slice(rule.base.length + 1);
4367
+ let hit;
4368
+ if (rule.nameOnly) hit = rule.regex.test(name);
4369
+ else if (rule.anywhere) {
4370
+ const parts = local.split("/");
4371
+ hit = parts.some((_, index) => rule.regex.test(parts.slice(index).join("/")));
4372
+ } else hit = rule.regex.test(local);
4373
+ if (hit) ignored = !rule.negate;
4374
+ }
4375
+ return ignored;
4376
+ }
4377
+ /** Whether a directory name or relative path is one of the `skipDirs` globs. */
4378
+ function isSkipped(name, rel, skipDirs) {
4379
+ return skipDirs.some((glob) => glob.includes("/") ? matchesAnyGlob(rel, [glob]) : globToRegExp(glob).test(name));
4380
+ }
4381
+ /**
4382
+ * List a directory with entry types: the backend's own typed listing when it has
4383
+ * one, else its plain listing plus `stat`, else (no `stat`) a second listing per
4384
+ * entry to tell directories from files.
4385
+ */
4386
+ async function listTyped(backend, absDir) {
4387
+ if (backend.listTyped !== void 0) return backend.listTyped(absDir);
4388
+ const entries = await backend.list(absDir);
4389
+ return Promise.all(entries.map(async (entry) => {
4390
+ if (backend.stat !== void 0) try {
4391
+ return {
4392
+ path: entry,
4393
+ type: (await backend.stat(entry)).type
4394
+ };
4395
+ } catch {
4396
+ return {
4397
+ path: entry,
4398
+ type: "other"
4399
+ };
4400
+ }
4401
+ try {
4402
+ if ((await backend.list(entry)).length === 0) try {
4403
+ await backend.readFile(entry);
4404
+ return {
4405
+ path: entry,
4406
+ type: "file"
4407
+ };
4408
+ } catch {
4409
+ return {
4410
+ path: entry,
4411
+ type: "directory"
4412
+ };
4413
+ }
4414
+ return {
4415
+ path: entry,
4416
+ type: "directory"
4417
+ };
4418
+ } catch {
4419
+ return {
4420
+ path: entry,
4421
+ type: "file"
4422
+ };
4423
+ }
4424
+ }));
4425
+ }
4426
+ /** Read `.gitignore` at `absDir`, or nothing when there is none. */
4427
+ async function readGitignore(backend, absDir, base) {
4428
+ try {
4429
+ return parseGitignore(await backend.readFile(node_path.default.join(absDir, ".gitignore")), base);
4430
+ } catch {
4431
+ return [];
4432
+ }
4433
+ }
4434
+ /**
4435
+ * Walk the files under `startDir` breadth first. Denied, skipped and
4436
+ * gitignored directories are dropped before they are listed, so a tree such as
4437
+ * `node_modules` is never read. Directories are read `concurrency` at a time,
4438
+ * the signal is checked between batches, and the walk stops at `maxFiles`.
4439
+ * Symlinks are never followed; a symlink to a file is reported only when its
4440
+ * target is inside the jail root.
4441
+ */
4442
+ async function walkFiles(options) {
4443
+ const { backend, jailRoot, startDir, denyPaths, skipDirs, signal } = options;
4444
+ const files = [];
4445
+ let truncated = false;
4446
+ const isDenied = (rel) => denyPaths.length > 0 && matchesAnyGlob(rel, denyPaths);
4447
+ const isDeniedDir = (rel) => isDenied(rel) || denyPaths.some((glob) => glob.endsWith("/**") && globToRegExp(glob.slice(0, -3)).test(rel));
4448
+ const startRel = node_path.default.relative(jailRoot, startDir).split(node_path.default.sep).join("/");
4449
+ let inherited = [];
4450
+ if (options.gitignore) {
4451
+ const segments = startRel === "" ? [] : startRel.split("/");
4452
+ for (let depth = 0; depth < segments.length; depth++) {
4453
+ const base = segments.slice(0, depth).join("/");
4454
+ const abs = base === "" ? jailRoot : node_path.default.join(jailRoot, ...segments.slice(0, depth));
4455
+ inherited = inherited.concat(await readGitignore(backend, abs, base));
4456
+ }
4457
+ }
4458
+ let level = [{
4459
+ abs: startDir,
4460
+ rel: startRel,
4461
+ rules: inherited
4462
+ }];
4463
+ const readDir = async (dir, next) => {
4464
+ let entries;
4465
+ try {
4466
+ entries = await listTyped(backend, dir.abs);
4467
+ } catch {
4468
+ return;
4469
+ }
4470
+ let rules = dir.rules;
4471
+ if (options.gitignore && entries.some((entry) => node_path.default.basename(entry.path) === ".gitignore" && entry.type !== "directory")) rules = rules.concat(await readGitignore(backend, dir.abs, dir.rel));
4472
+ for (const entry of entries) {
4473
+ if (files.length >= options.maxFiles) {
4474
+ truncated = true;
4475
+ return;
4476
+ }
4477
+ const name = node_path.default.basename(entry.path);
4478
+ const rel = dir.rel === "" ? name : `${dir.rel}/${name}`;
4479
+ let type = entry.type;
4480
+ let abs = entry.path;
4481
+ if (type === "symlink") try {
4482
+ const target = await backend.realpath(entry.path);
4483
+ if (!isInside(target, jailRoot) || backend.stat === void 0) continue;
4484
+ if ((await backend.stat(target)).type !== "file") continue;
4485
+ type = "file";
4486
+ abs = entry.path;
4487
+ } catch {
4488
+ continue;
4489
+ }
4490
+ if (type === "directory") {
4491
+ if (isDeniedDir(rel) || isSkipped(name, rel, skipDirs)) continue;
4492
+ if (options.gitignore && isIgnored(rel, name, true, rules)) continue;
4493
+ next.push({
4494
+ abs,
4495
+ rel,
4496
+ rules
4497
+ });
4498
+ continue;
4499
+ }
4500
+ if (type !== "file" || isDenied(rel)) continue;
4501
+ if (options.gitignore && isIgnored(rel, name, false, rules)) continue;
4502
+ files.push(abs);
4503
+ }
4504
+ };
4505
+ while (level.length > 0 && !truncated) {
4506
+ const next = [];
4507
+ for (let index = 0; index < level.length && !truncated; index += options.concurrency) {
4508
+ signal?.throwIfAborted();
4509
+ await Promise.all(level.slice(index, index + options.concurrency).map((dir) => readDir(dir, next)));
4510
+ }
4511
+ signal?.throwIfAborted();
4512
+ level = next;
4513
+ }
4514
+ return {
4515
+ files,
4516
+ truncated
4517
+ };
4518
+ }
4519
+
4151
4520
  //#endregion
4152
4521
  //#region ../../packages/ai-workspace/src/ops.ts
4153
4522
  /** Default line window a read returns when the policy sets no `defaultLines`. */
@@ -4320,20 +4689,35 @@ var Ops = class {
4320
4689
  }
4321
4690
  }
4322
4691
  /**
4323
- * Recursively collect every file under `absDir` as absolute paths,
4324
- * via the backend's `list` (so it works over disk or the in-memory
4325
- * mock). Directories are descended; files are accumulated.
4692
+ * Collect the files a `glob` or `grep` should look at, starting at the
4693
+ * folder the pattern is anchored to (see {@link staticGlobPrefix}).
4694
+ * `denyPaths`, `search.skipDirs` and `.gitignore` are applied before a
4695
+ * directory is listed.
4326
4696
  */
4327
- async walkFiles(absDir) {
4328
- const found = [];
4329
- const entries = await this.backend.list(absDir);
4330
- await Promise.all(entries.map(async (entry) => {
4331
- if (await this.isDirectory(entry)) {
4332
- const nested = await this.walkFiles(entry);
4333
- found.push(...nested);
4334
- } else found.push(entry);
4335
- }));
4336
- return found;
4697
+ async searchFiles(jailRoot, pattern, signal) {
4698
+ const prefix = pattern === void 0 ? "" : staticGlobPrefix(pattern);
4699
+ const search = this.policy.search;
4700
+ let startDir = jailRoot;
4701
+ if (prefix !== "") try {
4702
+ startDir = (await resolveInJail(this.policy, prefix)).absolutePath;
4703
+ } catch (error) {
4704
+ if (error instanceof WorkspacePolicyError) return {
4705
+ files: [],
4706
+ truncated: false
4707
+ };
4708
+ throw error;
4709
+ }
4710
+ return walkFiles({
4711
+ backend: this.backend,
4712
+ jailRoot,
4713
+ startDir,
4714
+ denyPaths: this.policy.denyPaths ?? [],
4715
+ skipDirs: search?.skipDirs ?? DEFAULT_SEARCH_SKIP_DIRS,
4716
+ gitignore: search?.gitignore ?? true,
4717
+ maxFiles: search?.maxFiles ?? 5e4,
4718
+ concurrency: Math.max(1, search?.concurrency ?? 16),
4719
+ ...signal ? { signal } : {}
4720
+ });
4337
4721
  }
4338
4722
  /** Whether an absolute path is a directory, by attempting to list it. */
4339
4723
  async isDirectory(absPath) {
@@ -4572,14 +4956,29 @@ var Ops = class {
4572
4956
  const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
4573
4957
  const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
4574
4958
  const interpreter = this.resolvedShell.interpreter;
4575
- const result = await this.backend.exec(command, {
4576
- cwd: this.policy.cwd,
4577
- timeoutMs,
4578
- env: buildCommandEnv(this.policy),
4579
- ...signal ? { signal } : {},
4580
- ...interpreter !== "none" ? { interpreter } : {},
4581
- ...interpreter !== "none" && this.resolvedShell.file !== void 0 ? { interpreterPath: this.resolvedShell.file } : {}
4582
- });
4959
+ const env = buildCommandEnv(this.policy);
4960
+ const live = ShellOutputStreamer.create(this.policy.hooks, {
4961
+ command,
4962
+ ...opts?.callId !== void 0 ? { callId: opts.callId } : {}
4963
+ }, this.policy.shell?.env);
4964
+ let result;
4965
+ try {
4966
+ result = await this.backend.exec(command, {
4967
+ cwd: this.policy.cwd,
4968
+ timeoutMs,
4969
+ env,
4970
+ ...live ? { onOutput: (stream, chunk) => live.push(stream, chunk) } : {},
4971
+ ...signal ? { signal } : {},
4972
+ ...interpreter !== "none" ? { interpreter } : {},
4973
+ ...interpreter !== "none" && this.resolvedShell.file !== void 0 ? { interpreterPath: this.resolvedShell.file } : {}
4974
+ });
4975
+ if (live !== void 0 && !live.received) {
4976
+ live.push("stdout", result.stdout);
4977
+ live.push("stderr", result.stderr);
4978
+ }
4979
+ } finally {
4980
+ live?.end();
4981
+ }
4583
4982
  const stdout = await this.fitStream("run_shell-stdout", result.stdout, maxOutputBytes);
4584
4983
  const stderr = await this.fitStream("run_shell-stderr", result.stderr, maxOutputBytes);
4585
4984
  const cancelled = result.cancelled === true;
@@ -4619,13 +5018,24 @@ var Ops = class {
4619
5018
  retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
4620
5019
  maxJobs: background.maxJobs ?? 4,
4621
5020
  ...timeoutMs !== void 0 ? { timeoutMs } : {},
4622
- launch: (onOutput) => start(command, {
4623
- cwd: this.policy.cwd,
4624
- env,
4625
- ...interpreter !== "none" ? { interpreter } : {},
4626
- ...interpreterPath !== void 0 ? { interpreterPath } : {},
4627
- onOutput
4628
- })
5021
+ launch: (onOutput, jobId) => {
5022
+ const live = ShellOutputStreamer.create(this.policy.hooks, {
5023
+ command,
5024
+ jobId
5025
+ }, this.policy.shell?.env);
5026
+ const handle = start(command, {
5027
+ cwd: this.policy.cwd,
5028
+ env,
5029
+ ...interpreter !== "none" ? { interpreter } : {},
5030
+ ...interpreterPath !== void 0 ? { interpreterPath } : {},
5031
+ onOutput: (stream, chunk) => {
5032
+ onOutput(stream, chunk);
5033
+ live?.push(stream, chunk);
5034
+ }
5035
+ });
5036
+ handle.done.then(() => live?.end());
5037
+ return handle;
5038
+ }
4629
5039
  });
4630
5040
  }
4631
5041
  async shellOutput(id, opts) {
@@ -4760,9 +5170,11 @@ var Ops = class {
4760
5170
  const flags = opts?.ignoreCase ? "i" : "";
4761
5171
  const regex = new RegExp(pattern, flags);
4762
5172
  const globRegex = opts?.glob ? globToRegExp(opts.glob) : void 0;
4763
- const files = await this.walkFiles(jailRoot);
5173
+ const walked = await this.searchFiles(jailRoot, opts?.glob, opts?.signal);
5174
+ const files = walked.files.sort();
4764
5175
  const matches = [];
4765
5176
  for (const absFile of files) {
5177
+ opts?.signal?.throwIfAborted();
4766
5178
  const relativePath = node_path.default.relative(jailRoot, absFile).split(node_path.default.sep).join("/");
4767
5179
  if (globRegex && !globRegex.test(relativePath)) continue;
4768
5180
  if (this.isDenied(relativePath)) continue;
@@ -4782,14 +5194,22 @@ var Ops = class {
4782
5194
  line: index + 1,
4783
5195
  text: line
4784
5196
  });
4785
- if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return this.fitMatches(matches);
5197
+ if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return this.fitMatches(matches, walked.truncated);
4786
5198
  }
4787
5199
  }
4788
5200
  }
4789
- return this.fitMatches(matches);
5201
+ return this.fitMatches(matches, walked.truncated);
4790
5202
  }
4791
5203
  /** Apply the `output` limits to a finished match list. */
4792
- async fitMatches(matches) {
5204
+ async fitMatches(matches, maxFilesReached) {
5205
+ const result = await this.fitMatchesWithin(matches);
5206
+ return maxFilesReached ? {
5207
+ ...result,
5208
+ truncated: true,
5209
+ maxFilesReached: true
5210
+ } : result;
5211
+ }
5212
+ async fitMatchesWithin(matches) {
4793
5213
  const fitted = await this.spiller.list("grep", matches, (match) => `${match.path}:${match.line}:${match.text}`, "head");
4794
5214
  return fitted.truncation === void 0 ? {
4795
5215
  matches,
@@ -4802,27 +5222,42 @@ var Ops = class {
4802
5222
  ...spilledField({ stdout: fitted.truncation })
4803
5223
  };
4804
5224
  }
4805
- async globResult(pattern) {
4806
- const all = await this.glob(pattern);
5225
+ async globResult(pattern, opts) {
5226
+ const { paths: all, maxFilesReached } = await this.globWalk(pattern, opts?.signal);
4807
5227
  const fitted = await this.spiller.list("glob", all, (entry) => entry, "head");
4808
- return fitted.truncation === void 0 ? { paths: all } : {
5228
+ const reached = maxFilesReached ? {
5229
+ truncated: true,
5230
+ maxFilesReached: true
5231
+ } : {};
5232
+ return fitted.truncation === void 0 ? {
5233
+ paths: all,
5234
+ ...reached
5235
+ } : {
4809
5236
  paths: fitted.items,
4810
5237
  truncated: true,
4811
5238
  truncation: fitted.truncation,
4812
- ...spilledField({ stdout: fitted.truncation })
5239
+ ...spilledField({ stdout: fitted.truncation }),
5240
+ ...reached
4813
5241
  };
4814
5242
  }
4815
- async glob(pattern) {
5243
+ async glob(pattern, opts) {
5244
+ return (await this.globWalk(pattern, opts?.signal)).paths;
5245
+ }
5246
+ /** Match `pattern` against the walked files; sorted, with whether the walk hit `maxFiles`. */
5247
+ async globWalk(pattern, signal) {
4816
5248
  const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
4817
5249
  const regex = globToRegExp(pattern);
4818
- const files = await this.walkFiles(jailRoot);
5250
+ const walked = await this.searchFiles(jailRoot, pattern, signal);
4819
5251
  const matched = [];
4820
- for (const absFile of files) {
5252
+ for (const absFile of walked.files) {
4821
5253
  const relativePath = node_path.default.relative(jailRoot, absFile).split(node_path.default.sep).join("/");
4822
5254
  if (this.isDenied(relativePath)) continue;
4823
5255
  if (regex.test(relativePath)) matched.push(relativePath);
4824
5256
  }
4825
- return matched.sort();
5257
+ return {
5258
+ paths: matched.sort(),
5259
+ maxFilesReached: walked.truncated
5260
+ };
4826
5261
  }
4827
5262
  async exists(inputPath) {
4828
5263
  const { absolutePath } = await resolveInJail(this.policy, inputPath);
@@ -5112,6 +5547,13 @@ var LocalBackend = class {
5112
5547
  async list(absDir) {
5113
5548
  return _warlock_js_fs.fs.dirs.list(absDir);
5114
5549
  }
5550
+ /** List children with their kinds from one directory read (symlinks not followed). */
5551
+ async listTyped(absDir) {
5552
+ return (await (0, node_fs_promises.readdir)(absDir, { withFileTypes: true })).map((entry) => ({
5553
+ path: node_path.default.join(absDir, entry.name),
5554
+ type: entry.isSymbolicLink() ? "symlink" : entry.isDirectory() ? "directory" : entry.isFile() ? "file" : "other"
5555
+ }));
5556
+ }
5115
5557
  /**
5116
5558
  * Describe a path without following a final symlink (`lstat` — like
5117
5559
  * `realpath`, a filesystem operation `@warlock.js/fs` does not expose).
@@ -5212,6 +5654,10 @@ var LocalBackend = class {
5212
5654
  settled = true;
5213
5655
  if (timer !== void 0) clearTimeout(timer);
5214
5656
  signal?.removeEventListener("abort", onAbort);
5657
+ for (const stream of ["stdout", "stderr"]) {
5658
+ const tail = liveDecoders[stream].end();
5659
+ if (tail !== "") opts.onOutput?.(stream, tail);
5660
+ }
5215
5661
  resolve({
5216
5662
  exitCode,
5217
5663
  stdout: Buffer.concat(stdoutChunks).toString("utf8"),
@@ -5220,11 +5666,22 @@ var LocalBackend = class {
5220
5666
  cancelled
5221
5667
  });
5222
5668
  };
5669
+ const liveDecoders = {
5670
+ stdout: new node_string_decoder.StringDecoder("utf8"),
5671
+ stderr: new node_string_decoder.StringDecoder("utf8")
5672
+ };
5673
+ const live = (stream, chunk) => {
5674
+ if (opts.onOutput === void 0 || settled) return;
5675
+ const text = liveDecoders[stream].write(chunk);
5676
+ if (text !== "") opts.onOutput(stream, text);
5677
+ };
5223
5678
  child.stdout?.on("data", (chunk) => {
5224
5679
  stdoutBytes = pushCapped(stdoutChunks, stdoutBytes, chunk);
5680
+ live("stdout", chunk);
5225
5681
  });
5226
5682
  child.stderr?.on("data", (chunk) => {
5227
5683
  stderrBytes = pushCapped(stderrChunks, stderrBytes, chunk);
5684
+ live("stderr", chunk);
5228
5685
  });
5229
5686
  child.on("error", () => {
5230
5687
  settle(127);
@@ -5970,12 +6427,13 @@ const globInputSchema = objectSchema({ pattern: stringField() }, GLOB_JSON_SCHEM
5970
6427
  function makeGlobTool(ops, options) {
5971
6428
  return (0, _warlock_js_ai.tool)({
5972
6429
  name: options?.name ?? "glob",
5973
- description: "Find files in the workspace whose path matches a glob pattern (supports `*`, `**`, and `?`). Returns the matching workspace-relative paths, sorted.",
6430
+ description: "Find files in the workspace whose path matches a glob pattern (supports `*`, `**`, and `?`). Returns the matching workspace-relative paths, sorted. Skips node_modules, .git and gitignored folders; start the pattern with a folder (`src/**/*.ts`) to search faster.",
5974
6431
  action: (input) => `Finding files matching ${input.pattern}`,
5975
6432
  input: globInputSchema,
5976
- async execute(input) {
5977
- if (ops.globResult !== void 0) return ops.globResult(input.pattern);
5978
- return { paths: await ops.glob(input.pattern) };
6433
+ async execute(input, ctx) {
6434
+ const opts = ctx?.signal ? { signal: ctx.signal } : void 0;
6435
+ if (ops.globResult !== void 0) return ops.globResult(input.pattern, opts);
6436
+ return { paths: await ops.glob(input.pattern, opts) };
5979
6437
  }
5980
6438
  });
5981
6439
  }
@@ -6017,10 +6475,11 @@ function makeGrepTool(ops, options) {
6017
6475
  description: "Search file contents across the workspace for a regular-expression pattern. Optionally narrow the scanned files with a glob and match case-insensitively. Returns every matching line with its file path and 1-based line number.",
6018
6476
  action: (input) => `Searching for /${input.pattern}/`,
6019
6477
  input: grepInputSchema,
6020
- async execute(input) {
6478
+ async execute(input, ctx) {
6021
6479
  return ops.grep(input.pattern, {
6022
6480
  glob: input.glob,
6023
- ignoreCase: input.ignoreCase
6481
+ ignoreCase: input.ignoreCase,
6482
+ ...ctx?.signal ? { signal: ctx.signal } : {}
6024
6483
  });
6025
6484
  }
6026
6485
  });
@@ -6276,6 +6735,7 @@ const DIALECT_RULES = {
6276
6735
  powershell: "Use PowerShell syntax: `;`, `&&`, `||`, pipes, `$( )`, the call operator `&` and the redirections `>`, `>>`, `2>` and `2>&1` are supported, and every command in the line is checked (aliases such as `rm` and `Remove-Item` are treated alike). Script blocks, `Invoke-Expression`, here-strings, splatting and `if` / `foreach` are not.",
6277
6736
  powershell5: "Use Windows PowerShell 5.1 syntax: `;`, pipes, `$( )`, the call operator `&` and the redirections `>`, `>>`, `2>` and `2>&1` are supported, and every command in the line is checked (aliases such as `rm` and `Remove-Item` are treated alike). This version cannot chain with `&&` or `||`: put one command per call, or join with `;` (which runs the next command whatever the exit code). Script blocks, `Invoke-Expression`, here-strings, splatting and `if` / `foreach` are not supported."
6278
6737
  };
6738
+ const WINDOWS_BASH_PATH_RULE = " Write paths with forward slashes (`cd ab/terra4/client`, `C:/work/app`): bash reads an unquoted backslash as an escape, so a line with `ab\\terra4` is refused.";
6279
6739
  const PLATFORM_NAMES = {
6280
6740
  win32: "Windows",
6281
6741
  darwin: "macOS",
@@ -6286,7 +6746,7 @@ function shellSentence(options) {
6286
6746
  const dialect = options?.shellDialect;
6287
6747
  if (options?.shellLabel === void 0 || dialect === void 0 || dialect === "none") return "";
6288
6748
  const platform = options.platform ?? process.platform;
6289
- return ` Commands run on ${PLATFORM_NAMES[platform] ?? platform} using ${options.shellLabel}. ` + DIALECT_RULES[dialect];
6749
+ return ` Commands run on ${PLATFORM_NAMES[platform] ?? platform} using ${options.shellLabel}. ` + DIALECT_RULES[dialect] + (platform === "win32" && options.shellLabel === "bash" ? WINDOWS_BASH_PATH_RULE : "");
6290
6750
  }
6291
6751
  /** The lower of the model's timeout and the cap (either may be absent). */
6292
6752
  function cappedTimeout(requested, cap) {
@@ -6924,8 +7384,8 @@ var WorkspaceImpl = class WorkspaceImpl {
6924
7384
  grep(pattern, opts) {
6925
7385
  return this.ops.grep(pattern, opts);
6926
7386
  }
6927
- glob(pattern) {
6928
- return this.ops.glob(pattern);
7387
+ glob(pattern, opts) {
7388
+ return this.ops.glob(pattern, opts);
6929
7389
  }
6930
7390
  exists(filePath) {
6931
7391
  return this.ops.exists(filePath);