@wrongstack/tools 0.296.4 → 0.298.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 (258) hide show
  1. package/dist/_spawn-stream.d.ts +2 -0
  2. package/dist/_util.d.ts +29 -1
  3. package/dist/audit.js +128 -65
  4. package/dist/bash.js +20 -4
  5. package/dist/batch-tool-use.js +70 -7
  6. package/dist/browser/index.js +31 -1
  7. package/dist/builtin.js +883 -351
  8. package/dist/codebase-index/index.js +95 -16
  9. package/dist/codebase-index/project-server-protocol.d.ts +26 -0
  10. package/dist/codebase-index/project-server.js +76 -16
  11. package/dist/codebase-index/worker.js +36 -13
  12. package/dist/codebase-index/writer-search-helpers.d.ts +16 -0
  13. package/dist/codebase-index/writer.d.ts +13 -1
  14. package/dist/design.d.ts +1 -1
  15. package/dist/design.js +4 -3
  16. package/dist/diff.js +11 -1
  17. package/dist/e2e.js +9 -6
  18. package/dist/edit.js +9 -6
  19. package/dist/exec.js +562 -516
  20. package/dist/format.js +131 -65
  21. package/dist/git.js +22 -2
  22. package/dist/glob.js +11 -5
  23. package/dist/grep.js +160 -104
  24. package/dist/index.js +834 -290
  25. package/dist/install.js +135 -68
  26. package/dist/json.js +64 -18
  27. package/dist/kanban-board-inputs.d.ts +21 -4
  28. package/dist/kanban.js +20 -5
  29. package/dist/languages/index.js +143 -71
  30. package/dist/lint.js +128 -65
  31. package/dist/next-steps.d.ts +23 -0
  32. package/dist/next-steps.js +4 -0
  33. package/dist/outdated.js +101 -26
  34. package/dist/pack.js +883 -351
  35. package/dist/patch.js +11 -1
  36. package/dist/plan.js +32 -10
  37. package/dist/process-registry.d.ts +9 -0
  38. package/dist/process-registry.js +15 -0
  39. package/dist/ps-slash.js +15 -0
  40. package/dist/read.js +104 -23
  41. package/dist/replace.js +11 -3
  42. package/dist/scaffold.js +9 -6
  43. package/dist/session-kanban.js +21 -5
  44. package/dist/task.js +3 -1
  45. package/dist/test.js +128 -65
  46. package/dist/todo.js +3 -1
  47. package/dist/tool-tier.js +883 -351
  48. package/dist/tool-use.d.ts +1 -1
  49. package/dist/tool-use.js +28 -4
  50. package/dist/tree.d.ts +1 -0
  51. package/dist/tree.js +44 -8
  52. package/dist/typecheck.js +128 -65
  53. package/dist/write.js +9 -6
  54. package/package.json +5 -4
  55. package/dist/_concurrency.d.ts.map +0 -1
  56. package/dist/_danger-detect.d.ts.map +0 -1
  57. package/dist/_edit-match.d.ts.map +0 -1
  58. package/dist/_env.d.ts.map +0 -1
  59. package/dist/_fetch-guard.d.ts.map +0 -1
  60. package/dist/_output-spool.d.ts.map +0 -1
  61. package/dist/_redact-command.d.ts.map +0 -1
  62. package/dist/_regex.d.ts.map +0 -1
  63. package/dist/_session-shell.d.ts.map +0 -1
  64. package/dist/_shell-pick.d.ts.map +0 -1
  65. package/dist/_spawn-stream.d.ts.map +0 -1
  66. package/dist/_syntax-check.d.ts.map +0 -1
  67. package/dist/_util.d.ts.map +0 -1
  68. package/dist/_win32-resolve.d.ts.map +0 -1
  69. package/dist/audit.d.ts.map +0 -1
  70. package/dist/audit.js.map +0 -7
  71. package/dist/auto-proceed-loop-guard.d.ts.map +0 -1
  72. package/dist/auto-proceed-loop-guard.js.map +0 -7
  73. package/dist/bash-kill-guard.d.ts.map +0 -1
  74. package/dist/bash.d.ts.map +0 -1
  75. package/dist/bash.js.map +0 -7
  76. package/dist/batch-tool-use.d.ts.map +0 -1
  77. package/dist/batch-tool-use.js.map +0 -7
  78. package/dist/browser/artifacts.d.ts.map +0 -1
  79. package/dist/browser/index.d.ts.map +0 -1
  80. package/dist/browser/index.js.map +0 -7
  81. package/dist/browser/manager.d.ts.map +0 -1
  82. package/dist/browser/network-guard-proxy.d.ts.map +0 -1
  83. package/dist/browser/security.d.ts.map +0 -1
  84. package/dist/browser/tools.d.ts.map +0 -1
  85. package/dist/browser/types.d.ts.map +0 -1
  86. package/dist/builtin.d.ts.map +0 -1
  87. package/dist/builtin.js.map +0 -7
  88. package/dist/circuit-breaker.d.ts.map +0 -1
  89. package/dist/circuit-breaker.js.map +0 -7
  90. package/dist/codebase-index/background-indexer.d.ts.map +0 -1
  91. package/dist/codebase-index/bm25.d.ts.map +0 -1
  92. package/dist/codebase-index/circuit-breaker.d.ts.map +0 -1
  93. package/dist/codebase-index/codebase-index-tool.d.ts.map +0 -1
  94. package/dist/codebase-index/codebase-search-tool.d.ts.map +0 -1
  95. package/dist/codebase-index/codebase-stats-tool.d.ts.map +0 -1
  96. package/dist/codebase-index/dead-code-scan.d.ts.map +0 -1
  97. package/dist/codebase-index/generic-parser.d.ts.map +0 -1
  98. package/dist/codebase-index/gitignore.d.ts.map +0 -1
  99. package/dist/codebase-index/go-parser.d.ts.map +0 -1
  100. package/dist/codebase-index/index-service.d.ts.map +0 -1
  101. package/dist/codebase-index/index.d.ts.map +0 -1
  102. package/dist/codebase-index/index.js.map +0 -7
  103. package/dist/codebase-index/indexer.d.ts.map +0 -1
  104. package/dist/codebase-index/json-parser.d.ts.map +0 -1
  105. package/dist/codebase-index/languages.d.ts.map +0 -1
  106. package/dist/codebase-index/lsp-kind.d.ts.map +0 -1
  107. package/dist/codebase-index/parser-dispatch.d.ts.map +0 -1
  108. package/dist/codebase-index/project-server-cache.d.ts.map +0 -1
  109. package/dist/codebase-index/project-server-client.d.ts.map +0 -1
  110. package/dist/codebase-index/project-server-endpoint.d.ts.map +0 -1
  111. package/dist/codebase-index/project-server-protocol.d.ts.map +0 -1
  112. package/dist/codebase-index/project-server.d.ts.map +0 -1
  113. package/dist/codebase-index/project-server.js.map +0 -7
  114. package/dist/codebase-index/py-parser.d.ts.map +0 -1
  115. package/dist/codebase-index/refs-extractor.d.ts.map +0 -1
  116. package/dist/codebase-index/rs-parser.d.ts.map +0 -1
  117. package/dist/codebase-index/schema.d.ts.map +0 -1
  118. package/dist/codebase-index/spawn-gate.d.ts.map +0 -1
  119. package/dist/codebase-index/sqlite-runtime.d.ts.map +0 -1
  120. package/dist/codebase-index/ts-parser.d.ts.map +0 -1
  121. package/dist/codebase-index/worker-protocol.d.ts.map +0 -1
  122. package/dist/codebase-index/worker.d.ts.map +0 -1
  123. package/dist/codebase-index/worker.js.map +0 -7
  124. package/dist/codebase-index/writer-admin.d.ts.map +0 -1
  125. package/dist/codebase-index/writer-bulk-insert.d.ts.map +0 -1
  126. package/dist/codebase-index/writer-graph-helpers.d.ts.map +0 -1
  127. package/dist/codebase-index/writer-graph-reader.d.ts.map +0 -1
  128. package/dist/codebase-index/writer-helpers.d.ts.map +0 -1
  129. package/dist/codebase-index/writer-pragmas.d.ts.map +0 -1
  130. package/dist/codebase-index/writer-ref-mapper.d.ts.map +0 -1
  131. package/dist/codebase-index/writer-schema.d.ts.map +0 -1
  132. package/dist/codebase-index/writer-search-helpers.d.ts.map +0 -1
  133. package/dist/codebase-index/writer-store-pool.d.ts.map +0 -1
  134. package/dist/codebase-index/writer.d.ts.map +0 -1
  135. package/dist/codebase-index/yaml-parser.d.ts.map +0 -1
  136. package/dist/design.d.ts.map +0 -1
  137. package/dist/design.js.map +0 -7
  138. package/dist/diff.d.ts.map +0 -1
  139. package/dist/diff.js.map +0 -7
  140. package/dist/document.d.ts.map +0 -1
  141. package/dist/document.js.map +0 -7
  142. package/dist/e2e.d.ts.map +0 -1
  143. package/dist/e2e.js.map +0 -7
  144. package/dist/edit.d.ts.map +0 -1
  145. package/dist/edit.js.map +0 -7
  146. package/dist/exec-kill-guard.d.ts.map +0 -1
  147. package/dist/exec.d.ts.map +0 -1
  148. package/dist/exec.js.map +0 -7
  149. package/dist/fetch.d.ts.map +0 -1
  150. package/dist/fetch.js.map +0 -7
  151. package/dist/format.d.ts.map +0 -1
  152. package/dist/format.js.map +0 -7
  153. package/dist/git.d.ts.map +0 -1
  154. package/dist/git.js.map +0 -7
  155. package/dist/glob.d.ts.map +0 -1
  156. package/dist/glob.js.map +0 -7
  157. package/dist/grep.d.ts.map +0 -1
  158. package/dist/grep.js.map +0 -7
  159. package/dist/index.d.ts.map +0 -1
  160. package/dist/index.js.map +0 -7
  161. package/dist/install.d.ts.map +0 -1
  162. package/dist/install.js.map +0 -7
  163. package/dist/json.d.ts.map +0 -1
  164. package/dist/json.js.map +0 -7
  165. package/dist/kanban-board-inputs.d.ts.map +0 -1
  166. package/dist/kanban-decomposition-actions.d.ts.map +0 -1
  167. package/dist/kanban-detail-actions.d.ts.map +0 -1
  168. package/dist/kanban-evidence-bridge.d.ts.map +0 -1
  169. package/dist/kanban-presence.d.ts.map +0 -1
  170. package/dist/kanban-split-task-handler.d.ts.map +0 -1
  171. package/dist/kanban-task-inputs.d.ts.map +0 -1
  172. package/dist/kanban-tool-results.d.ts.map +0 -1
  173. package/dist/kanban-tool-schema.d.ts.map +0 -1
  174. package/dist/kanban-tool-types.d.ts.map +0 -1
  175. package/dist/kanban.d.ts.map +0 -1
  176. package/dist/kanban.js.map +0 -7
  177. package/dist/languages/detect.d.ts.map +0 -1
  178. package/dist/languages/diagnostics.d.ts.map +0 -1
  179. package/dist/languages/execute-tool.d.ts.map +0 -1
  180. package/dist/languages/execute.d.ts.map +0 -1
  181. package/dist/languages/index.d.ts.map +0 -1
  182. package/dist/languages/index.js.map +0 -7
  183. package/dist/languages/legacy-bridge.d.ts.map +0 -1
  184. package/dist/languages/package-tool.d.ts.map +0 -1
  185. package/dist/languages/plan.d.ts.map +0 -1
  186. package/dist/languages/profile-helpers.d.ts.map +0 -1
  187. package/dist/languages/profiles/additional.d.ts.map +0 -1
  188. package/dist/languages/profiles/primary.d.ts.map +0 -1
  189. package/dist/languages/registry.d.ts.map +0 -1
  190. package/dist/languages/tool.d.ts.map +0 -1
  191. package/dist/languages/types.d.ts.map +0 -1
  192. package/dist/lint.d.ts.map +0 -1
  193. package/dist/lint.js.map +0 -7
  194. package/dist/logs.d.ts.map +0 -1
  195. package/dist/logs.js.map +0 -7
  196. package/dist/memory.d.ts.map +0 -1
  197. package/dist/memory.js.map +0 -7
  198. package/dist/mode.d.ts.map +0 -1
  199. package/dist/mode.js.map +0 -7
  200. package/dist/next-steps.d.ts.map +0 -1
  201. package/dist/next-steps.js.map +0 -7
  202. package/dist/outdated.d.ts.map +0 -1
  203. package/dist/outdated.js.map +0 -7
  204. package/dist/pack.d.ts.map +0 -1
  205. package/dist/pack.js.map +0 -7
  206. package/dist/patch.d.ts.map +0 -1
  207. package/dist/patch.js.map +0 -7
  208. package/dist/plan.d.ts.map +0 -1
  209. package/dist/plan.js.map +0 -7
  210. package/dist/process-guardian.d.ts.map +0 -1
  211. package/dist/process-registry-persistent.d.ts.map +0 -1
  212. package/dist/process-registry.d.ts.map +0 -1
  213. package/dist/process-registry.js.map +0 -7
  214. package/dist/ps-slash.d.ts.map +0 -1
  215. package/dist/ps-slash.js.map +0 -7
  216. package/dist/read.d.ts.map +0 -1
  217. package/dist/read.js.map +0 -7
  218. package/dist/replace.d.ts.map +0 -1
  219. package/dist/replace.js.map +0 -7
  220. package/dist/scaffold.d.ts.map +0 -1
  221. package/dist/scaffold.js.map +0 -7
  222. package/dist/search.d.ts.map +0 -1
  223. package/dist/search.js.map +0 -7
  224. package/dist/session-kanban.d.ts.map +0 -1
  225. package/dist/session-kanban.js.map +0 -7
  226. package/dist/set-working-dir.d.ts.map +0 -1
  227. package/dist/skill.d.ts.map +0 -1
  228. package/dist/skill.js.map +0 -7
  229. package/dist/spawn-background.d.ts.map +0 -1
  230. package/dist/task.d.ts.map +0 -1
  231. package/dist/task.js.map +0 -7
  232. package/dist/test.d.ts.map +0 -1
  233. package/dist/test.js.map +0 -7
  234. package/dist/todo.d.ts.map +0 -1
  235. package/dist/todo.js.map +0 -7
  236. package/dist/tool-diff.d.ts.map +0 -1
  237. package/dist/tool-diff.js.map +0 -7
  238. package/dist/tool-help.d.ts.map +0 -1
  239. package/dist/tool-help.js.map +0 -7
  240. package/dist/tool-icon-map.d.ts.map +0 -1
  241. package/dist/tool-icons.d.ts.map +0 -1
  242. package/dist/tool-icons.js.map +0 -7
  243. package/dist/tool-search.d.ts.map +0 -1
  244. package/dist/tool-search.js.map +0 -7
  245. package/dist/tool-summary.d.ts.map +0 -1
  246. package/dist/tool-summary.js.map +0 -7
  247. package/dist/tool-tier.d.ts.map +0 -1
  248. package/dist/tool-tier.js.map +0 -7
  249. package/dist/tool-use.d.ts.map +0 -1
  250. package/dist/tool-use.js.map +0 -7
  251. package/dist/tree.d.ts.map +0 -1
  252. package/dist/tree.js.map +0 -7
  253. package/dist/typecheck.d.ts.map +0 -1
  254. package/dist/typecheck.js.map +0 -7
  255. package/dist/win32.d.ts.map +0 -1
  256. package/dist/win32.js.map +0 -7
  257. package/dist/write.d.ts.map +0 -1
  258. package/dist/write.js.map +0 -7
package/dist/index.js CHANGED
@@ -148,11 +148,12 @@ function createOutputSpool(opts) {
148
148
  return {
149
149
  write(text) {
150
150
  if (finalized || !text) return;
151
- totalBytes += Buffer.byteLength(text, "utf8");
151
+ const textBytes = Buffer.byteLength(text, "utf8");
152
+ totalBytes += textBytes;
152
153
  if (!stream && !failed) {
153
- if (headBytes + text.length <= threshold) {
154
+ if (headBytes + textBytes <= threshold) {
154
155
  head += text;
155
- headBytes += text.length;
156
+ headBytes += textBytes;
156
157
  return;
157
158
  }
158
159
  head += text;
@@ -162,7 +163,7 @@ function createOutputSpool(opts) {
162
163
  }
163
164
  if (stream) {
164
165
  if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
165
- droppedBytes += Buffer.byteLength(text, "utf8");
166
+ droppedBytes += textBytes;
166
167
  return;
167
168
  }
168
169
  stream.write(text);
@@ -549,6 +550,7 @@ var init_process_registry = __esm({
549
550
  }
550
551
  /** Get all tracked processes. */
551
552
  list() {
553
+ this._pruneAllStale();
552
554
  return Array.from(this.processes.values());
553
555
  }
554
556
  /** Get processes filtered by name (e.g. 'bash', 'exec'). */
@@ -579,6 +581,7 @@ var init_process_registry = __esm({
579
581
  * Combined stats for observability — used by /ps and the TUI status bar.
580
582
  */
581
583
  stats() {
584
+ this._pruneAllStale();
582
585
  return {
583
586
  activeCount: this.activeCount,
584
587
  backgroundCount: this.activeBackgroundCount,
@@ -818,6 +821,19 @@ var init_process_registry = __esm({
818
821
  this.processes.delete(pid);
819
822
  }
820
823
  }
824
+ /**
825
+ * Remove every stale entry, not just one PID. `list()`/`stats()` — the
826
+ * surfaces the TUI status bar and `/ps` poll — must prune too: a child
827
+ * whose 'close' event never fires (e.g. Windows grandchildren holding stdio
828
+ * open) would otherwise linger in the registry until someone looks up its
829
+ * exact PID, and PID reuse meanwhile makes `get()`/`kill()` target the
830
+ * wrong process. RAM-leak audit 2026-07-31, MEDIUM.
831
+ */
832
+ _pruneAllStale() {
833
+ for (const [pid, entry] of this.processes) {
834
+ if (this._isStaleEntry(entry)) this.processes.delete(pid);
835
+ }
836
+ }
821
837
  };
822
838
  }
823
839
  });
@@ -834,8 +850,11 @@ async function* spawnStream(opts) {
834
850
  const max = opts.maxBytes ?? 2e5;
835
851
  const flushAt = opts.flushBytes ?? 4 * 1024;
836
852
  const maxQueue = opts.maxQueueSize ?? 500;
853
+ const maxQueueBytes = opts.maxQueueBytes ?? 1024 * 1024;
837
854
  let stdout = "";
838
855
  let stderr = "";
856
+ let stdoutRetainedBytes = 0;
857
+ let stderrRetainedBytes = 0;
839
858
  let pending2 = "";
840
859
  let error;
841
860
  const spool = createOutputSpool({ tool: opts.cmd, thresholdBytes: max });
@@ -877,6 +896,7 @@ async function* spawnStream(opts) {
877
896
  });
878
897
  }
879
898
  const queue = [];
899
+ let queuedBytes = 0;
880
900
  let waiter;
881
901
  let paused = false;
882
902
  const wake = () => {
@@ -887,7 +907,7 @@ async function* spawnStream(opts) {
887
907
  }
888
908
  };
889
909
  const resume = () => {
890
- if (paused && queue.length < maxQueue) {
910
+ if (paused && queue.length <= Math.floor(maxQueue / 2) && queuedBytes <= Math.floor(maxQueueBytes / 2)) {
891
911
  paused = false;
892
912
  child.stdout?.resume();
893
913
  child.stderr?.resume();
@@ -897,11 +917,16 @@ async function* spawnStream(opts) {
897
917
  const s = c.toString();
898
918
  stdoutBytes += c.byteLength;
899
919
  emitProcessOutput({ pid, stream: "stdout", chunk: c });
900
- if (stdout.length < max) stdout += s;
920
+ if (stdoutRetainedBytes < max) {
921
+ const retained = utf8Prefix(s, max - stdoutRetainedBytes);
922
+ stdout += retained;
923
+ stdoutRetainedBytes += Buffer.byteLength(retained, "utf8");
924
+ }
901
925
  spool.write(s);
902
- queue.push({ kind: "out", data: s });
926
+ queue.push({ kind: "out", data: s, bytes: c.byteLength });
927
+ queuedBytes += c.byteLength;
903
928
  wake();
904
- if (!paused && queue.length >= maxQueue) {
929
+ if (!paused && (queue.length >= maxQueue || queuedBytes >= maxQueueBytes)) {
905
930
  paused = true;
906
931
  child.stdout?.pause();
907
932
  child.stderr?.pause();
@@ -911,11 +936,16 @@ async function* spawnStream(opts) {
911
936
  const s = c.toString();
912
937
  stderrBytes += c.byteLength;
913
938
  emitProcessOutput({ pid, stream: "stderr", chunk: c });
914
- if (stderr.length < max) stderr += s;
939
+ if (stderrRetainedBytes < max) {
940
+ const retained = utf8Prefix(s, max - stderrRetainedBytes);
941
+ stderr += retained;
942
+ stderrRetainedBytes += Buffer.byteLength(retained, "utf8");
943
+ }
915
944
  spool.write(s);
916
- queue.push({ kind: "err", data: s });
945
+ queue.push({ kind: "err", data: s, bytes: c.byteLength });
946
+ queuedBytes += c.byteLength;
917
947
  wake();
918
- if (!paused && queue.length >= maxQueue) {
948
+ if (!paused && (queue.length >= maxQueue || queuedBytes >= maxQueueBytes)) {
919
949
  paused = true;
920
950
  child.stdout?.pause();
921
951
  child.stderr?.pause();
@@ -925,7 +955,9 @@ async function* spawnStream(opts) {
925
955
  child.stderr?.on("data", onErr);
926
956
  child.on("error", (e) => {
927
957
  error = e.message;
928
- queue.push({ kind: "error", data: e.message });
958
+ const bytes = Buffer.byteLength(e.message, "utf8");
959
+ queue.push({ kind: "error", data: e.message, bytes });
960
+ queuedBytes += bytes;
929
961
  wake();
930
962
  });
931
963
  const completeTelemetry = (code, signal, timedOut = false) => {
@@ -946,7 +978,13 @@ async function* spawnStream(opts) {
946
978
  if (typeof pid === "number") registry.unregister(pid);
947
979
  const exitCode2 = code ?? (signal ? 1 : 0);
948
980
  completeTelemetry(exitCode2, signal ?? void 0);
949
- queue.push({ kind: "close", data: "", code: exitCode2, ...signal ? { signal } : {} });
981
+ queue.push({
982
+ kind: "close",
983
+ data: "",
984
+ bytes: 0,
985
+ code: exitCode2,
986
+ ...signal ? { signal } : {}
987
+ });
950
988
  wake();
951
989
  });
952
990
  const onAbort = () => {
@@ -958,7 +996,7 @@ async function* spawnStream(opts) {
958
996
  } catch {
959
997
  }
960
998
  }
961
- queue.push({ kind: "close", data: "", code: 124 });
999
+ queue.push({ kind: "close", data: "", bytes: 0, code: 124 });
962
1000
  completeTelemetry(124, "SIGKILL", true);
963
1001
  wake();
964
1002
  };
@@ -976,6 +1014,7 @@ async function* spawnStream(opts) {
976
1014
  });
977
1015
  }
978
1016
  const chunk = queue.shift();
1017
+ queuedBytes -= chunk.bytes;
979
1018
  resume();
980
1019
  if (chunk.kind === "close") {
981
1020
  if (!spawnFailed) exitCode = chunk.code ?? 0;
@@ -1002,7 +1041,7 @@ async function* spawnStream(opts) {
1002
1041
  stdout: spooled ? stdout + spoolNote(spooled) : stdout,
1003
1042
  stderr,
1004
1043
  exitCode,
1005
- truncated: stdout.length >= max || stderr.length >= max,
1044
+ truncated: stdoutBytes > stdoutRetainedBytes || stderrBytes > stderrRetainedBytes,
1006
1045
  error,
1007
1046
  spoolPath: spooled?.path,
1008
1047
  spoolBytes: spooled?.bytes
@@ -1026,13 +1065,27 @@ async function* spawnStream(opts) {
1026
1065
  }
1027
1066
  }
1028
1067
  }
1068
+ function utf8Prefix(text, maxBytes) {
1069
+ if (maxBytes <= 0) return "";
1070
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
1071
+ let low = 0;
1072
+ let high = text.length;
1073
+ while (low < high) {
1074
+ const mid = Math.ceil((low + high) / 2);
1075
+ if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) low = mid;
1076
+ else high = mid - 1;
1077
+ }
1078
+ let end = low;
1079
+ if (end > 0 && /[\uD800-\uDBFF]/.test(text[end - 1])) end--;
1080
+ return text.slice(0, end);
1081
+ }
1029
1082
  var isWin;
1030
1083
  var init_spawn_stream = __esm({
1031
1084
  "src/_spawn-stream.ts"() {
1032
1085
  "use strict";
1033
1086
  init_output_spool();
1034
- init_process_registry();
1035
1087
  init_win32_resolve();
1088
+ init_process_registry();
1036
1089
  isWin = process.platform === "win32";
1037
1090
  }
1038
1091
  });
@@ -1046,14 +1099,14 @@ function sha256hex(content) {
1046
1099
  return createHash("sha256").update(content, "utf8").digest("hex");
1047
1100
  }
1048
1101
  async function detectPackageManager(cwd) {
1049
- const { stat: stat18 } = await import("node:fs/promises");
1102
+ const { stat: stat19 } = await import("node:fs/promises");
1050
1103
  try {
1051
- await stat18(`${cwd}/pnpm-lock.yaml`);
1104
+ await stat19(`${cwd}/pnpm-lock.yaml`);
1052
1105
  return "pnpm";
1053
1106
  } catch {
1054
1107
  }
1055
1108
  try {
1056
- await stat18(`${cwd}/yarn.lock`);
1109
+ await stat19(`${cwd}/yarn.lock`);
1057
1110
  return "yarn";
1058
1111
  } catch {
1059
1112
  }
@@ -1081,11 +1134,15 @@ function safeResolve(input, ctx) {
1081
1134
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
1082
1135
  }
1083
1136
  async function assertRealInsideRoot(absPath, ctx) {
1084
- if (ctx.allowOutsideProjectRoot) return;
1137
+ await resolveRealInsideRoot(absPath, ctx);
1138
+ }
1139
+ async function resolveRealInsideRoot(absPath, ctx) {
1140
+ if (ctx.allowOutsideProjectRoot) return absPath;
1085
1141
  const realRoots = await Promise.all(
1086
1142
  allowedRoots(ctx).map((r) => fsp2.realpath(r).catch(() => path3.resolve(r)))
1087
1143
  );
1088
1144
  let probe2 = absPath;
1145
+ const pendingTail = [];
1089
1146
  for (; ; ) {
1090
1147
  let real;
1091
1148
  try {
@@ -1093,13 +1150,16 @@ async function assertRealInsideRoot(absPath, ctx) {
1093
1150
  } catch (err) {
1094
1151
  if (err.code === "ENOENT") {
1095
1152
  const parent = path3.dirname(probe2);
1096
- if (parent === probe2) return;
1153
+ if (parent === probe2) return absPath;
1154
+ pendingTail.unshift(path3.basename(probe2));
1097
1155
  probe2 = parent;
1098
1156
  continue;
1099
1157
  }
1100
1158
  throw err;
1101
1159
  }
1102
- if (isInsideAny(real, realRoots)) return;
1160
+ if (isInsideAny(real, realRoots)) {
1161
+ return pendingTail.length > 0 ? path3.join(real, ...pendingTail) : real;
1162
+ }
1103
1163
  throw new Error(
1104
1164
  `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
1105
1165
  );
@@ -1107,8 +1167,7 @@ async function assertRealInsideRoot(absPath, ctx) {
1107
1167
  }
1108
1168
  async function safeResolveReal(input, ctx) {
1109
1169
  const abs = safeResolve(input, ctx);
1110
- await assertRealInsideRoot(abs, ctx);
1111
- return abs;
1170
+ return await resolveRealInsideRoot(abs, ctx);
1112
1171
  }
1113
1172
  function truncateMiddle(s, max) {
1114
1173
  if (Buffer.byteLength(s, "utf8") <= max) return s;
@@ -2672,8 +2731,8 @@ async function scanDirectory(directory, depth, profiles, limits, state, extraIgn
2672
2731
  collectFileEvidence(directory, fullPath, entry.name, profiles, state);
2673
2732
  }
2674
2733
  }
2675
- function collectFileEvidence(directory, fullPath, basename13, profiles, state) {
2676
- const lower = basename13.toLowerCase();
2734
+ function collectFileEvidence(directory, fullPath, basename14, profiles, state) {
2735
+ const lower = basename14.toLowerCase();
2677
2736
  const extension = path4.extname(lower);
2678
2737
  for (const profile of profiles) {
2679
2738
  const detector = profile.detectors.find(
@@ -2684,7 +2743,7 @@ function collectFileEvidence(directory, fullPath, basename13, profiles, state) {
2684
2743
  candidate.evidence.push({
2685
2744
  kind: detector.kind,
2686
2745
  path: fullPath,
2687
- value: basename13,
2746
+ value: basename14,
2688
2747
  weight: detector.weight
2689
2748
  });
2690
2749
  if (detector.kind === "manifest" || detector.kind === "config") {
@@ -2812,8 +2871,8 @@ function normalizeLimits(input) {
2812
2871
  async function canonicalDirectory(input) {
2813
2872
  const resolved = path4.resolve(input);
2814
2873
  const real = await fs2.realpath(resolved);
2815
- const stat18 = await fs2.stat(real);
2816
- if (!stat18.isDirectory()) throw new Error(`Project root is not a directory: ${input}`);
2874
+ const stat19 = await fs2.stat(real);
2875
+ if (!stat19.isDirectory()) throw new Error(`Project root is not a directory: ${input}`);
2817
2876
  return real;
2818
2877
  }
2819
2878
  async function canonicalInside(input, root, label) {
@@ -3369,6 +3428,7 @@ import * as fs3 from "node:fs/promises";
3369
3428
  import * as path6 from "node:path";
3370
3429
  async function planLanguageOperation(options) {
3371
3430
  validateOperationOptions(options.operation, options.operationOptions?.packages);
3431
+ validateFilterOption(options.operationOptions?.filter);
3372
3432
  const detection = await detectLanguageWorkspaces(options);
3373
3433
  const candidates = detection.workspaces.filter(
3374
3434
  (workspace) => workspace.capabilities.includes(options.operation)
@@ -3541,6 +3601,15 @@ function workspaceDepth(workspaceRoot, projectRoot) {
3541
3601
  const relative13 = path6.relative(path6.resolve(projectRoot), path6.resolve(workspaceRoot));
3542
3602
  return relative13 === "" ? 0 : relative13.split(path6.sep).length;
3543
3603
  }
3604
+ function validateFilterOption(filter) {
3605
+ if (filter === void 0) return;
3606
+ if (filter.startsWith("-") || filter.includes(" --")) {
3607
+ throw new Error(`Invalid test filter (parsed as a runner option): "${filter}"`);
3608
+ }
3609
+ if (filter.length > MAX_ARGUMENT_LENGTH || /[\r\n\0]/.test(filter)) {
3610
+ throw new Error("Invalid test filter (oversized or contains a control character)");
3611
+ }
3612
+ }
3544
3613
  function validateOperationOptions(operation, packages) {
3545
3614
  if (!operation.startsWith("package-") || !packages) return;
3546
3615
  for (const value of packages) {
@@ -3582,7 +3651,7 @@ var init_plan = __esm({
3582
3651
  MAX_ARGUMENT_LENGTH = 4096;
3583
3652
  PACKAGE_NAME_RE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+(?:@[a-z0-9*+._~^<>=|-]+)?$/i;
3584
3653
  COMPOSER_PACKAGE_RE = /^[a-z0-9_.-]+\/[a-z0-9_.-]+(?::[a-z0-9*+._~^<>=|-]+)?$/i;
3585
- GO_MODULE_RE = /^(?:[a-z0-9.-]+\.)+[a-z0-9.-]+\/[a-z0-9._~+/@-]+$/i;
3654
+ GO_MODULE_RE = /^(?:[a-z0-9-]+\.)+[a-z0-9-]+\/[a-z0-9._~+/@-]+$/i;
3586
3655
  PYTHON_PACKAGE_RE = /^[a-z0-9._-]+(?:\[[a-z0-9._,-]+\])?==[a-z0-9*+._!~-]+$/i;
3587
3656
  }
3588
3657
  });
@@ -3702,8 +3771,8 @@ async function executeInternal(options, startedAt) {
3702
3771
  const target = options.plan.evidence.find((item) => item.kind === "target")?.path;
3703
3772
  if (!target) return unavailableResult(options, "Internal syntax plan has no target evidence.");
3704
3773
  const safeTarget = await assertContainedFile(target, options.projectRoot);
3705
- const stat18 = await fs4.stat(safeTarget);
3706
- if (stat18.size > MAX_INTERNAL_SOURCE_BYTES) {
3774
+ const stat19 = await fs4.stat(safeTarget);
3775
+ if (stat19.size > MAX_INTERNAL_SOURCE_BYTES) {
3707
3776
  return unavailableResult(
3708
3777
  options,
3709
3778
  `Internal syntax target exceeds ${MAX_INTERNAL_SOURCE_BYTES} bytes.`
@@ -3990,8 +4059,8 @@ async function snapshotPaths(paths) {
3990
4059
  const existing = [];
3991
4060
  for (const candidate of paths) {
3992
4061
  try {
3993
- const stat18 = await fs4.stat(candidate);
3994
- if (!stat18.isFile()) continue;
4062
+ const stat19 = await fs4.stat(candidate);
4063
+ if (!stat19.isFile()) continue;
3995
4064
  existing.push(candidate);
3996
4065
  } catch {
3997
4066
  }
@@ -4019,8 +4088,8 @@ async function snapshotSizes(paths) {
4019
4088
  const sizes = /* @__PURE__ */ new Map();
4020
4089
  for (const candidate of paths) {
4021
4090
  try {
4022
- const stat18 = await fs4.stat(candidate);
4023
- if (stat18.isFile()) sizes.set(candidate, stat18.size);
4091
+ const stat19 = await fs4.stat(candidate);
4092
+ if (stat19.isFile()) sizes.set(candidate, stat19.size);
4024
4093
  } catch {
4025
4094
  }
4026
4095
  }
@@ -4180,6 +4249,9 @@ var init_execute_tool = __esm({
4180
4249
  useInstead: ["language_info", "install", "audit", "outdated"]
4181
4250
  },
4182
4251
  permission: "confirm",
4252
+ // WS-046: gives permission decisions something to key on.
4253
+ // The action performed (build/test/lint); the cwd is where, not what.
4254
+ subjectKey: "action",
4183
4255
  mutating: true,
4184
4256
  riskTier: "standard",
4185
4257
  capabilities: ["shell.restricted", "fs.write"],
@@ -4412,6 +4484,9 @@ var init_package_tool = __esm({
4412
4484
  useInstead: ["language_info", "language", "install", "audit", "outdated"]
4413
4485
  },
4414
4486
  permission: "confirm",
4487
+ // WS-046: gives permission decisions something to key on.
4488
+ // The package operation performed — install/remove are not interchangeable.
4489
+ subjectKey: "operation",
4415
4490
  mutating: true,
4416
4491
  riskTier: "destructive",
4417
4492
  capabilities: ["shell.restricted", "fs.write", "net.outbound", "package.install"],
@@ -6466,9 +6541,9 @@ function parseSymbols6(opts) {
6466
6541
  function regexParse2(opts) {
6467
6542
  const { file, content, lang } = opts;
6468
6543
  const symbols = [];
6469
- const basename13 = path22.basename(file).toLowerCase();
6470
- const isPackageJson = basename13 === "package.json";
6471
- const isTsconfig = basename13 === "tsconfig.json" || basename13 === "tsconfig.build.json";
6544
+ const basename14 = path22.basename(file).toLowerCase();
6545
+ const isPackageJson = basename14 === "package.json";
6546
+ const isTsconfig = basename14 === "tsconfig.json" || basename14 === "tsconfig.build.json";
6472
6547
  const isJsonSchema = content.includes("$schema") || content.includes("$id") || content.includes("$ref");
6473
6548
  const isOpenApi = content.includes("openapi") || content.includes("swagger");
6474
6549
  const lines = content.split("\n");
@@ -8680,30 +8755,59 @@ ${hint}` : ""),
8680
8755
  import {
8681
8756
  GOVERNED_TOOL_EXECUTOR_META_KEY
8682
8757
  } from "@wrongstack/core/types";
8758
+ var BATCHABLE_TOOL_NAMES = [
8759
+ "read",
8760
+ "glob",
8761
+ "grep",
8762
+ "tree",
8763
+ "codebase-stats",
8764
+ "codebase-search",
8765
+ "language_info",
8766
+ "tool_search",
8767
+ "memory_for_file",
8768
+ "memory_for_path",
8769
+ "memory_search"
8770
+ ];
8771
+ var BATCHABLE_TOOLS = new Set(BATCHABLE_TOOL_NAMES);
8772
+ var MAX_BATCH_CALLS = 8;
8773
+ var MAX_PARALLEL_BATCH_CALLS = 3;
8683
8774
  var batchToolUseTool = {
8684
8775
  name: "batch_tool_use",
8685
8776
  category: "Meta",
8686
- description: "Execute a batch of tool calls either sequentially or in parallel. Returns structured results for every call.",
8687
- usageHint: "ADVANCED / POWER USER TOOL:\n\n- Useful when you have a clear list of independent operations to perform.\n- `parallel: true` (default) runs them concurrently for speed.\n- `stop_on_error: true` makes it fail fast on the first error.\nUse with care \u2014 batching many mutating operations can be risky. Prefer explicit sequential steps for important work.",
8777
+ description: "Execute a small batch of independent, read-only discovery calls. Process, mutation, browser-action, MCP, delegation, and nested meta tools are not eligible.",
8778
+ usageHint: `BOUNDED READ-ONLY BATCHING:
8779
+
8780
+ - Use only for a small set of independent reads or searches that are all needed now.
8781
+ - Do not wrap normal sequential reasoning or every tool call in a batch.
8782
+ - \`parallel: true\` (default) runs at most three eligible calls concurrently.
8783
+ - \`stop_on_error: true\` makes it fail fast on the first error.
8784
+ - Eligible tools: ${BATCHABLE_TOOL_NAMES.join(", ")}.`,
8688
8785
  permission: "confirm",
8786
+ // Scheduling barrier: keep the outer meta-call out of ToolExecutor's
8787
+ // non-mutating fan-out even though every eligible inner tool is read-only.
8689
8788
  mutating: true,
8690
8789
  timeoutMs: 12e4,
8691
- capabilities: ["tool.mutate.any"],
8790
+ capabilities: ["tool.meta"],
8692
8791
  icon: "meta",
8693
8792
  inputSchema: {
8694
8793
  type: "object",
8695
8794
  properties: {
8696
8795
  calls: {
8697
8796
  type: "array",
8797
+ maxItems: MAX_BATCH_CALLS,
8698
8798
  items: {
8699
8799
  type: "object",
8700
8800
  properties: {
8701
- tool: { type: "string" },
8801
+ tool: {
8802
+ type: "string",
8803
+ enum: [...BATCHABLE_TOOL_NAMES],
8804
+ description: "An explicitly batch-safe, read-only discovery tool."
8805
+ },
8702
8806
  input: { type: "object" }
8703
8807
  },
8704
8808
  required: ["tool"]
8705
8809
  },
8706
- description: "Array of tool calls to execute"
8810
+ description: `Up to ${MAX_BATCH_CALLS} independent, batch-safe read calls.`
8707
8811
  },
8708
8812
  stop_on_error: {
8709
8813
  type: "boolean",
@@ -8726,6 +8830,9 @@ var batchToolUseTool = {
8726
8830
  stop_on_error: false
8727
8831
  };
8728
8832
  }
8833
+ if (input.calls.length > MAX_BATCH_CALLS) {
8834
+ throw new Error(`batch_tool_use: calls must contain at most ${MAX_BATCH_CALLS} items`);
8835
+ }
8729
8836
  const governedExecute = ctx.meta[GOVERNED_TOOL_EXECUTOR_META_KEY];
8730
8837
  if (typeof governedExecute !== "function") {
8731
8838
  return {
@@ -8745,8 +8852,11 @@ var batchToolUseTool = {
8745
8852
  let succeeded = 0;
8746
8853
  let failed = 0;
8747
8854
  if (input.parallel !== false) {
8748
- const promises = input.calls.map(async (call) => executeSingle(call, ctx, governedExecute));
8749
- const allResults = await Promise.all(promises);
8855
+ const allResults = await mapWithConcurrency(
8856
+ input.calls,
8857
+ MAX_PARALLEL_BATCH_CALLS,
8858
+ (call) => executeSingle(call, ctx, governedExecute)
8859
+ );
8750
8860
  results.push(...allResults);
8751
8861
  succeeded = allResults.filter((r) => r.success).length;
8752
8862
  failed = allResults.filter((r) => !r.success).length;
@@ -8771,8 +8881,28 @@ var batchToolUseTool = {
8771
8881
  };
8772
8882
  }
8773
8883
  };
8884
+ async function mapWithConcurrency(items, limit, run) {
8885
+ const results = new Array(items.length);
8886
+ let nextIndex = 0;
8887
+ const worker2 = async () => {
8888
+ while (nextIndex < items.length) {
8889
+ const index = nextIndex++;
8890
+ results[index] = await run(items[index]);
8891
+ }
8892
+ };
8893
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker2()));
8894
+ return results;
8895
+ }
8774
8896
  async function executeSingle(call, ctx, governedExecute) {
8775
8897
  const start = Date.now();
8898
+ if (call.tool === batchToolUseTool.name) {
8899
+ return {
8900
+ tool: call.tool,
8901
+ success: false,
8902
+ error: "recursive batch_tool_use execution is not allowed",
8903
+ executionMs: Date.now() - start
8904
+ };
8905
+ }
8776
8906
  const tool = ctx.tools.find((candidate) => candidate.name === call.tool);
8777
8907
  if (!tool) {
8778
8908
  return {
@@ -8782,6 +8912,14 @@ async function executeSingle(call, ctx, governedExecute) {
8782
8912
  executionMs: Date.now() - start
8783
8913
  };
8784
8914
  }
8915
+ if (!BATCHABLE_TOOLS.has(tool.name) || tool.mutating) {
8916
+ return {
8917
+ tool: call.tool,
8918
+ success: false,
8919
+ error: `tool "${call.tool}" is not eligible for batch execution; call it directly. Allowed: ${BATCHABLE_TOOL_NAMES.join(", ")}`,
8920
+ executionMs: Date.now() - start
8921
+ };
8922
+ }
8785
8923
  try {
8786
8924
  const result = await governedExecute(call.tool, call.input);
8787
8925
  return {
@@ -8808,8 +8946,14 @@ import * as path10 from "node:path";
8808
8946
  import { atomicWrite, ulid } from "@wrongstack/core/utils";
8809
8947
  var ARTIFACT_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
8810
8948
  var sweptRoots = /* @__PURE__ */ new Set();
8949
+ var MAX_SWEPT_ROOTS = 128;
8811
8950
  function sweepOldArtifacts(root) {
8812
8951
  if (sweptRoots.has(root)) return;
8952
+ while (sweptRoots.size >= MAX_SWEPT_ROOTS) {
8953
+ const oldest = sweptRoots.values().next().value;
8954
+ if (oldest === void 0) break;
8955
+ sweptRoots.delete(oldest);
8956
+ }
8813
8957
  sweptRoots.add(root);
8814
8958
  void (async () => {
8815
8959
  const cutoff = Date.now() - ARTIFACT_RETENTION_MS;
@@ -8832,8 +8976,8 @@ function sweepOldArtifacts(root) {
8832
8976
  for (const name of names) {
8833
8977
  const target = path10.join(dir, name);
8834
8978
  try {
8835
- const stat18 = await fs7.stat(target);
8836
- if (stat18.isFile() && stat18.mtimeMs < cutoff) {
8979
+ const stat19 = await fs7.stat(target);
8980
+ if (stat19.isFile() && stat19.mtimeMs < cutoff) {
8837
8981
  await fs7.rm(target, { force: true });
8838
8982
  removed++;
8839
8983
  }
@@ -8861,14 +9005,14 @@ var BrowserArtifactStore = class {
8861
9005
  }
8862
9006
  async record(id, sessionId, kind, target, mimeType) {
8863
9007
  await fs7.chmod(target, 384).catch(() => void 0);
8864
- const [stat18, sha256] = await Promise.all([fs7.stat(target), hashFile(target)]);
9008
+ const [stat19, sha256] = await Promise.all([fs7.stat(target), hashFile(target)]);
8865
9009
  const artifact = {
8866
9010
  id,
8867
9011
  kind,
8868
9012
  sensitivity: "sensitive",
8869
9013
  path: target,
8870
9014
  mimeType,
8871
- sizeBytes: stat18.size,
9015
+ sizeBytes: stat19.size,
8872
9016
  sha256,
8873
9017
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
8874
9018
  };
@@ -9243,7 +9387,7 @@ var BrowserSessionManager = class {
9243
9387
  constructor(options, launcher = defaultLauncher) {
9244
9388
  this.launcher = launcher;
9245
9389
  this.artifacts = new BrowserArtifactStore(options.artifactRoot);
9246
- this.allowPrivateHosts = options.allowPrivateHosts ?? true;
9390
+ this.allowPrivateHosts = options.allowPrivateHosts ?? false;
9247
9391
  this.allowedPrivateOrigins = options.allowedPrivateOrigins ?? [];
9248
9392
  this.networkProxy = new BrowserNetworkGuardProxy({
9249
9393
  allowPrivateHosts: this.allowPrivateHosts,
@@ -9466,8 +9610,8 @@ var BrowserSessionManager = class {
9466
9610
  if (realRelative.startsWith("..") || path11.isAbsolute(realRelative)) {
9467
9611
  throw new Error("browser: upload files must not escape the project root through a symlink");
9468
9612
  }
9469
- const stat18 = await fs8.stat(realFile);
9470
- if (!stat18.isFile()) throw new Error(`browser: upload target is not a file: ${file}`);
9613
+ const stat19 = await fs8.stat(realFile);
9614
+ if (!stat19.isFile()) throw new Error(`browser: upload target is not a file: ${file}`);
9471
9615
  resolved.push(realFile);
9472
9616
  }
9473
9617
  await this.runPageOperation(
@@ -9819,6 +9963,9 @@ var browserScreenshotTool = {
9819
9963
  description: "Capture a page or element PNG and return sensitive artifact metadata with integrity hash.",
9820
9964
  usageHint: "browser_screenshot({ sessionId, fullPage?, selector? })",
9821
9965
  permission: "confirm",
9966
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9967
+ // rule must distinguish clicking one control from another.
9968
+ subjectKey: "selector",
9822
9969
  mutating: true,
9823
9970
  riskTier: "safe",
9824
9971
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9848,6 +9995,9 @@ function selectorTool(name, description, run) {
9848
9995
  description,
9849
9996
  usageHint: `${name}({ sessionId, selector })`,
9850
9997
  permission: "confirm",
9998
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9999
+ // rule must distinguish clicking one control from another.
10000
+ subjectKey: "selector",
9851
10001
  mutating: true,
9852
10002
  riskTier: "standard",
9853
10003
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9877,6 +10027,9 @@ var browserTypeTool = {
9877
10027
  description: "Fill an input in an owned browser session. Use secretEnv instead of text for credentials so values stay out of tool arguments and session audit.",
9878
10028
  usageHint: "browser_type({ sessionId, selector, text? | secretEnv? })",
9879
10029
  permission: "confirm",
10030
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10031
+ // rule must distinguish clicking one control from another.
10032
+ subjectKey: "selector",
9880
10033
  mutating: true,
9881
10034
  riskTier: "standard",
9882
10035
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9911,6 +10064,9 @@ var browserSelectTool = {
9911
10064
  description: "Select an option in an owned browser session.",
9912
10065
  usageHint: "browser_select({ sessionId, selector, value })",
9913
10066
  permission: "confirm",
10067
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10068
+ // rule must distinguish clicking one control from another.
10069
+ subjectKey: "selector",
9914
10070
  mutating: true,
9915
10071
  riskTier: "standard",
9916
10072
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9939,6 +10095,9 @@ var browserPressTool = {
9939
10095
  description: "Press a keyboard key in an owned browser session.",
9940
10096
  usageHint: "browser_press({ sessionId, key })",
9941
10097
  permission: "confirm",
10098
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10099
+ // rule must distinguish clicking one control from another.
10100
+ subjectKey: "key",
9942
10101
  mutating: true,
9943
10102
  riskTier: "standard",
9944
10103
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9957,6 +10116,9 @@ var browserDragTool = {
9957
10116
  description: "Drag one element to another in an owned browser session.",
9958
10117
  usageHint: "browser_drag({ sessionId, from, to })",
9959
10118
  permission: "confirm",
10119
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10120
+ // rule must distinguish clicking one control from another.
10121
+ subjectKey: "from",
9960
10122
  mutating: true,
9961
10123
  riskTier: "standard",
9962
10124
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -10006,6 +10168,9 @@ var browserEvaluateTool = {
10006
10168
  description: "Evaluate bounded JavaScript in the page. Requires confirmation because page code is arbitrary.",
10007
10169
  usageHint: "browser_evaluate({ sessionId, expression })",
10008
10170
  permission: "confirm",
10171
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10172
+ // rule must distinguish clicking one control from another.
10173
+ subjectKey: "expression",
10009
10174
  mutating: true,
10010
10175
  riskTier: "destructive",
10011
10176
  capabilities: [ToolCapabilities.NET_OUTBOUND, ToolCapabilities.TOOL_MUTATE_ANY],
@@ -10028,6 +10193,9 @@ var browserUploadTool = {
10028
10193
  description: "Upload project-local files through a file input in an owned browser session.",
10029
10194
  usageHint: "browser_upload({ sessionId, selector, files })",
10030
10195
  permission: "confirm",
10196
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10197
+ // rule must distinguish clicking one control from another.
10198
+ subjectKey: "selector",
10031
10199
  mutating: true,
10032
10200
  riskTier: "destructive",
10033
10201
  capabilities: [ToolCapabilities.NET_OUTBOUND, ToolCapabilities.FS_READ],
@@ -10971,6 +11139,7 @@ var REFS_INDEX_SQL = [
10971
11139
  var SYMBOLS_FTS_SQL = "CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(text, tokenize = 'unicode61')";
10972
11140
 
10973
11141
  // src/codebase-index/writer-search-helpers.ts
11142
+ var SEARCH_CANDIDATE_SCAN_CAP = 5e3;
10974
11143
  function normalizeSearchLimit(limit) {
10975
11144
  return typeof limit === "number" && Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : void 0;
10976
11145
  }
@@ -11000,8 +11169,8 @@ function buildWriterSearchWhere(query, filter) {
11000
11169
  }
11001
11170
  if (query.trim()) {
11002
11171
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
11003
- conditions.push(`(${tokens.map(() => "text LIKE ?").join(" OR ")})`);
11004
- for (const token of tokens) values.push(`%${token}%`);
11172
+ conditions.push(`(${tokens.map(() => "text LIKE ? ESCAPE '\\'").join(" OR ")})`);
11173
+ for (const token of tokens) values.push(`%${escapeLike(token)}%`);
11005
11174
  }
11006
11175
  return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", values };
11007
11176
  }
@@ -11110,6 +11279,7 @@ var StorePool = class {
11110
11279
 
11111
11280
  // src/codebase-index/writer.ts
11112
11281
  var DB_FILE2 = "index.db";
11282
+ var MAX_STATEMENT_CACHE = 128;
11113
11283
  var IndexStore = class _IndexStore {
11114
11284
  db;
11115
11285
  /** Absolute path to this project's index directory. */
@@ -11147,12 +11317,31 @@ var IndexStore = class _IndexStore {
11147
11317
  // Dirty on open so the first getOrBuildBm25() rebuilds against current rows;
11148
11318
  // an empty or pre-existing corpus makes a stale IDF table meaningless.
11149
11319
  bm25Dirty = true;
11150
- /** Prepare-once helper: compile `sql` on first use, reuse thereafter. */
11320
+ /**
11321
+ * Prepare-once helper: compile `sql` on first use, reuse thereafter.
11322
+ *
11323
+ * Bounded LRU rather than an open Map. The cache is keyed by SQL TEXT, and
11324
+ * the fallback search builder emits one `text LIKE ?` clause per query token
11325
+ * — so the SQL varies with the token count and a stream of differently-sized
11326
+ * queries grew the cache without limit. Sage's store already bounds its
11327
+ * equivalent at 128 (WS-096).
11328
+ *
11329
+ * Re-inserting on a hit keeps the hot fixed-SQL statements (upsertFile,
11330
+ * insertRefs, …) at the young end, so a burst of one-off search SQL evicts
11331
+ * itself rather than the reindex hot path.
11332
+ */
11151
11333
  stmt(sql) {
11152
- let s = this.stmtCache.get(sql);
11153
- if (s === void 0) {
11154
- s = this.db.prepare(sql);
11155
- this.stmtCache.set(sql, s);
11334
+ const cached = this.stmtCache.get(sql);
11335
+ if (cached !== void 0) {
11336
+ this.stmtCache.delete(sql);
11337
+ this.stmtCache.set(sql, cached);
11338
+ return cached;
11339
+ }
11340
+ const s = this.db.prepare(sql);
11341
+ this.stmtCache.set(sql, s);
11342
+ if (this.stmtCache.size > MAX_STATEMENT_CACHE) {
11343
+ const oldest = this.stmtCache.keys().next();
11344
+ if (!oldest.done) this.stmtCache.delete(oldest.value);
11156
11345
  }
11157
11346
  return s;
11158
11347
  }
@@ -11425,7 +11614,7 @@ var IndexStore = class _IndexStore {
11425
11614
  const { where, values } = built;
11426
11615
  const limit = normalizeSearchLimit(opts?.limit);
11427
11616
  const limitSql = limit !== void 0 ? " LIMIT ?" : "";
11428
- const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment, text FROM symbols ${where}${limitSql}`;
11617
+ const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment FROM symbols ${where}${limitSql}`;
11429
11618
  const binds = limit !== void 0 ? [...values, limit] : values;
11430
11619
  const rows = this.stmt(sql).all(
11431
11620
  ...binds
@@ -11554,11 +11743,13 @@ var IndexStore = class _IndexStore {
11554
11743
  /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
11555
11744
  searchRankedFallback(query, filter, limit) {
11556
11745
  if (!query.trim()) {
11557
- const total = this.countSearch(query, filter);
11558
- if (total === 0) return { results: [], total: 0 };
11559
- return { results: this.search(query, filter, { limit }), total };
11746
+ const total2 = this.countSearch(query, filter);
11747
+ if (total2 === 0) return { results: [], total: 0 };
11748
+ return { results: this.search(query, filter, { limit }), total: total2 };
11560
11749
  }
11561
- const candidates = this.search(query, filter);
11750
+ const total = this.countSearch(query, filter);
11751
+ if (total === 0) return { results: [], total: 0 };
11752
+ const candidates = this.search(query, filter, { limit: SEARCH_CANDIDATE_SCAN_CAP });
11562
11753
  if (candidates.length === 0) return { results: [], total: 0 };
11563
11754
  const candidateById = new Map(candidates.map((c) => [c.id, c]));
11564
11755
  const bm25 = this.getOrBuildBm25();
@@ -11584,7 +11775,7 @@ var IndexStore = class _IndexStore {
11584
11775
  const c = expectDefined2(candidateById.get(id));
11585
11776
  return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
11586
11777
  });
11587
- return { results, total: candidates.length };
11778
+ return { results, total };
11588
11779
  }
11589
11780
  getAllIndexable() {
11590
11781
  return getAllIndexableWithStatement((sql) => this.stmt(sql));
@@ -12003,12 +12194,12 @@ var buildIdCache;
12003
12194
  function projectIndexServerBuildId(entrypoint) {
12004
12195
  const file = entrypoint instanceof URL || entrypoint.startsWith("file:") ? fileURLToPath(entrypoint) : path16.resolve(entrypoint);
12005
12196
  try {
12006
- const stat18 = fs11.statSync(file);
12007
- if (buildIdCache?.file === file && buildIdCache.mtimeMs === stat18.mtimeMs && buildIdCache.size === stat18.size) {
12197
+ const stat19 = fs11.statSync(file);
12198
+ if (buildIdCache?.file === file && buildIdCache.mtimeMs === stat19.mtimeMs && buildIdCache.size === stat19.size) {
12008
12199
  return buildIdCache.buildId;
12009
12200
  }
12010
12201
  const buildId = createHash4("sha256").update(fs11.readFileSync(file)).digest("hex").slice(0, 24);
12011
- buildIdCache = { file, mtimeMs: stat18.mtimeMs, size: stat18.size, buildId };
12202
+ buildIdCache = { file, mtimeMs: stat19.mtimeMs, size: stat19.size, buildId };
12012
12203
  return buildId;
12013
12204
  } catch {
12014
12205
  return `unreadable:${path16.basename(file)}`;
@@ -12178,6 +12369,8 @@ var ProjectServerConnection = class {
12178
12369
  connectResolve = null;
12179
12370
  connectReject = null;
12180
12371
  nextId = 1;
12372
+ /** WS-027: read from the owner-only metadata file — see currentAuthToken. */
12373
+ authToken;
12181
12374
  pending = /* @__PURE__ */ new Map();
12182
12375
  transition(status, options = {}) {
12183
12376
  const previous = connectionStates.get(this.endpoint);
@@ -12198,6 +12391,10 @@ var ProjectServerConnection = class {
12198
12391
  isConnected() {
12199
12392
  return this.socket !== null && !this.socket.destroyed && this.info !== null;
12200
12393
  }
12394
+ /** Safe LRU candidate: no request, connect, or health probe is in flight. */
12395
+ isEvictable() {
12396
+ return this.pending.size === 0 && this.connecting === null && this.healthCheck === null;
12397
+ }
12201
12398
  async checkHealth(spawnIfMissing = false, timeoutMs = SERVER_HEALTH_TIMEOUT_MS) {
12202
12399
  await this.ensureConnected(spawnIfMissing);
12203
12400
  if (this.healthCheck) return this.healthCheck;
@@ -12310,6 +12507,31 @@ var ProjectServerConnection = class {
12310
12507
  this.transition("offline");
12311
12508
  maybeStopHeartbeatLoop();
12312
12509
  }
12510
+ /**
12511
+ * WS-027: the per-process token, read from the daemon's owner-only metadata
12512
+ * file — never from the `hello` frame, which the daemon sends to every
12513
+ * socket that connects (the mistake WS-028 found in the SAGE daemon).
12514
+ *
12515
+ * Read lazily and re-read while unknown: the daemon starts listening before
12516
+ * it writes metadata (endpoint ownership has to be won first), and a
12517
+ * respawned daemon mints a new token, so a cached wrong one would be sticky.
12518
+ */
12519
+ currentAuthToken() {
12520
+ if (this.authToken === void 0) {
12521
+ try {
12522
+ const raw = fs12.readFileSync(
12523
+ projectIndexServerMetadataPath(this.projectRoot, this.indexDir),
12524
+ "utf8"
12525
+ );
12526
+ const parsed = JSON.parse(raw);
12527
+ if (typeof parsed.authToken === "string" && parsed.authToken.length > 0) {
12528
+ this.authToken = parsed.authToken;
12529
+ }
12530
+ } catch {
12531
+ }
12532
+ }
12533
+ return this.authToken;
12534
+ }
12313
12535
  request(message, options) {
12314
12536
  const socket = this.socket;
12315
12537
  if (!socket || socket.destroyed) {
@@ -12353,7 +12575,7 @@ var ProjectServerConnection = class {
12353
12575
  return;
12354
12576
  }
12355
12577
  }
12356
- this.write({ ...message, id });
12578
+ this.write({ ...message, id, authToken: this.currentAuthToken() });
12357
12579
  });
12358
12580
  }
12359
12581
  async ensureConnected(spawnIfMissing) {
@@ -12400,6 +12622,7 @@ var ProjectServerConnection = class {
12400
12622
  connectOnce() {
12401
12623
  this.socket?.destroy();
12402
12624
  this.socket = null;
12625
+ this.authToken = void 0;
12403
12626
  this.info = null;
12404
12627
  this.activity = null;
12405
12628
  this.health = null;
@@ -12596,7 +12819,26 @@ var ProjectServerConnection = class {
12596
12819
  }
12597
12820
  };
12598
12821
  var connections = /* @__PURE__ */ new Map();
12822
+ var MAX_CACHED_CONNECTIONS = 8;
12599
12823
  var heartbeatTimer;
12824
+ function forgetConnection(endpoint, connection) {
12825
+ if (connections.get(endpoint) === connection) connections.delete(endpoint);
12826
+ connection.close();
12827
+ connectionStates.delete(endpoint);
12828
+ if (latestConnectionState.endpoint !== endpoint) return;
12829
+ latestConnectionState = [...connectionStates.values()].at(-1) ?? {
12830
+ status: isProjectIndexServerAvailable() ? "offline" : "unavailable",
12831
+ connected: false
12832
+ };
12833
+ }
12834
+ function trimConnectionCache(protectedConnection) {
12835
+ if (connections.size <= MAX_CACHED_CONNECTIONS) return;
12836
+ for (const [endpoint, connection] of connections) {
12837
+ if (connections.size <= MAX_CACHED_CONNECTIONS) break;
12838
+ if (connection === protectedConnection || !connection.isEvictable()) continue;
12839
+ forgetConnection(endpoint, connection);
12840
+ }
12841
+ }
12600
12842
  function ensureHeartbeatLoop() {
12601
12843
  if (heartbeatTimer) return;
12602
12844
  heartbeatTimer = setInterval(() => {
@@ -12619,7 +12861,11 @@ function connectionFor(projectRoot, indexDir) {
12619
12861
  if (!connection) {
12620
12862
  connection = new ProjectServerConnection(projectRoot, indexDir, endpoint);
12621
12863
  connections.set(endpoint, connection);
12864
+ } else {
12865
+ connections.delete(endpoint);
12866
+ connections.set(endpoint, connection);
12622
12867
  }
12868
+ trimConnectionCache(connection);
12623
12869
  return connection;
12624
12870
  }
12625
12871
  function callProjectIndexServer(op, args, options) {
@@ -13020,9 +13266,9 @@ async function runIndexerWithStore(store, opts) {
13020
13266
  const statReadParse = await Promise.allSettled(
13021
13267
  batchFiles.map(
13022
13268
  async (file) => {
13023
- let stat18;
13269
+ let stat19;
13024
13270
  try {
13025
- stat18 = await fs17.stat(file, statOpts);
13271
+ stat19 = await fs17.stat(file, statOpts);
13026
13272
  } catch (e) {
13027
13273
  if (isAbortError(e)) throw e;
13028
13274
  return {
@@ -13034,21 +13280,21 @@ async function runIndexerWithStore(store, opts) {
13034
13280
  missing: isMissingPathError(e)
13035
13281
  };
13036
13282
  }
13037
- if (!stat18.isFile()) return { file, stat: stat18, lang: "", parsed: null };
13283
+ if (!stat19.isFile()) return { file, stat: stat19, lang: "", parsed: null };
13038
13284
  const lang = detectLang(file);
13039
- if (!lang) return { file, stat: stat18, lang: "", parsed: null };
13040
- if (stat18.size > MAX_INDEX_FILE_BYTES) {
13285
+ if (!lang) return { file, stat: stat19, lang: "", parsed: null };
13286
+ if (stat19.size > MAX_INDEX_FILE_BYTES) {
13041
13287
  return {
13042
13288
  file,
13043
- stat: stat18,
13289
+ stat: stat19,
13044
13290
  lang,
13045
13291
  parsed: null,
13046
- error: `file too large (${stat18.size} bytes; max ${MAX_INDEX_FILE_BYTES})`
13292
+ error: `file too large (${stat19.size} bytes; max ${MAX_INDEX_FILE_BYTES})`
13047
13293
  };
13048
13294
  }
13049
13295
  const meta = existingMeta.get(file);
13050
- if (!force && meta && meta.mtimeMs === Math.floor(stat18.mtimeMs)) {
13051
- return { file, stat: stat18, lang, parsed: null, skippedMeta: meta };
13296
+ if (!force && meta && meta.mtimeMs === Math.floor(stat19.mtimeMs)) {
13297
+ return { file, stat: stat19, lang, parsed: null, skippedMeta: meta };
13052
13298
  }
13053
13299
  let content;
13054
13300
  try {
@@ -13057,7 +13303,7 @@ async function runIndexerWithStore(store, opts) {
13057
13303
  if (isAbortError(e)) throw e;
13058
13304
  return {
13059
13305
  file,
13060
- stat: stat18,
13306
+ stat: stat19,
13061
13307
  lang,
13062
13308
  parsed: null,
13063
13309
  error: `read error: ${e instanceof Error ? e.message : String(e)}`
@@ -13069,13 +13315,13 @@ async function runIndexerWithStore(store, opts) {
13069
13315
  } catch (e) {
13070
13316
  return {
13071
13317
  file,
13072
- stat: stat18,
13318
+ stat: stat19,
13073
13319
  lang,
13074
13320
  parsed: null,
13075
13321
  error: `parse error: ${e instanceof Error ? e.message : String(e)}`
13076
13322
  };
13077
13323
  }
13078
- return { file, stat: stat18, lang, parsed, content };
13324
+ return { file, stat: stat19, lang, parsed, content };
13079
13325
  }
13080
13326
  )
13081
13327
  );
@@ -13096,7 +13342,7 @@ async function runIndexerWithStore(store, opts) {
13096
13342
  errors.push(`${file}: ${result.error}`);
13097
13343
  continue;
13098
13344
  }
13099
- const { stat: stat18, lang, parsed } = result;
13345
+ const { stat: stat19, lang, parsed } = result;
13100
13346
  if (result.skippedMeta) {
13101
13347
  langStats[lang] = (langStats[lang] ?? 0) + result.skippedMeta.symbolCount;
13102
13348
  symbolsIndexed += result.skippedMeta.symbolCount;
@@ -13108,7 +13354,7 @@ async function runIndexerWithStore(store, opts) {
13108
13354
  store.upsertFile({
13109
13355
  file,
13110
13356
  lang,
13111
- mtimeMs: Math.floor(stat18.mtimeMs),
13357
+ mtimeMs: Math.floor(stat19.mtimeMs),
13112
13358
  symbolCount: 0,
13113
13359
  lastIndexed: Date.now()
13114
13360
  });
@@ -13120,7 +13366,7 @@ async function runIndexerWithStore(store, opts) {
13120
13366
  store.replaceEmptyFile({
13121
13367
  file,
13122
13368
  lang,
13123
- mtimeMs: Math.floor(stat18.mtimeMs),
13369
+ mtimeMs: Math.floor(stat19.mtimeMs),
13124
13370
  symbolCount: 0,
13125
13371
  lastIndexed: Date.now()
13126
13372
  });
@@ -13132,7 +13378,7 @@ async function runIndexerWithStore(store, opts) {
13132
13378
  lang,
13133
13379
  symbols: parsed.symbols,
13134
13380
  refs: parsed.refs ?? [],
13135
- mtimeMs: Math.floor(stat18.mtimeMs),
13381
+ mtimeMs: Math.floor(stat19.mtimeMs),
13136
13382
  symbolCount: parsed.symbols.length
13137
13383
  });
13138
13384
  deleteForFiles.push(file);
@@ -14328,8 +14574,9 @@ function runDeadCodeScan(projectRoot, opts = {}) {
14328
14574
  const content = fs19.readFileSync(epFile, "utf8");
14329
14575
  const strippedContent = content.replace(/\/\*[\s\S]*?\*\//g, (m) => " ".repeat(m.length)).replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length));
14330
14576
  const reExportRe = /export\s+(?:(?:type\s+)?\{[\s\S]*?\}\s+from|\*\s+as\s+\w+\s+from|\*\s+from)\s+['"]([^'"]+)['"]/g;
14331
- let match;
14332
- while ((match = reExportRe.exec(strippedContent)) !== null) {
14577
+ for (; ; ) {
14578
+ const match = reExportRe.exec(strippedContent);
14579
+ if (match === null) break;
14333
14580
  const moduleSpec = match[1];
14334
14581
  const resolvedFiles = resolveModulePath(epFile, moduleSpec, indexedFiles);
14335
14582
  for (const rf of resolvedFiles) {
@@ -14535,6 +14782,9 @@ var designTool = {
14535
14782
  description: 'Browse, load, customize, and enforce curated frontend/mobile UI design kits. Use BEFORE writing UI code to commit to one coherent, modern, responsive, dark/light, accessible design. Actions: "list" (menu), "use" (load+pin a kit for a stack), "foundations" (baseline), "set" (override kit colors/tokens), "materialize" (write the tokens to a real theme file \u2014 CSS @theme/OKLCH or native), "verify" (scan UI files for off-palette colors).',
14536
14783
  usageHint: 'Flow: `design {action:"use", kit:"minimal-clarity", stack:"web"}` \u2192 optionally `design {action:"set", set:{primary:"oklch(62% 0.2 25)"}}` \u2192 `design {action:"materialize"}` to write tokens to disk \u2192 implement against them \u2192 `design {action:"verify"}`.',
14537
14784
  permission: "confirm",
14785
+ // WS-046: gives permission decisions something to key on.
14786
+ // The action performed; `out` is optional so it cannot be the subject.
14787
+ subjectKey: "action",
14538
14788
  mutating: true,
14539
14789
  capabilities: ["fs.write"],
14540
14790
  timeoutMs: 15e3,
@@ -14715,9 +14965,7 @@ Run \`design {action:"materialize"}\` to write the tuned tokens to your theme fi
14715
14965
  const abs = path25.join(absParent, path25.basename(absResolved));
14716
14966
  const rel = path25.relative(root, abs);
14717
14967
  if (rel.startsWith("..") || path25.isAbsolute(rel)) {
14718
- throw new Error(
14719
- `design: materialize path "${result.path}" would escape the project root`
14720
- );
14968
+ throw new Error(`design: materialize path "${result.path}" would escape the project root`);
14721
14969
  }
14722
14970
  let exists = false;
14723
14971
  try {
@@ -14792,6 +15040,7 @@ import { statSync as statSync3 } from "node:fs";
14792
15040
  import * as fs21 from "node:fs/promises";
14793
15041
  import * as path26 from "node:path";
14794
15042
  import { buildChildEnv as buildChildEnv3 } from "@wrongstack/core/utils";
15043
+ var MAX_FILE_DUMP_BYTES = 5 * 1024 * 1024;
14795
15044
  var diffTool = {
14796
15045
  name: "diff",
14797
15046
  category: "Filesystem",
@@ -14799,6 +15048,7 @@ var diffTool = {
14799
15048
  usageHint: 'USE FOR CODE REVIEW AND CHANGE INSPECTION:\n\n- `files` + no `a`/`b` \u2192 show file content with line numbers (NOT a unified diff; no +/- prefixes).\n- `a` and/or `b` \u2192 git-style commit/branch diff (unified format, real +/- prefixes).\n- `staged: true` \u2192 only show staged changes.\n- `mode` can be "unified", "stat", or "side-by-side" (only affects the git-diff path).\n\nNOTE: For a true file-vs-file unified diff, supply `a` and `b` so the tool delegates to `git diff`. The `files`-only path is a line-numbered dump, not a diff.\n\nThis tool has important safety guards against flag injection (see previous security findings).',
14800
15049
  permission: "auto",
14801
15050
  mutating: false,
15051
+ maxOutputBytes: 262144,
14802
15052
  capabilities: ["fs.read"],
14803
15053
  icon: "diff",
14804
15054
  timeoutMs: 1e4,
@@ -14874,8 +15124,8 @@ function findGitDir(cwd) {
14874
15124
  let dir = cwd;
14875
15125
  for (let i = 0; i < 20; i++) {
14876
15126
  try {
14877
- const stat18 = statSync3(path26.join(dir, ".git"));
14878
- if (stat18.isDirectory()) return dir;
15127
+ const stat19 = statSync3(path26.join(dir, ".git"));
15128
+ if (stat19.isDirectory()) return dir;
14879
15129
  } catch {
14880
15130
  }
14881
15131
  const parent = path26.dirname(dir);
@@ -14917,10 +15167,18 @@ async function fileDiff(input, ctx, _signal) {
14917
15167
  };
14918
15168
  }
14919
15169
  const results = [];
15170
+ let truncated = false;
14920
15171
  for (const file of files) {
14921
15172
  const absPath = safeResolve(file, ctx);
14922
- const stat18 = await fs21.stat(absPath).catch(() => null);
14923
- if (!stat18?.isFile()) continue;
15173
+ const stat19 = await fs21.stat(absPath).catch(() => null);
15174
+ if (!stat19?.isFile()) continue;
15175
+ if (stat19.size > MAX_FILE_DUMP_BYTES) {
15176
+ truncated = true;
15177
+ results.push(
15178
+ `--- ${file} (skipped: ${stat19.size} bytes exceeds the ${MAX_FILE_DUMP_BYTES} limit; use the read tool with offset/limit) ---`
15179
+ );
15180
+ continue;
15181
+ }
14924
15182
  const content = await fs21.readFile(absPath, "utf8");
14925
15183
  const lines = content.split(/\r?\n/);
14926
15184
  results.push(formatWithLineNumbers(file, lines));
@@ -14928,7 +15186,7 @@ async function fileDiff(input, ctx, _signal) {
14928
15186
  return {
14929
15187
  diff: results.join("\n\n"),
14930
15188
  files,
14931
- truncated: false,
15189
+ truncated,
14932
15190
  mode: input.mode ?? "unified"
14933
15191
  };
14934
15192
  }
@@ -15021,8 +15279,8 @@ async function resolveFiles(filesInput, cwd) {
15021
15279
  for (const f of files) {
15022
15280
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
15023
15281
  try {
15024
- const stat18 = await fs22.stat(absPath);
15025
- if (stat18.isFile()) resolved.push(absPath);
15282
+ const stat19 = await fs22.stat(absPath);
15283
+ if (stat19.isFile()) resolved.push(absPath);
15026
15284
  } catch {
15027
15285
  }
15028
15286
  }
@@ -15875,7 +16133,7 @@ var editTool = {
15875
16133
  });
15876
16134
  }
15877
16135
  const absPath = await safeResolveReal(input.path, ctx);
15878
- const stat18 = await fs23.stat(absPath).catch((err) => {
16136
+ const stat19 = await fs23.stat(absPath).catch((err) => {
15879
16137
  if (err.code === "ENOENT") {
15880
16138
  throw new ToolValidationError({
15881
16139
  message: `edit: file "${input.path}" does not exist. Use \`write\` instead.`,
@@ -15885,7 +16143,7 @@ var editTool = {
15885
16143
  }
15886
16144
  throw err;
15887
16145
  });
15888
- if (!stat18.isFile()) {
16146
+ if (!stat19.isFile()) {
15889
16147
  throw new ToolValidationError({
15890
16148
  message: `edit: "${input.path}" is not a regular file`,
15891
16149
  field: "path"
@@ -15915,7 +16173,7 @@ var editTool = {
15915
16173
  });
15916
16174
  }
15917
16175
  }
15918
- if (autoRead && updated.mtimeMs > stat18.mtimeMs + mtimeTolerance) {
16176
+ if (autoRead && updated.mtimeMs > stat19.mtimeMs + mtimeTolerance) {
15919
16177
  throw new ToolValidationError({
15920
16178
  message: `edit: file "${input.path}" changed while being auto-read. Retry the edit.`,
15921
16179
  field: "path",
@@ -16040,7 +16298,6 @@ import {
16040
16298
  import { toErrorMessage as toErrorMessage3 } from "@wrongstack/core/utils/error";
16041
16299
  init_output_spool();
16042
16300
  init_util();
16043
- init_process_registry();
16044
16301
  init_win32_resolve();
16045
16302
 
16046
16303
  // src/exec-kill-guard.ts
@@ -16279,6 +16536,7 @@ async function checkKillTarget(target) {
16279
16536
  }
16280
16537
 
16281
16538
  // src/exec.ts
16539
+ init_process_registry();
16282
16540
  var isWin4 = process.platform === "win32";
16283
16541
  var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16284
16542
  // JS / TS toolchain
@@ -16944,7 +17202,17 @@ var BLOCKED_ARG_PATTERNS = {
16944
17202
  bun: [],
16945
17203
  docker: [],
16946
17204
  // find -exec/-ok/-execdir execute arbitrary commands
16947
- find: [/^-exec$/, /^-exec;$/, /^-ok$/, /^-ok;$/, /^-execdir$/, /^-execdir;$/, /^-exec=/, /^-ok=/, /^-execdir=/],
17205
+ find: [
17206
+ /^-exec$/,
17207
+ /^-exec;$/,
17208
+ /^-ok$/,
17209
+ /^-ok;$/,
17210
+ /^-execdir$/,
17211
+ /^-execdir;$/,
17212
+ /^-exec=/,
17213
+ /^-ok=/,
17214
+ /^-execdir=/
17215
+ ],
16948
17216
  // rm -rf / is catastrophic — block absolute paths, home, dot-dirs,
16949
17217
  // and glob patterns that could expand to dangerous targets.
16950
17218
  // `rm -rf ./src/*` expands to project files; `rm -rf ../../` escapes upward;
@@ -17015,6 +17283,14 @@ var execTool = {
17015
17283
  useInstead: ["bash"]
17016
17284
  },
17017
17285
  permission: "confirm",
17286
+ // WS-046: without this, every exec call collapsed onto the bare tool name.
17287
+ // Three consequences followed: "always allow" stored a rule that could never
17288
+ // match, pressing "no" once blocked ALL exec for the session, and the
17289
+ // permission cache keyed one decision for every command. `subjectKey:
17290
+ // 'command'` renders the FULL invocation (`command` + `args`) — using the
17291
+ // program alone would make `exec git status` and `exec git push --force` the
17292
+ // same subject, so trusting one would silently authorize the other.
17293
+ subjectKey: "command",
17018
17294
  mutating: true,
17019
17295
  riskTier: "standard",
17020
17296
  timeoutMs: DEFAULT_TIMEOUT_MS3,
@@ -17231,7 +17507,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId, danger) {
17231
17507
  const pid = child.pid;
17232
17508
  if (typeof pid === "number") {
17233
17509
  const fullCommand = `${cmd} ${args.join(" ")}`;
17234
- registry.register({ pid, name: "exec", command: redactCommand(fullCommand), startedAt: Date.now(), sessionId, child });
17510
+ registry.register({
17511
+ pid,
17512
+ name: "exec",
17513
+ command: redactCommand(fullCommand),
17514
+ startedAt: Date.now(),
17515
+ sessionId,
17516
+ child
17517
+ });
17235
17518
  }
17236
17519
  const timer = setTimeout(() => {
17237
17520
  killed = true;
@@ -17671,6 +17954,9 @@ var formatTool = {
17671
17954
  description: "Format source files according to project style (Biome). Can also run in check-only mode.",
17672
17955
  usageHint: "RUN REGULARLY:\n\n- Use on changed files before committing.\n- `check: true` verifies formatting without making changes (useful in CI-like flows).\nThis project has very consistent formatting expectations. Always ensure your changes are formatted.",
17673
17956
  permission: "confirm",
17957
+ // WS-046: gives permission decisions something to key on.
17958
+ // The files being rewritten are the subject; the fixer is how, not what.
17959
+ subjectKey: "files",
17674
17960
  mutating: true,
17675
17961
  capabilities: ["fs.write", "shell.restricted"],
17676
17962
  icon: "code",
@@ -17775,13 +18061,13 @@ var formatTool = {
17775
18061
  }
17776
18062
  };
17777
18063
  async function detectFixer(cwd) {
17778
- const { stat: stat18 } = await import("node:fs/promises");
18064
+ const { stat: stat19 } = await import("node:fs/promises");
17779
18065
  try {
17780
- await stat18(`${cwd}/biome.json`);
18066
+ await stat19(`${cwd}/biome.json`);
17781
18067
  return "biome";
17782
18068
  } catch {
17783
18069
  try {
17784
- await stat18(`${cwd}/.prettierrc`);
18070
+ await stat19(`${cwd}/.prettierrc`);
17785
18071
  return "prettier";
17786
18072
  } catch {
17787
18073
  return "biome";
@@ -17808,6 +18094,10 @@ var gitTool = {
17808
18094
  // Conservative: any of these may mutate. The non-mutating commands
17809
18095
  // (status/log/diff/branch/fetch) are still gated on `permission: 'confirm'`
17810
18096
  // and `MUTATING_SUBCOMMANDS` is consulted at runtime for per-call checks.
18097
+ // WS-046: gives permission decisions something to key on.
18098
+ // The git subcommand (status/commit/push) is what a trust rule needs to
18099
+ // distinguish; `git status` and `git push --force` must not share a subject.
18100
+ subjectKey: "command",
17811
18101
  mutating: true,
17812
18102
  capabilities: ["fs.write", "shell.restricted"],
17813
18103
  timeoutMs: TIMEOUT_MS2,
@@ -17876,6 +18166,8 @@ var gitTool = {
17876
18166
  truncated: false
17877
18167
  };
17878
18168
  }
18169
+ const branchGuard = validateBranchInput(input);
18170
+ if (branchGuard) return branchGuard;
17879
18171
  if (input.command === "worktree") {
17880
18172
  const guard = validateWorktreeInput(input, ctx.projectRoot);
17881
18173
  if (guard) return guard;
@@ -17924,6 +18216,18 @@ var gitTool = {
17924
18216
  return result;
17925
18217
  }
17926
18218
  };
18219
+ function validateBranchInput(input) {
18220
+ const branch = input.branch;
18221
+ if (branch === void 0) return null;
18222
+ if (!branch.startsWith("-") && !branch.includes(" --")) return null;
18223
+ return {
18224
+ command: input.command,
18225
+ stdout: "",
18226
+ stderr: `unsafe branch name (parsed as a git option): ${branch}`,
18227
+ exitCode: 1,
18228
+ truncated: false
18229
+ };
18230
+ }
17927
18231
  function validateWorktreeInput(input, projectRoot) {
17928
18232
  const reject = (stderr) => ({
17929
18233
  command: "worktree",
@@ -17932,7 +18236,6 @@ function validateWorktreeInput(input, projectRoot) {
17932
18236
  exitCode: 1,
17933
18237
  truncated: false
17934
18238
  });
17935
- if (input.branch?.startsWith("-")) return reject(`unsafe branch name: ${input.branch}`);
17936
18239
  if (input.worktreePath?.startsWith("-")) {
17937
18240
  return reject(`unsafe worktree path: ${input.worktreePath}`);
17938
18241
  }
@@ -17950,8 +18253,8 @@ function findGitDir2(cwd, projectRoot) {
17950
18253
  let dir = cwd;
17951
18254
  for (let i = 0; i < 20; i++) {
17952
18255
  try {
17953
- const stat18 = statSync4(`${dir}/.git`);
17954
- if (stat18.isDirectory() || stat18.isFile()) return dir;
18256
+ const stat19 = statSync4(`${dir}/.git`);
18257
+ if (stat19.isDirectory() || stat19.isFile()) return dir;
17955
18258
  } catch {
17956
18259
  }
17957
18260
  if (dir === root) break;
@@ -17986,7 +18289,10 @@ function buildArgs(input) {
17986
18289
  ...files.length ? ["--", ...files] : []
17987
18290
  ];
17988
18291
  case "branch":
17989
- return input.branch ? ["branch", ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]] : ["branch"];
18292
+ return input.branch ? [
18293
+ "branch",
18294
+ ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]
18295
+ ] : ["branch"];
17990
18296
  case "checkout":
17991
18297
  return [
17992
18298
  "checkout",
@@ -18079,7 +18385,7 @@ import * as path30 from "node:path";
18079
18385
  import { compileGlob as compileGlob2, DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS2 } from "@wrongstack/core/utils";
18080
18386
 
18081
18387
  // src/_concurrency.ts
18082
- async function mapWithConcurrency(items, limit, fn) {
18388
+ async function mapWithConcurrency2(items, limit, fn) {
18083
18389
  if (items.length === 0) return [];
18084
18390
  const effectiveLimit = Math.max(1, Math.min(limit | 0, items.length));
18085
18391
  const results = new Array(items.length);
@@ -18214,10 +18520,10 @@ var globTool = {
18214
18520
  }
18215
18521
  if (truncated) return;
18216
18522
  }
18217
- await mapWithConcurrency(matchedFiles, WALK_CONCURRENCY, pushResult);
18523
+ await mapWithConcurrency2(matchedFiles, WALK_CONCURRENCY, pushResult);
18218
18524
  if (truncated) return;
18219
18525
  const remainingSubdirs = truncated ? [] : subdirs;
18220
- await mapWithConcurrency(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk2(full, rel));
18526
+ await mapWithConcurrency2(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk2(full, rel));
18221
18527
  };
18222
18528
  await walk2(base, "");
18223
18529
  results.sort((a, b) => b.mtime - a.mtime);
@@ -18226,12 +18532,16 @@ var globTool = {
18226
18532
  };
18227
18533
 
18228
18534
  // src/grep.ts
18229
- import { expectDefined as expectDefined7 } from "@wrongstack/core/utils";
18230
18535
  import { spawn as spawn11 } from "node:child_process";
18231
18536
  import * as fs25 from "node:fs/promises";
18232
18537
  import * as path31 from "node:path";
18233
18538
  import { ToolValidationError as ToolValidationError4 } from "@wrongstack/core/types";
18234
- import { buildChildEnv as buildChildEnv5, compileGlob as compileGlob3, DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS3 } from "@wrongstack/core/utils";
18539
+ import {
18540
+ buildChildEnv as buildChildEnv5,
18541
+ compileGlob as compileGlob3,
18542
+ DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS3,
18543
+ expectDefined as expectDefined7
18544
+ } from "@wrongstack/core/utils";
18235
18545
 
18236
18546
  // src/_regex.ts
18237
18547
  var MAX_PATTERN_LEN = 256;
@@ -18284,6 +18594,8 @@ var DEFAULT_IGNORE3 = DEFAULT_WALK_IGNORE_DIRS3;
18284
18594
  var NATIVE_SCAN_CONCURRENCY = 32;
18285
18595
  var NATIVE_READ_CHUNK_BYTES = 64 * 1024;
18286
18596
  var NATIVE_MAX_FILE_BYTES = 1e6;
18597
+ var RG_MAX_QUEUE_CHUNKS = 128;
18598
+ var RG_MAX_QUEUE_CHARS = 8 * 1024 * 1024;
18287
18599
  var grepTool = {
18288
18600
  name: "grep",
18289
18601
  category: "Search",
@@ -18411,9 +18723,19 @@ async function* runRgStream(input, base, mode, limit, signal) {
18411
18723
  const FLUSH_AT = 16;
18412
18724
  const MAX_BUF_BYTES = 1e6;
18413
18725
  let bufOverflow = false;
18414
- const child = spawn11("rg", args, { signal, env: buildChildEnv5(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
18726
+ const child = spawn11("rg", args, {
18727
+ signal,
18728
+ env: buildChildEnv5(),
18729
+ // rg diagnostics are not part of the tool result. Ignoring stderr avoids
18730
+ // an unread pipe filling up and pinning the child when rg reports many
18731
+ // filesystem errors.
18732
+ stdio: ["ignore", "pipe", "ignore"],
18733
+ windowsHide: true
18734
+ });
18415
18735
  const queue = [];
18736
+ let queuedChars = 0;
18416
18737
  let waiter;
18738
+ let paused = false;
18417
18739
  const wake = () => {
18418
18740
  if (waiter) {
18419
18741
  const w = waiter;
@@ -18421,93 +18743,133 @@ async function* runRgStream(input, base, mode, limit, signal) {
18421
18743
  w();
18422
18744
  }
18423
18745
  };
18424
- child.stdout?.on("data", (c) => {
18425
- queue.push({ kind: "out", data: c.toString() });
18746
+ const pauseIfFlooded = () => {
18747
+ if (paused || queue.length < RG_MAX_QUEUE_CHUNKS && queuedChars < RG_MAX_QUEUE_CHARS)
18748
+ return;
18749
+ paused = true;
18750
+ child.stdout?.pause();
18751
+ };
18752
+ const resumeIfDrained = () => {
18753
+ if (!paused || queue.length >= RG_MAX_QUEUE_CHUNKS || queuedChars >= RG_MAX_QUEUE_CHARS)
18754
+ return;
18755
+ paused = false;
18756
+ child.stdout?.resume();
18757
+ };
18758
+ const onStdoutData = (c) => {
18759
+ const data = c.toString();
18760
+ queue.push({ kind: "out", data });
18761
+ queuedChars += data.length;
18426
18762
  wake();
18427
- });
18428
- child.on("error", (e) => {
18763
+ pauseIfFlooded();
18764
+ };
18765
+ const onError = (e) => {
18429
18766
  queue.push({ kind: "error", data: e.message });
18430
18767
  wake();
18431
- });
18432
- child.on("close", () => {
18768
+ };
18769
+ const onClose = () => {
18433
18770
  queue.push({ kind: "close", data: "" });
18434
18771
  wake();
18435
- });
18772
+ };
18773
+ child.stdout?.on("data", onStdoutData);
18774
+ child.on("error", onError);
18775
+ child.on("close", onClose);
18436
18776
  let pendingBatch = [];
18437
18777
  let errored = false;
18438
- for (; ; ) {
18439
- while (queue.length === 0) {
18440
- await new Promise((r) => {
18441
- waiter = r;
18442
- });
18443
- }
18444
- const c = expectDefined7(queue.shift());
18445
- if (c.kind === "error") {
18446
- errored = true;
18447
- continue;
18448
- }
18449
- if (c.kind === "close") break;
18450
- buf += c.data;
18451
- if (buf.length > MAX_BUF_BYTES && !bufOverflow) {
18452
- bufOverflow = true;
18453
- buf = buf.slice(-MAX_BUF_BYTES);
18454
- try {
18455
- child.kill("SIGTERM");
18456
- } catch {
18778
+ let closed = false;
18779
+ try {
18780
+ for (; ; ) {
18781
+ while (queue.length === 0) {
18782
+ await new Promise((r) => {
18783
+ waiter = r;
18784
+ });
18457
18785
  }
18458
- }
18459
- const idx = buf.lastIndexOf("\n");
18460
- if (idx === -1) continue;
18461
- const ready = buf.slice(0, idx);
18462
- buf = buf.slice(idx + 1);
18463
- for (const line of ready.split("\n")) {
18464
- if (!line) continue;
18465
- totalLines++;
18466
- if (mode === "count") totalCount += parseRgCountLine(line);
18467
- if (matches.length < limit) {
18468
- matches.push(line);
18469
- pendingBatch.push(line);
18470
- batchSinceFlush++;
18786
+ const c = expectDefined7(queue.shift());
18787
+ queuedChars = Math.max(0, queuedChars - c.data.length);
18788
+ resumeIfDrained();
18789
+ if (c.kind === "error") {
18790
+ errored = true;
18791
+ continue;
18792
+ }
18793
+ if (c.kind === "close") {
18794
+ closed = true;
18795
+ break;
18796
+ }
18797
+ buf += c.data;
18798
+ if (buf.length > MAX_BUF_BYTES && !bufOverflow) {
18799
+ bufOverflow = true;
18800
+ buf = buf.slice(-MAX_BUF_BYTES);
18801
+ try {
18802
+ child.kill("SIGTERM");
18803
+ } catch {
18804
+ }
18805
+ }
18806
+ const idx = buf.lastIndexOf("\n");
18807
+ if (idx === -1) continue;
18808
+ const ready = buf.slice(0, idx);
18809
+ buf = buf.slice(idx + 1);
18810
+ for (const line of ready.split("\n")) {
18811
+ if (!line) continue;
18812
+ totalLines++;
18813
+ if (mode === "count") totalCount += parseRgCountLine(line);
18814
+ if (matches.length < limit) {
18815
+ matches.push(line);
18816
+ pendingBatch.push(line);
18817
+ batchSinceFlush++;
18818
+ }
18819
+ }
18820
+ if (batchSinceFlush >= FLUSH_AT) {
18821
+ yield {
18822
+ type: "partial_output",
18823
+ text: pendingBatch.join("\n"),
18824
+ data: { matches_so_far: matches.length }
18825
+ };
18826
+ pendingBatch = [];
18827
+ batchSinceFlush = 0;
18828
+ }
18829
+ }
18830
+ if (buf.trim()) {
18831
+ for (const line of buf.split("\n")) {
18832
+ if (!line) continue;
18833
+ totalLines++;
18834
+ if (mode === "count") totalCount += parseRgCountLine(line);
18835
+ if (matches.length < limit) {
18836
+ matches.push(line);
18837
+ pendingBatch.push(line);
18838
+ }
18471
18839
  }
18472
18840
  }
18473
- if (batchSinceFlush >= FLUSH_AT) {
18841
+ if (pendingBatch.length > 0) {
18474
18842
  yield {
18475
18843
  type: "partial_output",
18476
18844
  text: pendingBatch.join("\n"),
18477
18845
  data: { matches_so_far: matches.length }
18478
18846
  };
18479
- pendingBatch = [];
18480
- batchSinceFlush = 0;
18481
- }
18482
- }
18483
- if (buf.trim()) {
18484
- for (const line of buf.split("\n")) {
18485
- if (!line) continue;
18486
- totalLines++;
18487
- if (mode === "count") totalCount += parseRgCountLine(line);
18488
- if (matches.length < limit) {
18489
- matches.push(line);
18490
- pendingBatch.push(line);
18491
- }
18492
18847
  }
18493
- }
18494
- if (pendingBatch.length > 0) {
18848
+ if (errored) throw new Error("rg: spawn error");
18495
18849
  yield {
18496
- type: "partial_output",
18497
- text: pendingBatch.join("\n"),
18498
- data: { matches_so_far: matches.length }
18850
+ type: "final",
18851
+ output: {
18852
+ matches,
18853
+ count: mode === "count" ? totalCount : totalLines,
18854
+ truncated: totalLines > limit || bufOverflow,
18855
+ used: "rg"
18856
+ }
18499
18857
  };
18500
- }
18501
- if (errored) throw new Error("rg: spawn error");
18502
- yield {
18503
- type: "final",
18504
- output: {
18505
- matches,
18506
- count: mode === "count" ? totalCount : totalLines,
18507
- truncated: totalLines > limit || bufOverflow,
18508
- used: "rg"
18858
+ } finally {
18859
+ child.stdout?.off("data", onStdoutData);
18860
+ child.off("error", onError);
18861
+ child.off("close", onClose);
18862
+ child.stdout?.destroy();
18863
+ queue.length = 0;
18864
+ queuedChars = 0;
18865
+ waiter = void 0;
18866
+ if (!closed && child.exitCode === null) {
18867
+ try {
18868
+ child.kill("SIGTERM");
18869
+ } catch {
18870
+ }
18509
18871
  }
18510
- };
18872
+ }
18511
18873
  }
18512
18874
  function parseRgCountLine(line) {
18513
18875
  const idx = line.lastIndexOf(":");
@@ -18537,8 +18899,8 @@ async function runNative(input, base, mode, limit, signal) {
18537
18899
  if (globRe && !globRe.test(name) && !globRe.test(full)) return;
18538
18900
  if (globRe) globRe.lastIndex = 0;
18539
18901
  try {
18540
- const stat18 = await fs25.stat(full);
18541
- if (!stat18.isFile() || stat18.size > maxBytes || stopped || signal.aborted) return;
18902
+ const stat19 = await fs25.stat(full);
18903
+ if (!stat19.isFile() || stat19.size > maxBytes || stopped || signal.aborted) return;
18542
18904
  const file = await fs25.open(full, "r");
18543
18905
  try {
18544
18906
  let bytesReadTotal = 0;
@@ -18638,8 +19000,8 @@ async function runNative(input, base, mode, limit, signal) {
18638
19000
  files.push({ full, name: e.name });
18639
19001
  }
18640
19002
  }
18641
- await mapWithConcurrency(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
18642
- await mapWithConcurrency(
19003
+ await mapWithConcurrency2(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
19004
+ await mapWithConcurrency2(
18643
19005
  subdirs,
18644
19006
  Math.min(16, NATIVE_SCAN_CONCURRENCY),
18645
19007
  ({ full, rel }) => walk2(full, rel)
@@ -18658,7 +19020,7 @@ async function runNative(input, base, mode, limit, signal) {
18658
19020
  init_spawn_stream();
18659
19021
  init_util();
18660
19022
  init_legacy_bridge();
18661
- import { join as join24 } from "node:path";
19023
+ import { join as join25 } from "node:path";
18662
19024
  import {
18663
19025
  detectEcosystem as detectPackageEcosystem,
18664
19026
  recordPackageAction
@@ -18669,6 +19031,10 @@ var installTool = {
18669
19031
  description: "Install, update or manage packages using the detected package manager (pnpm/npm/yarn). Strongly preferred over raw shell commands for dependency management because it is structured and safer.",
18670
19032
  usageHint: "ALWAYS USE THIS INSTEAD OF BASH FOR PACKAGE WORK:\n\n- Empty `packages` \u2192 normal `install` (respects lockfile).\n- Provide names \u2192 adds/updates specific packages.\n- `dry_run: true` for safe preview.\n- Set `save` appropriately.\nThis tool has proper capability declaration and is heavily recommended in the security posture of the project.",
18671
19033
  permission: "confirm",
19034
+ // WS-046: gives permission decisions something to key on.
19035
+ // What gets installed is the subject — installing a package runs its
19036
+ // lifecycle scripts, so `express` and `evil-pkg` must not be interchangeable.
19037
+ subjectKey: "packages",
18672
19038
  mutating: true,
18673
19039
  riskTier: "standard",
18674
19040
  icon: "package",
@@ -18838,10 +19204,10 @@ function resolveManifestPath(cwd, pkgManager) {
18838
19204
  case "pnpm":
18839
19205
  case "yarn":
18840
19206
  case "npm":
18841
- return join24(cwd, "package.json");
19207
+ return join25(cwd, "package.json");
18842
19208
  /* v8 ignore next 2 -- pkgManager is always pnpm/yarn/npm; the default is defensive. */
18843
19209
  default:
18844
- return join24(cwd, "package.json");
19210
+ return join25(cwd, "package.json");
18845
19211
  }
18846
19212
  }
18847
19213
 
@@ -18849,6 +19215,24 @@ function resolveManifestPath(cwd, pkgManager) {
18849
19215
  init_util();
18850
19216
  import * as fs26 from "node:fs/promises";
18851
19217
  import { deepMerge as deepMergeCore } from "@wrongstack/core/utils";
19218
+ var MAX_JSON_FILE_BYTES = 16 * 1024 * 1024;
19219
+ var MAX_JSON_FILE_BYTES_HUMAN = "16 MiB";
19220
+ var JsonFileTooLargeError = class extends Error {
19221
+ constructor(filePath, size) {
19222
+ super(
19223
+ `json: "${filePath}" is ${size} bytes \u2014 exceeds the ${MAX_JSON_FILE_BYTES_HUMAN} file-size limit. Extract the relevant portion into a smaller file first, or use a shell tool to query it directly.`
19224
+ );
19225
+ this.name = "JsonFileTooLargeError";
19226
+ }
19227
+ };
19228
+ async function readJsonFileBounded(filePath, ctx) {
19229
+ const resolved = await safeResolveReal(filePath, ctx);
19230
+ const stat19 = await fs26.stat(resolved);
19231
+ if (stat19.size > MAX_JSON_FILE_BYTES) {
19232
+ throw new JsonFileTooLargeError(filePath, stat19.size);
19233
+ }
19234
+ return fs26.readFile(resolved, "utf8");
19235
+ }
18852
19236
  var jsonTool = {
18853
19237
  name: "json",
18854
19238
  category: "Data",
@@ -18856,6 +19240,7 @@ var jsonTool = {
18856
19240
  usageHint: 'VERY USEFUL FOR DATA INSPECTION:\n\n- `action: "parse"` (default): read/pretty-print/convert JSON, JSON5, or YAML from `file` or `data`.\n- `action: "query"`: JMESPath-like query (`a.b[0].c`, `items[*].name`, filters, functions).\n- `action: "validate"`: validate data against a JSON Schema (`schema` param).\n- `action: "transform"`: chain multiple JMESPath transforms (`transforms` param).\n- `action: "merge"`: deep merge `base` and `patch` objects (`conflictResolution` param).\nPrefer this over raw `read` + manual parsing when dealing with configuration or data files.',
18857
19241
  permission: "auto",
18858
19242
  mutating: false,
19243
+ maxOutputBytes: 262144,
18859
19244
  timeoutMs: 5e3,
18860
19245
  capabilities: ["fs.read"],
18861
19246
  icon: "json",
@@ -18922,9 +19307,15 @@ async function executeParse(input, ctx) {
18922
19307
  let raw;
18923
19308
  if (input.file) {
18924
19309
  try {
18925
- raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
18926
- } catch {
18927
- return { data: null, formatted: "", type: "unknown", action: "parse", error: "Could not read file" };
19310
+ raw = await readJsonFileBounded(input.file, ctx);
19311
+ } catch (error) {
19312
+ return {
19313
+ data: null,
19314
+ formatted: "",
19315
+ type: "unknown",
19316
+ action: "parse",
19317
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read file"
19318
+ };
18928
19319
  }
18929
19320
  } else if (input.data) {
18930
19321
  raw = input.data;
@@ -18980,10 +19371,16 @@ async function executeQuery(input, ctx) {
18980
19371
  let parsed;
18981
19372
  if (input.file) {
18982
19373
  try {
18983
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19374
+ const raw = await readJsonFileBounded(input.file, ctx);
18984
19375
  parsed = JSON.parse(raw);
18985
- } catch {
18986
- return { data: null, formatted: "", type: "unknown", action: "query", error: "Could not read/parse file" };
19376
+ } catch (error) {
19377
+ return {
19378
+ data: null,
19379
+ formatted: "",
19380
+ type: "unknown",
19381
+ action: "query",
19382
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19383
+ };
18987
19384
  }
18988
19385
  } else if (input.data) {
18989
19386
  try {
@@ -19022,10 +19419,16 @@ async function executeValidate(input, ctx) {
19022
19419
  let parsed;
19023
19420
  if (input.file) {
19024
19421
  try {
19025
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19422
+ const raw = await readJsonFileBounded(input.file, ctx);
19026
19423
  parsed = JSON.parse(raw);
19027
- } catch {
19028
- return { data: null, formatted: "", type: "unknown", action: "validate", error: "Could not read/parse file" };
19424
+ } catch (error) {
19425
+ return {
19426
+ data: null,
19427
+ formatted: "",
19428
+ type: "unknown",
19429
+ action: "validate",
19430
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19431
+ };
19029
19432
  }
19030
19433
  } else if (input.data) {
19031
19434
  try {
@@ -19064,10 +19467,16 @@ async function executeTransform(input, ctx) {
19064
19467
  let parsed;
19065
19468
  if (input.file) {
19066
19469
  try {
19067
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19470
+ const raw = await readJsonFileBounded(input.file, ctx);
19068
19471
  parsed = JSON.parse(raw);
19069
- } catch {
19070
- return { data: null, formatted: "", type: "unknown", action: "transform", error: "Could not read/parse file" };
19472
+ } catch (error) {
19473
+ return {
19474
+ data: null,
19475
+ formatted: "",
19476
+ type: "unknown",
19477
+ action: "transform",
19478
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19479
+ };
19071
19480
  }
19072
19481
  } else if (input.data) {
19073
19482
  try {
@@ -19671,6 +20080,10 @@ async function handleKanbanDetailAction(projectRoot, input) {
19671
20080
  }
19672
20081
 
19673
20082
  // src/kanban-board-inputs.ts
20083
+ function agentSettableGate(enforcement) {
20084
+ if (enforcement === void 0 || enforcement === "off") return {};
20085
+ return { completionGate: { enforcement } };
20086
+ }
19674
20087
  function boardCreateInput(input, title) {
19675
20088
  return {
19676
20089
  title,
@@ -19683,7 +20096,7 @@ function boardCreateInput(input, title) {
19683
20096
  decomposition: input.atomicityDecomposition ?? "propose"
19684
20097
  }
19685
20098
  } : {},
19686
- ...input.gateEnforcement !== void 0 ? { completionGate: { enforcement: input.gateEnforcement } } : {}
20099
+ ...agentSettableGate(input.gateEnforcement)
19687
20100
  };
19688
20101
  }
19689
20102
  function boardUpdatePatch(input) {
@@ -19697,7 +20110,7 @@ function boardUpdatePatch(input) {
19697
20110
  decomposition: input.atomicityDecomposition ?? "propose"
19698
20111
  }
19699
20112
  } : {},
19700
- ...input.gateEnforcement !== void 0 ? { completionGate: { enforcement: input.gateEnforcement } } : {}
20113
+ ...agentSettableGate(input.gateEnforcement)
19701
20114
  };
19702
20115
  }
19703
20116
  function duplicateBoardOptions(input) {
@@ -19712,6 +20125,7 @@ function duplicateBoardOptions(input) {
19712
20125
 
19713
20126
  // src/kanban-task-inputs.ts
19714
20127
  import { randomUUID } from "node:crypto";
20128
+ import { clampSubagentCapabilities } from "@wrongstack/core/security";
19715
20129
  function taskInput(input) {
19716
20130
  const assignment = hasAssignmentInput(input) ? assignmentForTaskCreate(input) : void 0;
19717
20131
  return {
@@ -19809,6 +20223,10 @@ function taskPatch(input) {
19809
20223
  ...input.actualHours !== void 0 ? { actualHours: input.actualHours } : {}
19810
20224
  };
19811
20225
  }
20226
+ function clampRequestedCapabilities(requested) {
20227
+ if (requested === void 0) return void 0;
20228
+ return clampSubagentCapabilities(requested).granted;
20229
+ }
19812
20230
  function assignmentInput(input) {
19813
20231
  return {
19814
20232
  agentId: input.agentId,
@@ -19819,7 +20237,7 @@ function assignmentInput(input) {
19819
20237
  fallbackProfile: input.fallbackProfile,
19820
20238
  fallbackModels: input.fallbackModels,
19821
20239
  tools: input.tools,
19822
- allowedCapabilities: input.allowedCapabilities,
20240
+ allowedCapabilities: clampRequestedCapabilities(input.allowedCapabilities),
19823
20241
  assignee: input.assignee,
19824
20242
  leaseId: input.leaseId,
19825
20243
  claimedAt: input.claimedAt,
@@ -19846,7 +20264,7 @@ function assignmentForTaskCreate(input) {
19846
20264
  ...input.fallbackProfile !== void 0 ? { fallbackProfile: input.fallbackProfile } : {},
19847
20265
  ...input.fallbackModels !== void 0 ? { fallbackModels: input.fallbackModels } : {},
19848
20266
  ...input.tools !== void 0 ? { tools: input.tools } : {},
19849
- ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {},
20267
+ ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: clampRequestedCapabilities(input.allowedCapabilities) } : {},
19850
20268
  ...input.leaseId !== void 0 ? { leaseId: input.leaseId } : {},
19851
20269
  ...input.claimedAt !== void 0 ? { claimedAt: input.claimedAt } : {},
19852
20270
  ...input.heartbeatAt !== void 0 ? { heartbeatAt: input.heartbeatAt } : {},
@@ -20057,7 +20475,13 @@ var KANBAN_INPUT_SCHEMA = {
20057
20475
  moveTasksToColumnId: { type: "string" },
20058
20476
  atomicityMode: { type: "string", enum: ["off", "assess", "enforce"] },
20059
20477
  atomicityDecomposition: { type: "string", enum: ["auto", "propose"] },
20060
- gateEnforcement: { type: "string", enum: ["strict", "soft", "off"] },
20478
+ gateEnforcement: {
20479
+ type: "string",
20480
+ // WS-023: `'off'` is deliberately absent. The agent whose work this gate
20481
+ // checks must not be able to switch it off; it may only tighten. Turning
20482
+ // a gate off stays a human decision, made through board config.
20483
+ enum: ["strict", "soft"]
20484
+ },
20061
20485
  subtasks: {
20062
20486
  type: "array",
20063
20487
  minItems: 2,
@@ -20099,7 +20523,7 @@ function createKanbanPresenceWrapper(projectRoot, input, ctx) {
20099
20523
 
20100
20524
  // src/session-kanban.ts
20101
20525
  import { watch } from "node:fs";
20102
- import { basename as basename12, dirname as dirname15 } from "node:path";
20526
+ import { basename as basename13, dirname as dirname15 } from "node:path";
20103
20527
  import { getSharedProjectMailbox } from "@wrongstack/core/coordination";
20104
20528
  import {
20105
20529
  loadPlan,
@@ -20189,7 +20613,9 @@ async function ensureSessionKanbanBoard(projectRoot, sessionId) {
20189
20613
  description: "Live session work: todos, tasks, and plan items.",
20190
20614
  tags: sessionBoardTags(sessionId),
20191
20615
  columns: SESSION_KANBAN_COLUMNS,
20192
- generatedBy: `session-kanban:${sessionId}`
20616
+ generatedBy: `session-kanban:${sessionId}`,
20617
+ kind: "session_mirror",
20618
+ retention: { mode: "archive_after_ttl", ttlMs: 7 * 24 * 60 * 60 * 1e3 }
20193
20619
  });
20194
20620
  }
20195
20621
  if (!sameColumns(board.columns) || !board.tags?.includes(SESSION_BOARD_TAG)) {
@@ -20424,12 +20850,26 @@ function projectSessionPlanToKanban(projectRoot, items, sessionId) {
20424
20850
  function fireAndForget(context, work) {
20425
20851
  void work.catch((err) => {
20426
20852
  const message = err instanceof Error ? err.message : String(err);
20427
- console.warn(JSON.stringify({ level: "warn", event: "session-kanban", context, message, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
20853
+ console.warn(
20854
+ JSON.stringify({
20855
+ level: "warn",
20856
+ event: "session-kanban",
20857
+ context,
20858
+ message,
20859
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
20860
+ })
20861
+ );
20428
20862
  });
20429
20863
  }
20430
20864
  function broadcastTodoUpdate(context, todos) {
20431
20865
  const sessionId = context.session?.id ?? "";
20432
20866
  if (!context.agentId || !sessionId) return;
20867
+ const statusCounts = { pending: 0, inProgress: 0, completed: 0 };
20868
+ for (const todo of todos) {
20869
+ if (todo.status === "completed") statusCounts.completed++;
20870
+ else if (todo.status === "in_progress") statusCounts.inProgress++;
20871
+ else statusCounts.pending++;
20872
+ }
20433
20873
  const projectDir = resolveWstackPaths3({ projectRoot: context.projectRoot }).projectDir;
20434
20874
  const mailbox = getSharedProjectMailbox(projectDir);
20435
20875
  void mailbox.send({
@@ -20441,11 +20881,11 @@ function broadcastTodoUpdate(context, todos) {
20441
20881
  kind: "kanban.todos.updated",
20442
20882
  sessionId,
20443
20883
  revision: context.state.revision,
20444
- todos
20884
+ todoCount: todos.length,
20885
+ statusCounts
20445
20886
  }),
20446
20887
  priority: "normal",
20447
- senderSessionId: sessionId,
20448
- ttlMs: 6 * 60 * 60 * 1e3
20888
+ senderSessionId: sessionId
20449
20889
  }).catch(() => {
20450
20890
  });
20451
20891
  }
@@ -20581,8 +21021,8 @@ function attachSessionKanbanMirror(context) {
20581
21021
  const name = filename?.toString();
20582
21022
  const currentPlanPath = context.meta["plan.path"];
20583
21023
  const currentTaskPath = context.meta["task.path"];
20584
- const planName = typeof currentPlanPath === "string" ? basename12(currentPlanPath) : "";
20585
- const taskName = typeof currentTaskPath === "string" ? basename12(currentTaskPath) : "";
21024
+ const planName = typeof currentPlanPath === "string" ? basename13(currentPlanPath) : "";
21025
+ const taskName = typeof currentTaskPath === "string" ? basename13(currentTaskPath) : "";
20586
21026
  if (name && name !== planName && name !== taskName) return;
20587
21027
  if (timer) clearTimeout(timer);
20588
21028
  timer = setTimeout(() => fireAndForget("refresh-files", refreshFiles()), 60);
@@ -21598,11 +22038,11 @@ var lintTool = {
21598
22038
  }
21599
22039
  };
21600
22040
  async function detectLinter(cwd) {
21601
- const { stat: stat18 } = await import("node:fs/promises");
22041
+ const { stat: stat19 } = await import("node:fs/promises");
21602
22042
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
21603
22043
  for (const f of checks) {
21604
22044
  try {
21605
- await stat18(`${cwd}/${f}`);
22045
+ await stat19(`${cwd}/${f}`);
21606
22046
  if (f.includes("biome")) return "biome";
21607
22047
  if (f.includes("eslint")) return "eslint";
21608
22048
  if (f.includes("tslint")) return "tslint";
@@ -21846,6 +22286,9 @@ var outdatedTool = {
21846
22286
  // tool.confirm_needed flow on every invocation. M-1 originally
21847
22287
  // fixed four sibling tools (mcp_control, shellcheck, shellcheck (scan mode),
21848
22288
  // search) but missed this one; applying the same contract here.
22289
+ // WS-046: gives permission decisions something to key on.
22290
+ // The working directory scanned.
22291
+ subjectKey: "cwd",
21849
22292
  mutating: true,
21850
22293
  // Capability is outbound network — the tool only hits the package
21851
22294
  // registry over HTTP, never touches the filesystem or runs shell.
@@ -22027,6 +22470,10 @@ var patchTool = {
22027
22470
  useInstead: ["edit"]
22028
22471
  },
22029
22472
  permission: "confirm",
22473
+ // WS-046: gives permission decisions something to key on.
22474
+ // The target directory. The patch body itself is unstable (every diff
22475
+ // differs), so it could never re-match its own stored rule.
22476
+ subjectKey: "directory",
22030
22477
  mutating: true,
22031
22478
  capabilities: ["fs.write"],
22032
22479
  icon: "edit",
@@ -22093,8 +22540,8 @@ var patchTool = {
22093
22540
  const before = beforeContents.get(target) ?? null;
22094
22541
  const after = await readTextForTracking(target);
22095
22542
  if (after === null || after === before) continue;
22096
- const stat18 = await fs27.stat(target).catch(() => null);
22097
- if (stat18) ctx.recordRead?.(target, stat18.mtimeMs, "write", sha256hex(after));
22543
+ const stat19 = await fs27.stat(target).catch(() => null);
22544
+ if (stat19) ctx.recordRead?.(target, stat19.mtimeMs, "write", sha256hex(after));
22098
22545
  ctx.session?.recordFileChange?.({
22099
22546
  path: target,
22100
22547
  action: before === null ? "created" : "modified",
@@ -22119,8 +22566,8 @@ var patchTool = {
22119
22566
  var MAX_TRACKING_BYTES = 5 * 1024 * 1024;
22120
22567
  async function readTextForTracking(absPath) {
22121
22568
  try {
22122
- const stat18 = await fs27.stat(absPath);
22123
- if (!stat18.isFile() || stat18.size > MAX_TRACKING_BYTES) return null;
22569
+ const stat19 = await fs27.stat(absPath);
22570
+ if (!stat19.isFile() || stat19.size > MAX_TRACKING_BYTES) return null;
22124
22571
  const buf = await fs27.readFile(absPath);
22125
22572
  if (buf.includes(0)) return null;
22126
22573
  return buf.toString("utf8");
@@ -22150,7 +22597,13 @@ function runPatch(args, cwd, signal) {
22150
22597
  let stdout = "";
22151
22598
  let stderr = "";
22152
22599
  const env = { ...buildChildEnv8(), LANG: "C", LC_ALL: "C" };
22153
- const child = spawn14("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
22600
+ const child = spawn14("patch", args, {
22601
+ cwd,
22602
+ signal,
22603
+ env,
22604
+ stdio: ["pipe", "pipe", "pipe"],
22605
+ windowsHide: true
22606
+ });
22154
22607
  child.stdout?.on("data", (c) => {
22155
22608
  stdout += c.toString();
22156
22609
  });
@@ -22171,6 +22624,7 @@ function extractPatchedFiles(output) {
22171
22624
  }
22172
22625
 
22173
22626
  // src/plan.ts
22627
+ import { randomUUID as randomUUID2 } from "node:crypto";
22174
22628
  import {
22175
22629
  addPlanItem,
22176
22630
  clearPlan,
@@ -22178,20 +22632,20 @@ import {
22178
22632
  formatPlan,
22179
22633
  getPlanTemplate,
22180
22634
  mutatePlan as mutatePlan2,
22635
+ mutateTasks as mutateTasks2,
22181
22636
  removePlanItem,
22182
22637
  setPlanItemStatus
22183
22638
  } from "@wrongstack/core/storage";
22184
- import {
22185
- mutateTasks as mutateTasks2
22186
- } from "@wrongstack/core/storage";
22187
22639
  import { formatTaskList } from "@wrongstack/core/utils";
22188
- import { randomUUID as randomUUID2 } from "node:crypto";
22189
22640
  var planTool = {
22190
22641
  name: "plan",
22191
22642
  category: "Session",
22192
22643
  description: 'Manage a session-persistent strategic plan. The plan is written to disk and survives conversation resumptions within the same session, but is isolated to this session \u2014 other sessions have their own separate plans. Unlike todos (which are per-turn and lost on restart), a plan tracks high-level progress across multiple turns. Use this to outline big-picture work, then promote concrete items into the todo list when ready to execute. By default plans are isolated to this session; use `scope: "project"` to store the plan in a shared project-level file visible to all sessions.',
22193
22644
  usageHint: 'RECOMMENDED FOR COMPLEX, MULTI-PHASE WORK:\n\n- Start by creating a high-level plan with `action: "add"` or using templates (`template_use`).\n- Use `promote` to turn a plan item into actionable todos.\n- Use `taskify` to convert a plan item into a structured task (with type/priority/deps).\n- Keep plans at the "why and what" level, and todos at the "how and next step" level.\n- Common templates: "new-feature", "bug-fix", "refactor", "release", "security-audit".\n\nThis tool is excellent for maintaining long-term direction across many turns within a session. Plans survive resume but are not shared across separate sessions.\nUse `scope: "project"` to use a shared project-level plan file.',
22194
22645
  permission: "confirm",
22646
+ // WS-046: gives permission decisions something to key on.
22647
+ // The action performed; plan has no single file or path subject.
22648
+ subjectKey: "action",
22195
22649
  mutating: true,
22196
22650
  capabilities: ["fs.write"],
22197
22651
  icon: "plan",
@@ -22248,7 +22702,10 @@ var planTool = {
22248
22702
  let planPath;
22249
22703
  if (input.scope === "project") {
22250
22704
  if (typeof sessionPlanPath === "string") {
22251
- const lastSep = Math.max(sessionPlanPath.lastIndexOf("/"), sessionPlanPath.lastIndexOf("\\"));
22705
+ const lastSep = Math.max(
22706
+ sessionPlanPath.lastIndexOf("/"),
22707
+ sessionPlanPath.lastIndexOf("\\")
22708
+ );
22252
22709
  planPath = lastSep >= 0 ? sessionPlanPath.slice(0, lastSep + 1) + "backlog.plan.json" : "backlog.plan.json";
22253
22710
  }
22254
22711
  } else {
@@ -22285,7 +22742,11 @@ var planTool = {
22285
22742
  case "start":
22286
22743
  case "done": {
22287
22744
  if (!input.target) {
22288
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
22745
+ early = mkResult(
22746
+ p,
22747
+ false,
22748
+ `${input.action} requires \`target\` (id|index|substring).`
22749
+ );
22289
22750
  return p;
22290
22751
  }
22291
22752
  const next = setPlanItemStatus(
@@ -22313,7 +22774,11 @@ var planTool = {
22313
22774
  }
22314
22775
  case "promote": {
22315
22776
  if (!input.target) {
22316
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
22777
+ early = mkResult(
22778
+ p,
22779
+ false,
22780
+ `${input.action} requires \`target\` (id|index|substring).`
22781
+ );
22317
22782
  return p;
22318
22783
  }
22319
22784
  const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);
@@ -22356,7 +22821,11 @@ var planTool = {
22356
22821
  return clearPlan(p);
22357
22822
  case "taskify": {
22358
22823
  if (!input.target) {
22359
- early = mkResult(p, false, "taskify requires `target` (plan item id|index|substring).");
22824
+ early = mkResult(
22825
+ p,
22826
+ false,
22827
+ "taskify requires `target` (plan item id|index|substring)."
22828
+ );
22360
22829
  return p;
22361
22830
  }
22362
22831
  let itemIdx = -1;
@@ -22429,7 +22898,11 @@ var planTool = {
22429
22898
  ${formatTaskList(taskFile.tasks)}`
22430
22899
  );
22431
22900
  } catch (err) {
22432
- return mkResult(plan, false, `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`);
22901
+ return mkResult(
22902
+ plan,
22903
+ false,
22904
+ `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`
22905
+ );
22433
22906
  }
22434
22907
  }
22435
22908
  return mkResult(plan, true, `Plan ${input.action} ok.`);
@@ -22506,9 +22979,9 @@ var readTool = {
22506
22979
  }
22507
22980
  const absPath = await safeResolveReal(input.path, ctx);
22508
22981
  const shouldIncludeSymbols = input.includeSymbols === true || input.includeSymbols !== false && ctx.meta[ADVANCED_MODE_META_KEY] === true;
22509
- let stat18;
22982
+ let stat19;
22510
22983
  try {
22511
- stat18 = await fs28.stat(absPath);
22984
+ stat19 = await fs28.stat(absPath);
22512
22985
  } catch (err) {
22513
22986
  const code = err.code;
22514
22987
  if (code === "ENOENT") {
@@ -22527,7 +23000,7 @@ var readTool = {
22527
23000
  cause: err
22528
23001
  });
22529
23002
  }
22530
- if (!stat18.isFile()) {
23003
+ if (!stat19.isFile()) {
22531
23004
  throw new FsError({
22532
23005
  message: `read: "${input.path}" is not a regular file`,
22533
23006
  code: "FS_READ_FAILED",
@@ -22535,23 +23008,23 @@ var readTool = {
22535
23008
  context: { reason: "not-a-regular-file" }
22536
23009
  });
22537
23010
  }
22538
- if (stat18.size > MAX_BYTES2) {
23011
+ if (stat19.size > MAX_BYTES2) {
22539
23012
  throw new FsError({
22540
- message: `read: file too large (${stat18.size} bytes, limit ${MAX_BYTES2})`,
23013
+ message: `read: file too large (${stat19.size} bytes, limit ${MAX_BYTES2})`,
22541
23014
  code: "FS_READ_FAILED",
22542
23015
  path: absPath,
22543
- context: { size: stat18.size, limit: MAX_BYTES2, reason: "too-large" }
23016
+ context: { size: stat19.size, limit: MAX_BYTES2, reason: "too-large" }
22544
23017
  });
22545
23018
  }
22546
23019
  const offset = Math.max(1, input.offset ?? 1);
22547
23020
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
22548
23021
  const prior = getReadRangeRecord(ctx, absPath);
22549
23022
  const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
22550
- if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat18.mtimeMs, offset, requestedEnd)) {
22551
- ctx.recordRead(absPath, stat18.mtimeMs);
23023
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat19.mtimeMs, offset, requestedEnd)) {
23024
+ ctx.recordRead(absPath, stat19.mtimeMs);
22552
23025
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22553
23026
  return {
22554
- text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat18.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
23027
+ text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat19.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
22555
23028
  total_lines: prior.totalLines,
22556
23029
  encoding: "utf8",
22557
23030
  truncated: requestedEnd < prior.totalLines,
@@ -22569,11 +23042,11 @@ var readTool = {
22569
23042
  const allLines = text.split(/\r\n|\r|\n/);
22570
23043
  const total = allLines.length;
22571
23044
  if (input.mode === "summary") {
22572
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22573
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, 1, Math.min(total, 200));
23045
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23046
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, 1, Math.min(total, 200));
22574
23047
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22575
23048
  return {
22576
- text: summarizeFile(input.path, stat18.size, allLines),
23049
+ text: summarizeFile(input.path, stat19.size, allLines),
22577
23050
  total_lines: total,
22578
23051
  encoding: "utf8",
22579
23052
  truncated: total > 200,
@@ -22585,8 +23058,8 @@ var readTool = {
22585
23058
  };
22586
23059
  }
22587
23060
  if (limit === 0) {
22588
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22589
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, 1, 0);
23061
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23062
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, 1, 0);
22590
23063
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22591
23064
  return {
22592
23065
  text: "",
@@ -22598,8 +23071,8 @@ var readTool = {
22598
23071
  };
22599
23072
  }
22600
23073
  if (offset > total) {
22601
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22602
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, total + 1, total + 1);
23074
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23075
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, total + 1, total + 1);
22603
23076
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22604
23077
  return {
22605
23078
  text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
@@ -22614,8 +23087,8 @@ var readTool = {
22614
23087
  const truncated = offset - 1 + slice.length < total;
22615
23088
  const width = String(offset + slice.length - 1).length;
22616
23089
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
22617
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22618
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, offset, offset + slice.length - 1);
23090
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23091
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, offset, offset + slice.length - 1);
22619
23092
  const symResult = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22620
23093
  return {
22621
23094
  text: numbered,
@@ -22721,20 +23194,20 @@ ${interesting.join("\n")}` : "symbols/imports: (none detected)"
22721
23194
  }
22722
23195
 
22723
23196
  // src/replace.ts
22724
- import { expectDefined as expectDefined8 } from "@wrongstack/core/utils";
22725
23197
  import { spawn as spawn15 } from "node:child_process";
22726
23198
  import * as fs29 from "node:fs/promises";
22727
23199
  import * as path33 from "node:path";
23200
+ import { ToolValidationError as ToolValidationError6 } from "@wrongstack/core/types";
22728
23201
  import {
22729
23202
  atomicWrite as atomicWrite3,
22730
23203
  buildChildEnv as buildChildEnv9,
22731
23204
  compileGlob as compileGlob4,
22732
23205
  detectNewlineStyle as detectNewlineStyle2,
23206
+ expectDefined as expectDefined8,
22733
23207
  normalizeToLf as normalizeToLf2,
22734
23208
  toStyle as toStyle2,
22735
23209
  unifiedDiff as unifiedDiff2
22736
23210
  } from "@wrongstack/core/utils";
22737
- import { ToolValidationError as ToolValidationError6 } from "@wrongstack/core/types";
22738
23211
  init_util();
22739
23212
  var DEFAULT_IGNORE4 = ["node_modules", ".git", "dist", "build", ".next", "coverage"];
22740
23213
  var replaceTool = {
@@ -22743,6 +23216,10 @@ var replaceTool = {
22743
23216
  description: "Perform a search-and-replace across multiple files using a regex pattern. This is a powerful bulk transformation tool. Dry-run is ON by default \u2014 set `dry_run: false` to apply changes.",
22744
23217
  usageHint: "DANGEROUS IF USED CARELESSLY \u2014 review the diff output carefully.\n\nRecommended workflow:\n1. Run without `dry_run: false` first to see exactly what would change (dry-run is the default).\n2. Review the diff output, then re-run with `dry_run: false` to apply.\n3. Use a specific enough `pattern` (and `glob` / `files`) to avoid accidental broad changes.\n4. `replace_all` controls whether only the first match per file or all matches are replaced.\nThis tool is excellent for large-scale refactors (renaming, import updates, etc.) but must be used with caution.",
22745
23218
  permission: "confirm",
23219
+ // WS-046: gives permission decisions something to key on.
23220
+ // The file scope being rewritten, not the pattern: a trust rule should say
23221
+ // where a bulk replace may run.
23222
+ subjectKey: "files",
22746
23223
  mutating: true,
22747
23224
  capabilities: ["fs.write"],
22748
23225
  icon: "edit",
@@ -22815,8 +23292,8 @@ var replaceTool = {
22815
23292
  }
22816
23293
  const rel = path33.relative(realRoot, realPath);
22817
23294
  if (rel.startsWith("..") || path33.isAbsolute(rel)) continue;
22818
- const stat18 = await fs29.stat(realPath).catch(() => null);
22819
- if (!stat18?.isFile()) continue;
23295
+ const stat19 = await fs29.stat(realPath).catch(() => null);
23296
+ if (!stat19?.isFile()) continue;
22820
23297
  let content;
22821
23298
  try {
22822
23299
  const buf = await fs29.readFile(realPath);
@@ -22841,7 +23318,7 @@ var replaceTool = {
22841
23318
  totalReplacements += count;
22842
23319
  if (!dryRun) {
22843
23320
  const newContent = toStyle2(newContentLf, style);
22844
- await atomicWrite3(realPath, newContent, { mode: stat18.mode & 511 });
23321
+ await atomicWrite3(realPath, newContent, { mode: stat19.mode & 511 });
22845
23322
  const written = await fs29.stat(realPath).catch(() => null);
22846
23323
  if (written) {
22847
23324
  ctx.recordRead?.(realPath, written.mtimeMs, "write", sha256hex(newContent));
@@ -22881,8 +23358,8 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
22881
23358
  const resolved = [];
22882
23359
  for (const p of parts) {
22883
23360
  const absPath = safeResolve(p, ctx);
22884
- const stat18 = await fs29.stat(absPath).catch(() => null);
22885
- if (stat18?.isFile()) {
23361
+ const stat19 = await fs29.stat(absPath).catch(() => null);
23362
+ if (stat19?.isFile()) {
22886
23363
  resolved.push(absPath);
22887
23364
  }
22888
23365
  }
@@ -22902,7 +23379,11 @@ async function globFiles(pattern, base, extraGlob) {
22902
23379
  function checkRg() {
22903
23380
  return new Promise((resolve17) => {
22904
23381
  try {
22905
- const p = spawn15("rg", ["--version"], { env: buildChildEnv9(), stdio: "ignore", windowsHide: true });
23382
+ const p = spawn15("rg", ["--version"], {
23383
+ env: buildChildEnv9(),
23384
+ stdio: "ignore",
23385
+ windowsHide: true
23386
+ });
22906
23387
  p.on("error", () => resolve17(false));
22907
23388
  p.on("close", (code) => resolve17(code === 0));
22908
23389
  } catch {
@@ -22953,8 +23434,8 @@ async function globNative(pattern, base, extraGlob) {
22953
23434
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
22954
23435
  const full = path33.join(dir, e.name);
22955
23436
  try {
22956
- const stat18 = await fs29.lstat(full);
22957
- if (stat18.isSymbolicLink()) continue;
23437
+ const stat19 = await fs29.lstat(full);
23438
+ if (stat19.isSymbolicLink()) continue;
22958
23439
  } catch {
22959
23440
  continue;
22960
23441
  }
@@ -23078,6 +23559,7 @@ var scaffoldTool = {
23078
23559
  usageHint: "PREFERRED FOR SCAFFOLDING:\n\n- Use built-in templates when they match your needs (e.g. react-component, npm-package).\n- Supports `dry_run` so you can preview exactly what will be created.\n- Has the powerful `fs.write.outside-project` capability \u2014 review paths carefully.\nMuch cleaner and safer than manually writing multiple files.",
23079
23560
  permission: "confirm",
23080
23561
  mutating: true,
23562
+ subjectKey: "name",
23081
23563
  capabilities: ["fs.write.outside-project", "fs.write"],
23082
23564
  icon: "scaffold",
23083
23565
  timeoutMs: 3e4,
@@ -23152,15 +23634,17 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
23152
23634
  output: dryRun ? `Would create ${filesCreated} files: ${files.join(", ")}` : `Created ${filesCreated} files: ${files.join(", ")}`
23153
23635
  };
23154
23636
  }
23637
+ function escapeRegexLiteral(value) {
23638
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23639
+ }
23155
23640
  function substituteVars(content, name, vars) {
23641
+ const kebab = name.toLowerCase().replace(/\s+/g, "-");
23642
+ const pascal = name.replace(/(?:^|[-_\s]+)([a-z])/g, (_, c) => c.toUpperCase());
23156
23643
  let result = content;
23157
- result = result.replace(/\{\{name\}\}/g, name.toLowerCase().replace(/\s+/g, "-"));
23158
- result = result.replace(
23159
- /\{\{Name\}\}/g,
23160
- name.replace(/(?:^|[-_\s]+)([a-z])/g, (_, c) => c.toUpperCase())
23161
- );
23644
+ result = result.replace(/\{\{name\}\}/g, () => kebab);
23645
+ result = result.replace(/\{\{Name\}\}/g, () => pascal);
23162
23646
  for (const [k, v] of Object.entries(vars)) {
23163
- result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), v);
23647
+ result = result.replace(new RegExp(`\\{\\{${escapeRegexLiteral(k)}\\}\\}`, "g"), () => v);
23164
23648
  }
23165
23649
  return result;
23166
23650
  }
@@ -24096,11 +24580,11 @@ var testTool = {
24096
24580
  }
24097
24581
  };
24098
24582
  async function detectRunner(cwd) {
24099
- const { stat: stat18 } = await import("node:fs/promises");
24583
+ const { stat: stat19 } = await import("node:fs/promises");
24100
24584
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
24101
24585
  for (const f of candidates) {
24102
24586
  try {
24103
- await stat18(path35.join(cwd, f));
24587
+ await stat19(path35.join(cwd, f));
24104
24588
  if (f.includes("vitest")) return "vitest";
24105
24589
  if (f.includes("jest")) return "jest";
24106
24590
  if (f.includes("mocha")) return "mocha";
@@ -24497,12 +24981,19 @@ var toolSearchTool = {
24497
24981
  };
24498
24982
 
24499
24983
  // src/tool-use.ts
24984
+ import {
24985
+ GOVERNED_TOOL_EXECUTOR_META_KEY as GOVERNED_TOOL_EXECUTOR_META_KEY2
24986
+ } from "@wrongstack/core/types";
24500
24987
  var toolUseTool = {
24501
24988
  name: "tool_use",
24502
24989
  category: "Meta",
24503
24990
  description: "Directly execute any registered tool by its exact name, bypassing normal discovery. This is a powerful meta-tool intended for cases where the agent has a clear plan and knows precisely which tool to invoke.",
24504
24991
  usageHint: "ADVANCED META TOOL \u2014 USE WITH CARE:\n\n- Only use when you are certain of the exact tool name and its expected input shape.\n- Prefer using the normal tool calling mechanism when possible.\n- Very useful in batch-tool-use or when orchestrating complex workflows programmatically.\n- The call still goes through full permission checks and capability validation.",
24505
24992
  permission: "confirm",
24993
+ // WS-046: gives permission decisions something to key on.
24994
+ // The tool being invoked through this indirection — the whole point of a
24995
+ // permission decision here is WHICH tool is being reached.
24996
+ subjectKey: "tool",
24506
24997
  mutating: true,
24507
24998
  timeoutMs: 6e4,
24508
24999
  capabilities: ["tool.mutate.any"],
@@ -24521,7 +25012,7 @@ var toolUseTool = {
24521
25012
  },
24522
25013
  required: ["tool"]
24523
25014
  },
24524
- async execute(input, ctx, opts) {
25015
+ async execute(input, ctx) {
24525
25016
  const start = Date.now();
24526
25017
  if (!input?.tool) {
24527
25018
  return {
@@ -24540,6 +25031,14 @@ var toolUseTool = {
24540
25031
  executionMs: Date.now() - start
24541
25032
  };
24542
25033
  }
25034
+ if (tool.name === toolUseTool.name) {
25035
+ return {
25036
+ tool: input.tool,
25037
+ success: false,
25038
+ error: "tool_use: recursive meta-tool execution is not allowed",
25039
+ executionMs: Date.now() - start
25040
+ };
25041
+ }
24543
25042
  if (tool.permission === "deny") {
24544
25043
  return {
24545
25044
  tool: input.tool,
@@ -24548,12 +25047,21 @@ var toolUseTool = {
24548
25047
  executionMs: Date.now() - start
24549
25048
  };
24550
25049
  }
25050
+ const governedExecute = ctx.meta[GOVERNED_TOOL_EXECUTOR_META_KEY2];
25051
+ if (typeof governedExecute !== "function") {
25052
+ return {
25053
+ tool: input.tool,
25054
+ success: false,
25055
+ error: "tool_use: governed nested execution is unavailable; call the tool directly",
25056
+ executionMs: Date.now() - start
25057
+ };
25058
+ }
24551
25059
  try {
24552
- const result = await tool.execute(input.input, ctx, opts);
25060
+ const result = await governedExecute(input.tool, input.input ?? {});
24553
25061
  return {
24554
25062
  tool: input.tool,
24555
- success: true,
24556
- result,
25063
+ success: result.success,
25064
+ ...result.success ? { result: result.result } : { error: result.error ?? "nested tool failed" },
24557
25065
  executionMs: Date.now() - start
24558
25066
  };
24559
25067
  } catch (e) {
@@ -24569,10 +25077,12 @@ var toolUseTool = {
24569
25077
 
24570
25078
  // src/tree.ts
24571
25079
  init_util();
24572
- import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
24573
25080
  import * as fs32 from "node:fs/promises";
24574
25081
  import * as path36 from "node:path";
25082
+ import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
24575
25083
  var DEFAULT_IGNORE5 = [...DEFAULT_WALK_IGNORE_DIRS4, ".wrongstack", ".ssh", ".gnupg", ".aws"];
25084
+ var DEFAULT_MAX_ENTRIES2 = 5e3;
25085
+ var MAX_TREE_OUTPUT_BYTES = 256 * 1024;
24576
25086
  var treeTool = {
24577
25087
  name: "tree",
24578
25088
  category: "Filesystem",
@@ -24616,6 +25126,12 @@ var treeTool = {
24616
25126
  show_hidden: {
24617
25127
  type: "boolean",
24618
25128
  description: "Show hidden files starting with . (default: false)"
25129
+ },
25130
+ max_entries: {
25131
+ type: "integer",
25132
+ description: `Maximum entries retained in the rendered tree (default ${DEFAULT_MAX_ENTRIES2}).`,
25133
+ minimum: 1,
25134
+ maximum: 5e4
24619
25135
  }
24620
25136
  }
24621
25137
  },
@@ -24629,7 +25145,7 @@ var treeTool = {
24629
25145
  if (!final) throw new Error("tree: stream ended without final event");
24630
25146
  return final;
24631
25147
  },
24632
- async *executeStream(input, ctx) {
25148
+ async *executeStream(input, ctx, opts) {
24633
25149
  const basePath = input.path ? safeResolve(input.path, ctx) : ctx.cwd;
24634
25150
  const maxDepth = input.depth ?? 3;
24635
25151
  const showFiles = input.show_files ?? true;
@@ -24637,8 +25153,17 @@ var treeTool = {
24637
25153
  const showHidden = input.show_hidden ?? false;
24638
25154
  const exclude = /* @__PURE__ */ new Set([...DEFAULT_IGNORE5, ...input.exclude ?? []]);
24639
25155
  const filterGlob = input.glob;
25156
+ const maxEntries = input.max_entries ?? DEFAULT_MAX_ENTRIES2;
25157
+ const signal = opts?.signal ?? ctx.signal ?? new AbortController().signal;
24640
25158
  const lines = [basePath];
24641
25159
  const totals = { totalFiles: { value: 0 }, totalDirs: { value: 0 } };
25160
+ const retention = {
25161
+ entries: 0,
25162
+ outputBytes: Buffer.byteLength(basePath, "utf8"),
25163
+ maxEntries,
25164
+ maxOutputBytes: MAX_TREE_OUTPUT_BYTES,
25165
+ truncated: false
25166
+ };
24642
25167
  const queue = [];
24643
25168
  const FLUSH_EVERY = 200;
24644
25169
  let lastEmittedTotal = 0;
@@ -24665,12 +25190,19 @@ var treeTool = {
24665
25190
  isLast: true,
24666
25191
  totalFiles: totals.totalFiles,
24667
25192
  totalDirs: totals.totalDirs,
24668
- onProgress: tickProgress
25193
+ onProgress: tickProgress,
25194
+ retention,
25195
+ signal
24669
25196
  });
24670
25197
  let walkDone = false;
24671
- walkPromise.finally(() => {
24672
- walkDone = true;
24673
- });
25198
+ void walkPromise.then(
25199
+ () => {
25200
+ walkDone = true;
25201
+ },
25202
+ () => {
25203
+ walkDone = true;
25204
+ }
25205
+ );
24674
25206
  while (!walkDone || queue.length > 0) {
24675
25207
  if (queue.length > 0) {
24676
25208
  yield expectDefined10(queue.shift());
@@ -24693,13 +25225,15 @@ var treeTool = {
24693
25225
  tree: lines.join("\n"),
24694
25226
  total_files: totals.totalFiles.value,
24695
25227
  total_dirs: totals.totalDirs.value,
24696
- truncated: false,
25228
+ truncated: retention.truncated,
24697
25229
  path: basePath
24698
25230
  }
24699
25231
  };
24700
25232
  }
24701
25233
  };
24702
25234
  async function walkDir(dir, depth, opts) {
25235
+ opts.signal.throwIfAborted();
25236
+ if (opts.retention.truncated) return;
24703
25237
  const entries = await fs32.readdir(dir, { withFileTypes: true }).catch(() => []);
24704
25238
  const filtered = entries.filter((e) => {
24705
25239
  if (!opts.showHidden && e.name.startsWith(".")) return false;
@@ -24719,6 +25253,8 @@ async function walkDir(dir, depth, opts) {
24719
25253
  return a.name.localeCompare(b.name);
24720
25254
  });
24721
25255
  for (let i = 0; i < items.length; i++) {
25256
+ opts.signal.throwIfAborted();
25257
+ if (opts.retention.truncated) return;
24722
25258
  const entry = items[i];
24723
25259
  if (!entry) continue;
24724
25260
  const isLast = i === items.length - 1;
@@ -24727,7 +25263,15 @@ async function walkDir(dir, depth, opts) {
24727
25263
  const displayName = entry.name + (entry.isDirectory() ? "/" : "");
24728
25264
  if (!opts.showDirs && entry.isDirectory()) continue;
24729
25265
  if (!opts.showFiles && entry.isFile()) continue;
24730
- opts.lines.push(opts.prefix + branch + displayName);
25266
+ const line = opts.prefix + branch + displayName;
25267
+ const lineBytes = Buffer.byteLength(line, "utf8") + 1;
25268
+ if (opts.retention.entries >= opts.retention.maxEntries || opts.retention.outputBytes + lineBytes > opts.retention.maxOutputBytes) {
25269
+ opts.retention.truncated = true;
25270
+ return;
25271
+ }
25272
+ opts.lines.push(line);
25273
+ opts.retention.entries++;
25274
+ opts.retention.outputBytes += lineBytes;
24731
25275
  if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
24732
25276
  const childPrefix = opts.prefix + connector;
24733
25277
  await walkDir(path36.join(dir, entry.name), depth + 1, {
@@ -24842,11 +25386,11 @@ var typecheckTool = {
24842
25386
  }
24843
25387
  };
24844
25388
  async function findTsConfig(cwd) {
24845
- const { stat: stat18 } = await import("node:fs/promises");
25389
+ const { stat: stat19 } = await import("node:fs/promises");
24846
25390
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
24847
25391
  for (const f of candidates) {
24848
25392
  try {
24849
- const s = await stat18(path37.join(cwd, f));
25393
+ const s = await stat19(path37.join(cwd, f));
24850
25394
  if (s.isFile()) return path37.join(cwd, f);
24851
25395
  } catch {
24852
25396
  }
@@ -24921,12 +25465,12 @@ async function prepareWrite(input, ctx) {
24921
25465
  let existed = false;
24922
25466
  let prev = "";
24923
25467
  try {
24924
- const stat18 = await fs33.stat(absPath);
24925
- existed = stat18.isFile();
25468
+ const stat19 = await fs33.stat(absPath);
25469
+ existed = stat19.isFile();
24926
25470
  if (existed) {
24927
25471
  if (!ctx.hasRead(absPath)) {
24928
25472
  prev = await fs33.readFile(absPath, "utf8");
24929
- ctx.recordRead(absPath, stat18.mtimeMs, "write", sha256hex(prev));
25473
+ ctx.recordRead(absPath, stat19.mtimeMs, "write", sha256hex(prev));
24930
25474
  } else {
24931
25475
  prev = await fs33.readFile(absPath, "utf8");
24932
25476
  }
@@ -24943,8 +25487,8 @@ async function finishWrite(input, ctx, prepared, signal) {
24943
25487
  await atomicWrite5(prepared.absPath, input.content);
24944
25488
  const diff = prepared.existed ? unifiedDiff3(prepared.prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
24945
25489
  + (new file, ${input.content.split("\n").length} lines)`;
24946
- const stat18 = await fs33.stat(prepared.absPath);
24947
- ctx.recordRead(prepared.absPath, stat18.mtimeMs, "write", sha256hex(input.content));
25490
+ const stat19 = await fs33.stat(prepared.absPath);
25491
+ ctx.recordRead(prepared.absPath, stat19.mtimeMs, "write", sha256hex(input.content));
24948
25492
  ctx.session.recordFileChange({
24949
25493
  path: prepared.absPath,
24950
25494
  action: prepared.existed ? "modified" : "created",
@@ -26132,9 +26676,9 @@ async function walk(root, dir, out) {
26132
26676
  } else if (e.isFile()) {
26133
26677
  if (e.name === "SKILL.md" || e.name === "SKILL.save.md") continue;
26134
26678
  try {
26135
- const stat18 = await fs34.stat(fullPath);
26679
+ const stat19 = await fs34.stat(fullPath);
26136
26680
  const rel = path38.relative(root, fullPath).split(path38.sep).join("/");
26137
- out.push({ path: rel, bytes: stat18.size });
26681
+ out.push({ path: rel, bytes: stat19.size });
26138
26682
  } catch {
26139
26683
  }
26140
26684
  }