@codeworksh/harness 0.1.0-dev.20261007133219 → 0.2.0-canary.10.1.g462f34f6

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.
package/effect.mjs CHANGED
@@ -1,16 +1,17 @@
1
1
  import { n as __reExport, t as __exportAll } from "./rolldown-runtime-B4FMCO8f.mjs";
2
2
  import { a as io_exports, i as errors_exports, n as shell_exports, o as instance_exports, r as filesystem_exports, s as posix } from "./resource-DFZh0e-s.mjs";
3
- import "./daytona-BAurx6Gp.mjs";
4
- import "./vercel-C0v5nh20.mjs";
3
+ import "./daytona-ByTHZelc.mjs";
4
+ import "./vercel-3GouQSJI.mjs";
5
+ import { a as decode, n as dimensionNote, o as load$4, r as resize } from "./resize-Dwv5dCgY.mjs";
5
6
  import { createRequire, registerHooks } from "node:module";
6
7
  import { Cause, Clock, Config, Context, Crypto, DateTime, Deferred, Duration, Effect, Exit, Fiber, FiberSet, FileSystem, Layer, LayerMap, Option, Path, Predicate, PubSub, Queue, Random, Ref, Result, Schedule, Schema, SchemaIssue, Scope, Semaphore, Stream, String as String$1 } from "effect";
7
- import { Message, Model, getEnvApiKey, llm, stream } from "@codeworksh/aikit";
8
+ import { Message, Model, getEnvApiKey, llm, normalizeOptionalNulls, stream } from "@codeworksh/aikit";
8
9
  import { MessageID as ID, SessionID as ID$1 } from "@codeworksh/plugin/ids";
9
10
  import { Migrator, SqlClient, SqlClient as SqlClient$1, SqlError, SqlSchema, SqlSchema as SqlSchema$1 } from "effect/sql";
10
11
  import { uuidv7 } from "uuidv7";
11
12
  import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node";
12
13
  import * as os from "node:os";
13
- import { homedir, tmpdir } from "node:os";
14
+ import { homedir } from "node:os";
14
15
  import { NodeChildProcessSpawner, NodeCrypto, NodeFileSystem, NodePath } from "@effect/platform-node";
15
16
  import { Model as Model$1, VariantSchema } from "effect/schema";
16
17
  import { Hex } from "effect/encoding";
@@ -25,6 +26,8 @@ import { JsonGitHubCopilotAuthStorage, getGitHubCopilotApiKey } from "@codeworks
25
26
  import { JsonOpenAICodexAuthStorage, getOpenAICodexApiKey } from "@codeworksh/aikit/oauth/openai/codex";
26
27
  import { parse, printParseErrorCode } from "jsonc-parser";
27
28
  import * as Section from "@codeworksh/plugin/plugin/section";
29
+ import * as Diff from "diff";
30
+ import { FetchHttpClient, HttpClient } from "effect/http";
28
31
  import { fileURLToPath, pathToFileURL } from "node:url";
29
32
  import path from "node:path";
30
33
  import { Script, constants } from "node:vm";
@@ -139,7 +142,7 @@ var global_exports = /* @__PURE__ */ __exportAll({
139
142
  layer: () => layer$27,
140
143
  layerWith: () => layerWith,
141
144
  make: () => make$18,
142
- resolve: () => resolve$3
145
+ resolve: () => resolve$4
143
146
  });
144
147
  const appConfigDir = ".codework";
145
148
  const app = "codework";
@@ -153,7 +156,8 @@ function make$18(input = {}) {
153
156
  home,
154
157
  cache: input.cache ?? posix.join(home, "cache"),
155
158
  data: input.data ?? posix.join(home, "data"),
156
- log: input.log ?? posix.join(home, "log")
159
+ log: input.log ?? posix.join(home, "log"),
160
+ bin: input.bin ?? posix.join(home, "bin")
157
161
  };
158
162
  }
159
163
  /**
@@ -166,7 +170,7 @@ function make$18(input = {}) {
166
170
  * would be invisible to the other.
167
171
  */
168
172
  const authFile = (home) => home === void 0 ? void 0 : posix.join(home, "aikit", "auth.json");
169
- const resolve$3 = Effect.fn("Global.resolve")(function* (input = {}) {
173
+ const resolve$4 = Effect.fn("Global.resolve")(function* (input = {}) {
170
174
  const home = input.home === void 0 ? yield* homeConfig : expandHome(input.home);
171
175
  return make$18({
172
176
  ...input,
@@ -174,7 +178,7 @@ const resolve$3 = Effect.fn("Global.resolve")(function* (input = {}) {
174
178
  });
175
179
  });
176
180
  const build = (input) => Effect.gen(function* () {
177
- const paths = yield* resolve$3(input);
181
+ const paths = yield* resolve$4(input);
178
182
  yield* Effect.all([
179
183
  fileSystem.makeDirectory(paths.cache, { recursive: true }),
180
184
  fileSystem.makeDirectory(paths.data, { recursive: true }),
@@ -376,7 +380,7 @@ var db_exports = /* @__PURE__ */ __exportAll({
376
380
  defaultLayer: () => defaultLayer$1,
377
381
  layer: () => layer$26,
378
382
  locationConfig: () => locationConfig,
379
- path: () => path$1,
383
+ path: () => path$2,
380
384
  resolveDatabaseLocation: () => resolveDatabaseLocation
381
385
  });
382
386
  const setup = Effect.gen(function* () {
@@ -403,11 +407,11 @@ function resolveDatabaseLocation(configured, data) {
403
407
  if (configured === ":memory:" || posix.isAbsolute(configured)) return configured;
404
408
  return posix.join(data, configured);
405
409
  }
406
- const path$1 = Effect.fn("Database.path")(function* (data) {
407
- const directory = data ?? (yield* resolve$3()).data;
410
+ const path$2 = Effect.fn("Database.path")(function* (data) {
411
+ const directory = data ?? (yield* resolve$4()).data;
408
412
  return resolveDatabaseLocation(yield* locationConfig, directory);
409
413
  });
410
- const defaultLayer$1 = Layer.unwrap(path$1().pipe(Effect.map(layer$26)));
414
+ const defaultLayer$1 = Layer.unwrap(path$2().pipe(Effect.map(layer$26)));
411
415
  //#endregion
412
416
  //#region src/event/schema.ts
413
417
  var schema_exports$7 = /* @__PURE__ */ __exportAll({});
@@ -2665,6 +2669,7 @@ const toFileStat = (stats) => ({
2665
2669
  size: stats.size,
2666
2670
  mtime: stats.mtime
2667
2671
  });
2672
+ const SCAN_CHUNK = 65536;
2668
2673
  const bytes = (content) => typeof content === "string" ? Buffer$1.from(content, "utf8") : Buffer$1.from(content);
2669
2674
  /**
2670
2675
  * Provider factory method that implements the SandboxFileSytem
@@ -2676,6 +2681,8 @@ const make$17 = (vfs) => {
2676
2681
  await vfs.promises.writeFile(path, bytes(content));
2677
2682
  };
2678
2683
  const readdir = async (path) => vfs.promises.readdir(path);
2684
+ const open = (path) => new Promise((resolve, reject) => vfs.open(path, "r", (error, value) => error ? reject(error) : resolve(value)));
2685
+ const close = (fd) => new Promise((resolve) => vfs.close(fd, () => resolve()));
2679
2686
  const rm = async (path, options) => {
2680
2687
  let stats;
2681
2688
  try {
@@ -2715,7 +2722,36 @@ const make$17 = (vfs) => {
2715
2722
  await vfs.promises.mkdir(path, options);
2716
2723
  },
2717
2724
  rm,
2718
- realpath: (path) => vfs.promises.realpath(path)
2725
+ realpath: (path) => vfs.promises.realpath(path),
2726
+ readBytes: async (path, offset, length) => {
2727
+ const fd = await open(path);
2728
+ try {
2729
+ const buffer = Buffer$1.alloc(length);
2730
+ const read = await new Promise((resolve, reject) => vfs.read(fd, buffer, 0, length, offset, (error, bytesRead) => error ? reject(error) : resolve(bytesRead)));
2731
+ return new Uint8Array(buffer.subarray(0, read));
2732
+ } finally {
2733
+ await close(fd);
2734
+ }
2735
+ },
2736
+ scanLines: async (path, options) => {
2737
+ const scanner = new filesystem_exports.SandboxFileSystem.Scan.LineScanner(options);
2738
+ const { size } = await vfs.promises.stat(path);
2739
+ if (size === 0) return scanner.finish();
2740
+ const fd = await open(path);
2741
+ try {
2742
+ const buffer = Buffer$1.alloc(Math.min(SCAN_CHUNK, size));
2743
+ for (let position = 0; position < size;) {
2744
+ const length = Math.min(buffer.length, size - position);
2745
+ const read = await new Promise((resolve, reject) => vfs.read(fd, buffer, 0, length, position, (error, bytesRead) => error ? reject(error) : resolve(bytesRead)));
2746
+ if (read === 0) break;
2747
+ scanner.push(buffer.subarray(0, read));
2748
+ position += read;
2749
+ }
2750
+ } finally {
2751
+ await close(fd);
2752
+ }
2753
+ return scanner.finish();
2754
+ }
2719
2755
  };
2720
2756
  };
2721
2757
  /** The `SandboxFileSystem.Service` backed by the local {@link Vfs}. */
@@ -2843,10 +2879,12 @@ const layer$19 = (...drivers) => Layer.effect(Registry, make$16(drivers));
2843
2879
  //#endregion
2844
2880
  //#region src/sandbox/shell/host.ts
2845
2881
  const DEFAULT_SHELL = "sh";
2882
+ const DEFAULT_FORCE_KILL_AFTER = Duration.seconds(1);
2846
2883
  const layer$18 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(function* () {
2847
2884
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2848
- const run = (label, file, args, opts) => Effect.scoped(Effect.gen(function* () {
2849
- const handle = yield* spawner.spawn(ChildProcess.make(file, [...args], {
2885
+ const forceKillAfter = options.forceKillAfter ?? DEFAULT_FORCE_KILL_AFTER;
2886
+ const spawn = (file, args, opts) => Effect.gen(function* () {
2887
+ const spawned = yield* spawner.spawn(ChildProcess.make(file, [...args], {
2850
2888
  cwd: opts?.cwd,
2851
2889
  env: {
2852
2890
  ...options.env,
@@ -2857,6 +2895,32 @@ const layer$18 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(
2857
2895
  stdout: "pipe",
2858
2896
  stderr: "pipe"
2859
2897
  }));
2898
+ let openPipes = 2;
2899
+ const track = (stream) => stream.pipe(Stream.ensuring(Effect.sync(() => openPipes--)));
2900
+ const output = {
2901
+ stdout: track(spawned.stdout),
2902
+ stderr: track(spawned.stderr)
2903
+ };
2904
+ yield* Effect.addFinalizer((exit) => Exit.isSuccess(exit) ? Effect.void : Effect.gen(function* () {
2905
+ const running = yield* spawned.isRunning.pipe(Effect.orElseSucceed(() => false));
2906
+ if (!running && openPipes === 0) return;
2907
+ if (running) yield* spawned.kill({ killSignal: "SIGTERM" }).pipe(Effect.timeoutOption(forceKillAfter), Effect.flatMap(Option.match({
2908
+ onSome: () => Effect.void,
2909
+ onNone: () => spawned.kill({ killSignal: "SIGKILL" })
2910
+ })), Effect.ignore);
2911
+ yield* Effect.sync(() => {
2912
+ try {
2913
+ process.kill(-Number(spawned.pid), "SIGKILL");
2914
+ } catch {}
2915
+ });
2916
+ }));
2917
+ return {
2918
+ exitCode: spawned.exitCode,
2919
+ ...output
2920
+ };
2921
+ });
2922
+ const run = (label, file, args, opts) => Effect.scoped(Effect.gen(function* () {
2923
+ const handle = yield* spawn(file, args, opts);
2860
2924
  return yield* Effect.all({
2861
2925
  exitCode: handle.exitCode,
2862
2926
  stdout: handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
@@ -2871,9 +2935,23 @@ const layer$18 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(
2871
2935
  command: "",
2872
2936
  cause: /* @__PURE__ */ new Error("execArgv requires a program")
2873
2937
  })) : run(argv.join(" "), argv[0], argv.slice(1), opts));
2938
+ const stream = (command, opts) => Stream.unwrap(Effect.map(spawn(options.shell ?? DEFAULT_SHELL, ["-c", command], opts), (handle) => Stream.concat(Stream.merge(handle.stdout.pipe(Stream.map((bytes) => ({
2939
+ _tag: "stdout",
2940
+ bytes
2941
+ }))), handle.stderr.pipe(Stream.map((bytes) => ({
2942
+ _tag: "stderr",
2943
+ bytes
2944
+ })))), Stream.fromEffect(Effect.map(handle.exitCode, (exitCode) => ({
2945
+ _tag: "exit",
2946
+ exitCode
2947
+ })))))).pipe(Stream.mapError((cause) => new shell_exports.ShellError({
2948
+ command,
2949
+ cause
2950
+ })));
2874
2951
  return shell_exports.Shell.of({
2875
2952
  exec,
2876
- execArgv
2953
+ execArgv,
2954
+ stream
2877
2955
  });
2878
2956
  }));
2879
2957
  //#endregion
@@ -3258,7 +3336,8 @@ const make$14 = Effect.fn("Sandbox.Controller.make")(function* (options) {
3258
3336
  id,
3259
3337
  driver: driver_exports.SandboxDriver.Name.make("local"),
3260
3338
  kind: "local",
3261
- cwd: io_exports.SandboxIO.resolveMountCwd(hostDefaultCwd, mountOptions.cwd)
3339
+ cwd: io_exports.SandboxIO.resolveMountCwd(hostDefaultCwd, mountOptions.cwd),
3340
+ spillPath: io_exports.SandboxIO.DEFAULT_SPILL_PATH
3262
3341
  };
3263
3342
  else {
3264
3343
  const row = yield* requireRow(id);
@@ -3280,7 +3359,8 @@ const make$14 = Effect.fn("Sandbox.Controller.make")(function* (options) {
3280
3359
  id,
3281
3360
  driver: attached.driver.name,
3282
3361
  kind: row.kind,
3283
- cwd: io_exports.SandboxIO.resolveMountCwd(attached.input.runtimeConfig.defaultCwd, mountOptions.cwd)
3362
+ cwd: io_exports.SandboxIO.resolveMountCwd(attached.input.runtimeConfig.defaultCwd, mountOptions.cwd),
3363
+ spillPath: attached.input.runtimeConfig.spillPath ?? io_exports.SandboxIO.DEFAULT_SPILL_PATH
3284
3364
  };
3285
3365
  const wasIdle = lease.held ? false : yield* gate.withPermits(1)(Effect.gen(function* () {
3286
3366
  const latest = yield* requireRow(id);
@@ -3327,7 +3407,7 @@ const make$14 = Effect.fn("Sandbox.Controller.make")(function* (options) {
3327
3407
  WHERE id = ${id}
3328
3408
  `.pipe(Effect.orDie);
3329
3409
  }
3330
- return Layer.mergeAll(Layer.succeed(io_exports.SandboxIO.Current, identity), Layer.succeed(io_exports.SandboxIO.FileSystem, filesystem_exports.SandboxFileSystem.withCwd(fs, identity.cwd)), Layer.succeed(io_exports.SandboxIO.Shell, (0, shell_exports.withCwd)(shell, identity.cwd)));
3410
+ return Layer.mergeAll(Layer.succeed(io_exports.SandboxIO.Current, identity), Layer.succeed(io_exports.SandboxIO.FileSystem, filesystem_exports.SandboxFileSystem.withCwd(fs, identity.cwd)), Layer.succeed(io_exports.SandboxIO.Shell, (0, shell_exports.withCwd)(shell, identity.cwd))).pipe(io_exports.SandboxIO.withMutation);
3331
3411
  }));
3332
3412
  const mount = (id, mountOptions) => mountLayer(id, mountOptions);
3333
3413
  const withMount = (id, use, mountOptions) => use.pipe(Effect.provide(mount(id, mountOptions)), Effect.scoped);
@@ -4277,7 +4357,7 @@ var Service$11 = class extends Context.Service()("@codeworksh/harness/repo/repo/
4277
4357
  * `.git` suffix and surrounding slashes stripped, scp form accepted.
4278
4358
  * `file:` urls and empty input yield undefined.
4279
4359
  */
4280
- function normalize$2(url) {
4360
+ function normalize$3(url) {
4281
4361
  const value = url.trim();
4282
4362
  if (!value) return void 0;
4283
4363
  try {
@@ -4319,7 +4399,7 @@ const layer$14 = Layer.effect(Service$11, Effect.gen(function* () {
4319
4399
  const marker = posix.join(repo.store, MARKER$1);
4320
4400
  const pinned = (yield* readFileSafe(fs, marker))?.trim() || void 0;
4321
4401
  const url = yield* git.remote(repo.directory);
4322
- const normalized = url === void 0 ? void 0 : normalize$2(url);
4402
+ const normalized = url === void 0 ? void 0 : normalize$3(url);
4323
4403
  const remote = normalized === void 0 ? void 0 : {
4324
4404
  id: fast(`git:${normalized}`),
4325
4405
  name: posix.basename(normalized)
@@ -5090,7 +5170,7 @@ function merge(base, ...patches) {
5090
5170
  return Object.fromEntries(result);
5091
5171
  }
5092
5172
  /** Normalize nullable properties before schema validation, without changing array positions. */
5093
- const normalize$1 = copy;
5173
+ const normalize$2 = copy;
5094
5174
  //#endregion
5095
5175
  //#region src/settings/settings.ts
5096
5176
  var settings_exports = /* @__PURE__ */ __exportAll({
@@ -5101,7 +5181,7 @@ var settings_exports = /* @__PURE__ */ __exportAll({
5101
5181
  layer: () => layer$8,
5102
5182
  load: () => load$3,
5103
5183
  modules: () => modules,
5104
- parse: () => parse$2,
5184
+ parse: () => parse$4,
5105
5185
  paths: () => paths,
5106
5186
  projectRoot: () => projectRoot,
5107
5187
  userConfigDir: () => userConfigDir
@@ -5115,7 +5195,7 @@ const userConfigDir = (raw, hostCwd) => hostPath.resolve(hostCwd, expandTilde(ra
5115
5195
  /** The settings files a directory may hold, in the order it is searched. */
5116
5196
  const settingsIn = (directory) => [hostPath.join(directory, "settings.jsonc"), hostPath.join(directory, "settings.json")];
5117
5197
  /** A directory and every ancestor above it, nearest first. */
5118
- const ancestors = (from) => {
5198
+ const ancestors$1 = (from) => {
5119
5199
  const chain = [];
5120
5200
  for (let directory = from;; directory = hostPath.dirname(directory)) {
5121
5201
  chain.push(directory);
@@ -5141,7 +5221,7 @@ const isDirectory = (path) => fileSystem.stat(path).pipe(Effect.map((info) => in
5141
5221
  */
5142
5222
  const projectRoot = Effect.fn("Settings.projectRoot")(function* (from, home) {
5143
5223
  const userConfig = hostPath.resolve(home);
5144
- for (const directory of ancestors(hostPath.resolve(from))) {
5224
+ for (const directory of ancestors$1(hostPath.resolve(from))) {
5145
5225
  const marker = hostPath.join(directory, appConfigDir);
5146
5226
  if (hostPath.resolve(marker) === userConfig) continue;
5147
5227
  if (yield* isDirectory(marker)) return directory;
@@ -5190,7 +5270,7 @@ const decodeFormatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (i
5190
5270
  const message = "annotations" in issue ? issue.annotations?.message : void 0;
5191
5271
  return typeof message === "string" ? message.replaceAll(/\s*\n\s*/g, " ") : "invalid value";
5192
5272
  } });
5193
- const parse$2 = Effect.fn("Settings.parse")(function* (path, source) {
5273
+ const parse$4 = Effect.fn("Settings.parse")(function* (path, source) {
5194
5274
  const errors = [];
5195
5275
  const json = parse(source, errors, {
5196
5276
  allowTrailingComma: true,
@@ -5202,7 +5282,7 @@ const parse$2 = Effect.fn("Settings.parse")(function* (path, source) {
5202
5282
  reason: "parse",
5203
5283
  detail: `${printParseErrorCode(first.error)} at ${at(source, first.offset)}`
5204
5284
  });
5205
- const document = normalize$1(json);
5285
+ const document = normalize$2(json);
5206
5286
  if (Predicate.isObject(document) && Predicate.isObject(json) && "plugins" in json) Object.assign(document, { plugins: json.plugins });
5207
5287
  return yield* Schema.decodeUnknownEffect(schema_exports$3.Patch)(document).pipe(Effect.mapError((error) => new SettingsError({
5208
5288
  path,
@@ -5238,7 +5318,7 @@ const attempt = (path) => fileSystem.readFileString(path).pipe(Effect.mapError((
5238
5318
  path,
5239
5319
  reason: "read",
5240
5320
  detail: error.reason._tag
5241
- })), Effect.flatMap((source) => parse$2(path, source)));
5321
+ })), Effect.flatMap((source) => parse$4(path, source)));
5242
5322
  /**
5243
5323
  * One read of every layer. Exported for the harness constructor, which needs the plugin
5244
5324
  * selection before any layer is built; everything else goes through `Service`.
@@ -5308,7 +5388,7 @@ var catalog_exports = /* @__PURE__ */ __exportAll({
5308
5388
  Credentials: () => Credentials,
5309
5389
  ModelCatalog: () => catalog_exports,
5310
5390
  RefreshError: () => RefreshError,
5311
- available: () => available,
5391
+ available: () => available$1,
5312
5392
  effective: () => effective,
5313
5393
  entry: () => entry,
5314
5394
  filename: () => filename,
@@ -5489,7 +5569,7 @@ const oauthProtocols = /* @__PURE__ */ new Set(["openai-codex", "github-copilot"
5489
5569
  * Presence, not validity: nothing is sent over the network, so a revoked key still counts. A
5490
5570
  * client showing a model picker should still add the session's current model.
5491
5571
  */
5492
- const available = Effect.fn("ModelCatalog.available")(function* (filter = {}) {
5572
+ const available$1 = Effect.fn("ModelCatalog.available")(function* (filter = {}) {
5493
5573
  const { authFile } = yield* Credentials;
5494
5574
  const settings = yield* Service$3;
5495
5575
  const catalog = yield* effective((yield* settings.load(filter.hostDir)).models);
@@ -5558,11 +5638,25 @@ const content = (raw) => {
5558
5638
  const text = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw;
5559
5639
  return text.trim().length === 0 ? void 0 : text;
5560
5640
  };
5561
- const first = Effect.fnUntraced(function* (fs, directory) {
5641
+ /** `directory` and every ancestor up to the sandbox root, closest first. */
5642
+ const ancestors = (directory) => {
5643
+ const chain = [];
5644
+ for (let current = directory;; current = posix.dirname(current)) {
5645
+ chain.push(current);
5646
+ if (posix.dirname(current) === current) return chain;
5647
+ }
5648
+ };
5649
+ /**
5650
+ * Which of `paths` are files. Absence is the expected answer and stays silent; any other failure
5651
+ * (permissions, a remote blip) is logged so a dropped file is not a mystery, then counts as absent.
5652
+ */
5653
+ const present$1 = (fs, paths) => Effect.forEach(paths, (path) => fs.stat(path).pipe(Effect.map((stat) => stat.isFile), Effect.catch((error) => filesystem_exports.SandboxFileSystem.isNotFoundError(error.cause) ? Effect.succeed(false) : Effect.logWarning(`could not stat instruction file ${path}`, error.cause).pipe(Effect.as(false)))), { concurrency: "unbounded" }).pipe(Effect.map((hits) => new Set(paths.filter((_, index) => hits[index]))));
5654
+ /** A file that stats but does not read falls through to the next candidate. */
5655
+ const first = Effect.fnUntraced(function* (fs, directory, files) {
5562
5656
  for (const name of names) {
5563
5657
  const path = posix.join(directory, name);
5564
- if (!(yield* fs.stat(path).pipe(Effect.map((stat) => stat.isFile), Effect.orElseSucceed(() => false)))) continue;
5565
- const raw = yield* fs.readFile(path).pipe(Effect.tapError((cause) => Effect.logWarning(`could not read instruction file ${path}`, cause)), Effect.option);
5658
+ if (!files.has(path)) continue;
5659
+ const raw = yield* fs.readFile(path).pipe(Effect.tapError((error) => Effect.logWarning(`could not read instruction file ${path}`, error.cause)), Effect.option);
5566
5660
  if (Option.isSome(raw)) return {
5567
5661
  name,
5568
5662
  path,
@@ -5583,29 +5677,26 @@ const shadowing = (location) => {
5583
5677
  const main = posix.dirname(store);
5584
5678
  return location.space.location.startsWith(`${main}/`) ? main : void 0;
5585
5679
  };
5680
+ /** One file per directory from the sandbox root down to the cwd, closest last so it has the final word. */
5681
+ const discover = Effect.fnUntraced(function* (fs, location) {
5682
+ const chain = ancestors(location.directory);
5683
+ const files = yield* present$1(fs, chain.flatMap((directory) => names.map((name) => posix.join(directory, name))));
5684
+ const found = yield* Effect.forEach(chain, (directory) => first(fs, directory, files), { concurrency: "unbounded" });
5685
+ const main = shadowing(location);
5686
+ const worktree = main === void 0 ? void 0 : found[chain.indexOf(location.space.location)];
5687
+ const walked = [];
5688
+ for (const [index, here] of found.entries()) {
5689
+ const shadowed = chain[index] === main && worktree !== void 0 && here?.name === worktree.name;
5690
+ if (here !== void 0 && !shadowed) walked.push(here);
5691
+ }
5692
+ return walked.reverse();
5693
+ });
5586
5694
  const instructionPlugin = (0, plugin_exports$1.define)({
5587
5695
  id: "codework.prompt.instruction",
5588
5696
  kind: "prompt",
5589
5697
  setup: Effect.fn("InstructionPlugin.setup")(function* (ctx) {
5590
5698
  const fs = yield* io_exports.SandboxIO.FileSystem;
5591
- const probed = /* @__PURE__ */ new Map();
5592
- const at = Effect.fnUntraced(function* (directory) {
5593
- if (!probed.has(directory)) probed.set(directory, yield* first(fs, directory));
5594
- return probed.get(directory);
5595
- });
5596
- const walked = [];
5597
- const main = shadowing(ctx.location);
5598
- const worktree = main === void 0 ? void 0 : yield* at(ctx.location.space.location);
5599
- let current = ctx.location.directory;
5600
- while (true) {
5601
- const here = yield* at(current);
5602
- const shadowed = current === main && worktree !== void 0 && here?.name === worktree.name;
5603
- if (here !== void 0 && !shadowed) walked.push(here);
5604
- const parent = posix.dirname(current);
5605
- if (parent === current) break;
5606
- current = parent;
5607
- }
5608
- const files = walked.reverse().filter((file) => file.content !== void 0);
5699
+ const files = (yield* discover(fs, ctx.location)).filter((file) => file.content !== void 0);
5609
5700
  if (files.length === 0) return;
5610
5701
  const prompt = ctx.plugin.prompt;
5611
5702
  prompt.sections.append(Section.ProjectContext, "Project-Specific Instructions & Guidelines:");
@@ -5626,6 +5717,17 @@ const instructionPlugin = (0, plugin_exports$1.define)({
5626
5717
  */
5627
5718
  const DEFAULT_MAX_LINES = 2e3;
5628
5719
  const DEFAULT_MAX_BYTES = 51200;
5720
+ /** Cut one line to `maxChars`, marking the cut. */
5721
+ function truncateLine(line, maxChars = 500) {
5722
+ if (line.length <= maxChars) return {
5723
+ text: line,
5724
+ wasTruncated: false
5725
+ };
5726
+ return {
5727
+ text: `${line.slice(0, maxChars)}... [truncated]`,
5728
+ wasTruncated: true
5729
+ };
5730
+ }
5629
5731
  function splitLinesForCounting(content) {
5630
5732
  if (content.length === 0) return [];
5631
5733
  const lines = content.split("\n");
@@ -5639,6 +5741,74 @@ function formatSize(bytes) {
5639
5741
  return `${(bytes / 1048576).toFixed(1)}MB`;
5640
5742
  }
5641
5743
  /**
5744
+ * Truncate content from the head (keep the first N lines/bytes). Suitable for
5745
+ * file reads where the beginning matters. Never returns partial lines; if the
5746
+ * first line alone exceeds the byte limit, returns empty content with
5747
+ * `firstLineExceedsLimit = true`.
5748
+ */
5749
+ function truncateHead(content, options = {}) {
5750
+ const maxLines = options.maxLines ?? 2e3;
5751
+ const maxBytes = options.maxBytes ?? 51200;
5752
+ const totalBytes = Buffer.byteLength(content, "utf-8");
5753
+ const lines = splitLinesForCounting(content);
5754
+ const totalLines = lines.length;
5755
+ if (totalLines <= maxLines && totalBytes <= maxBytes) return {
5756
+ content,
5757
+ truncated: false,
5758
+ truncatedBy: null,
5759
+ totalLines,
5760
+ totalBytes,
5761
+ outputLines: totalLines,
5762
+ outputBytes: totalBytes,
5763
+ lastLinePartial: false,
5764
+ firstLineExceedsLimit: false,
5765
+ maxLines,
5766
+ maxBytes
5767
+ };
5768
+ if (Buffer.byteLength(lines[0] ?? "", "utf-8") > maxBytes) return {
5769
+ content: "",
5770
+ truncated: true,
5771
+ truncatedBy: "bytes",
5772
+ totalLines,
5773
+ totalBytes,
5774
+ outputLines: 0,
5775
+ outputBytes: 0,
5776
+ lastLinePartial: false,
5777
+ firstLineExceedsLimit: true,
5778
+ maxLines,
5779
+ maxBytes
5780
+ };
5781
+ const outputLinesArr = [];
5782
+ let outputBytesCount = 0;
5783
+ let truncatedBy = "lines";
5784
+ for (let i = 0; i < lines.length && i < maxLines; i++) {
5785
+ const line = lines[i] ?? "";
5786
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0);
5787
+ if (outputBytesCount + lineBytes > maxBytes) {
5788
+ truncatedBy = "bytes";
5789
+ break;
5790
+ }
5791
+ outputLinesArr.push(line);
5792
+ outputBytesCount += lineBytes;
5793
+ }
5794
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) truncatedBy = "lines";
5795
+ const outputContent = outputLinesArr.join("\n");
5796
+ const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
5797
+ return {
5798
+ content: outputContent,
5799
+ truncated: true,
5800
+ truncatedBy,
5801
+ totalLines,
5802
+ totalBytes,
5803
+ outputLines: outputLinesArr.length,
5804
+ outputBytes: finalOutputBytes,
5805
+ lastLinePartial: false,
5806
+ firstLineExceedsLimit: false,
5807
+ maxLines,
5808
+ maxBytes
5809
+ };
5810
+ }
5811
+ /**
5642
5812
  * Truncate content from the tail (keep the last N lines/bytes). Suitable for
5643
5813
  * bash output where the end matters (errors, final results). May return a
5644
5814
  * partial first line if the last line of the original content exceeds the byte
@@ -5713,9 +5883,6 @@ function truncateStringToBytesFromEnd(str, maxBytes) {
5713
5883
  }
5714
5884
  //#endregion
5715
5885
  //#region src/tool/accumulator.ts
5716
- function defaultTempFilePath(prefix, suffix) {
5717
- return posix.join(tmpdir(), `${prefix}-${suffix}.log`);
5718
- }
5719
5886
  function byteLength(text) {
5720
5887
  return Buffer.byteLength(text, "utf-8");
5721
5888
  }
@@ -5723,9 +5890,7 @@ var Accumulator = class {
5723
5890
  maxLines;
5724
5891
  maxBytes;
5725
5892
  maxRollingBytes;
5726
- tempFilePrefix;
5727
5893
  decoder = new TextDecoder();
5728
- rawChunks = [];
5729
5894
  tailText = "";
5730
5895
  tailBytes = 0;
5731
5896
  tailStartsAtLineBoundary = true;
@@ -5736,45 +5901,43 @@ var Accumulator = class {
5736
5901
  currentLineBytes = 0;
5737
5902
  hasOpenLine = false;
5738
5903
  finished = false;
5739
- tempFilePath;
5740
- tempFile;
5741
5904
  constructor(options = {}) {
5742
5905
  this.maxLines = options.maxLines ?? 2e3;
5743
5906
  this.maxBytes = options.maxBytes ?? 51200;
5744
5907
  this.maxRollingBytes = Math.max(this.maxBytes * 2, 1);
5745
- this.tempFilePrefix = options.tempFilePrefix ?? "codework-output";
5746
- }
5747
- append(data) {
5748
- return Effect.suspend(() => {
5749
- if (this.finished) return Effect.die(/* @__PURE__ */ new Error("cannot append to a finished output accumulator"));
5750
- this.totalRawBytes += data.length;
5751
- this.appendDecodedText(this.decoder.decode(data, { stream: true }));
5752
- if (this.tempFile !== void 0 || this.shouldUseTempFile()) return Effect.gen({ self: this }, function* () {
5753
- yield* this.ensureTempFile();
5754
- yield* this.tempFile.writeAll(data).pipe(Effect.orDie);
5755
- });
5756
- if (data.length > 0) this.rawChunks.push(data);
5757
- return Effect.void;
5758
- });
5908
+ }
5909
+ /** `rawBytes` is what `data` measured before a backend decoded it, when that differs. */
5910
+ append(data, rawBytes = data.length) {
5911
+ if (this.finished) throw new Error("cannot append to a finished output accumulator");
5912
+ this.totalRawBytes += rawBytes;
5913
+ this.appendDecodedText(this.decoder.decode(data, { stream: true }));
5914
+ }
5915
+ /**
5916
+ * Count output that was produced but not delivered — the start of the output,
5917
+ * before anything is appended. Unless it is known to end a line, the first
5918
+ * appended line is treated as partial and left out of snapshots.
5919
+ */
5920
+ skip(bytes, newlines, endsLine) {
5921
+ if (bytes <= 0) return;
5922
+ this.totalRawBytes += bytes;
5923
+ this.totalDecodedBytes += bytes;
5924
+ this.completedLines += newlines;
5925
+ this.totalLines = this.completedLines;
5926
+ this.tailStartsAtLineBoundary = endsLine;
5759
5927
  }
5760
5928
  finish() {
5761
- return Effect.suspend(() => {
5762
- if (this.finished) return Effect.void;
5763
- this.finished = true;
5764
- this.appendDecodedText(this.decoder.decode());
5765
- return Effect.gen({ self: this }, function* () {
5766
- if (this.shouldUseTempFile()) yield* this.ensureTempFile();
5767
- if (this.tempFile !== void 0) yield* this.tempFile.sync.pipe(Effect.orDie);
5768
- });
5769
- });
5929
+ if (this.finished) return;
5930
+ this.finished = true;
5931
+ this.appendDecodedText(this.decoder.decode());
5770
5932
  }
5771
5933
  snapshot() {
5772
5934
  const tailTruncation = truncateTail(this.getSnapshotText(), {
5773
5935
  maxLines: this.maxLines,
5774
5936
  maxBytes: this.maxBytes
5775
5937
  });
5776
- const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes;
5777
- const truncatedBy = truncated ? tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes ? "bytes" : "lines") : null;
5938
+ const beyondLimits = this.totalLines > this.maxLines || this.totalRawBytes > this.maxBytes;
5939
+ const truncated = beyondLimits || tailTruncation.truncated;
5940
+ const truncatedBy = truncated ? tailTruncation.truncatedBy ?? (this.totalRawBytes > this.maxBytes ? "bytes" : "lines") : null;
5778
5941
  const truncation = {
5779
5942
  ...tailTruncation,
5780
5943
  truncated,
@@ -5787,7 +5950,7 @@ var Accumulator = class {
5787
5950
  return {
5788
5951
  content: truncation.content,
5789
5952
  truncation,
5790
- ...this.tempFilePath === void 0 ? {} : { fullOutputPath: this.tempFilePath }
5953
+ beyondLimits
5791
5954
  };
5792
5955
  }
5793
5956
  /** Bytes in the final (possibly unterminated) line — for the partial-line footer. */
@@ -5833,23 +5996,111 @@ var Accumulator = class {
5833
5996
  getSnapshotText() {
5834
5997
  if (this.tailStartsAtLineBoundary) return this.tailText;
5835
5998
  const firstNewline = this.tailText.indexOf("\n");
5836
- return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1);
5837
- }
5838
- shouldUseTempFile() {
5839
- return this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines;
5840
- }
5841
- ensureTempFile() {
5842
- if (this.tempFile !== void 0) return Effect.void;
5843
- return Effect.gen({ self: this }, function* () {
5844
- const suffix = Hex.encode(yield* crypto.randomBytes(8));
5845
- this.tempFilePath = defaultTempFilePath(this.tempFilePrefix, suffix);
5846
- this.tempFile = yield* fileSystem.open(this.tempFilePath, { flag: "w" });
5847
- for (const chunk of this.rawChunks) yield* this.tempFile.writeAll(chunk);
5848
- this.rawChunks = [];
5849
- }).pipe(Effect.orDie);
5999
+ return firstNewline === -1 || firstNewline === this.tailText.length - 1 ? this.tailText : this.tailText.slice(firstNewline + 1);
5850
6000
  }
5851
6001
  };
5852
6002
  //#endregion
6003
+ //#region src/tool/output.ts
6004
+ /** A new, unused output file path under `spillPath`. */
6005
+ const path$1 = (spillPath, prefix) => crypto.randomBytes(8).pipe(Effect.map((bytes) => posix.join(spillPath, `${prefix}-${Hex.encode(bytes)}.log`)), Effect.orDie);
6006
+ /** First line printed when the output file cannot be created; the command then runs unsaved. */
6007
+ const unsaved = (file) => `codework: output not saved, cannot create ${file}`;
6008
+ const HEADER = "codework-output";
6009
+ /** Twice the byte limit leaves room to find where the shown lines start. */
6010
+ const windowBytes = (limits) => limits.maxBytes * 2;
6011
+ /**
6012
+ * The wrapper. The command's combined output goes through `pipeline` into the
6013
+ * file; the file's size then decides whether it is kept, and `report` lines run
6014
+ * before an unkept file is removed.
6015
+ */
6016
+ const script = (command, file, limits, pipeline, report) => [
6017
+ `f=${(0, shell_exports.quote)(file)}`,
6018
+ "run() (",
6019
+ command,
6020
+ ")",
6021
+ `if mkdir -p ${(0, shell_exports.quote)(posix.dirname(file))} 2>/dev/null && (umask 077 2>/dev/null; set -C && : > "$f" && : > "$f.rc" && : > "$f.pid") 2>/dev/null; then`,
6022
+ " echo \"$$ $(ps -o pgid= -p $$ 2>/dev/null)\" > \"$f.pid\"",
6023
+ `\t${pipeline("{ run 2>&1; echo $? > \"$f.rc\"; }")}`,
6024
+ " rc=$(cat \"$f.rc\" 2>/dev/null); rm -f \"$f.rc\"",
6025
+ `\tb=$(wc -c "$f" | awk '{ print $1 }'); n=$(wc -l "$f" | awk '{ print $1 }'); l=$n`,
6026
+ `\t[ -s "$f" ] && [ "$(tail -c 1 "$f" | wc -l | awk '{ print $1 }')" = 0 ] && l=$((l + 1))`,
6027
+ `\tkeep=0; { [ "$b" -gt ${limits.maxBytes} ] || [ "$l" -gt ${limits.maxLines} ]; } && keep=1`,
6028
+ ...report.map((line) => `\t${line}`),
6029
+ " [ \"$keep\" = 1 ] || rm -f \"$f\"",
6030
+ " rm -f \"$f.pid\"; exit \"${rc:-1}\"",
6031
+ "fi",
6032
+ "rm -f \"$f\" \"$f.rc\" \"$f.pid\" 2>/dev/null",
6033
+ `printf '%s\\n' ${(0, shell_exports.quote)(unsaved(file))}`,
6034
+ "run 2>&1"
6035
+ ].join("\n");
6036
+ const header = new RegExp(`^${HEADER} (\\d+) (\\d+) ([01])(?: (\\d+))?$`, "m");
6037
+ const parse$3 = (match) => ({
6038
+ bytes: Number(match[1]),
6039
+ newlines: Number(match[2]),
6040
+ kept: match[3] === "1"
6041
+ });
6042
+ /**
6043
+ * For a backend that streams: all output still flows back as it is produced, and
6044
+ * a copy lands in the file. The report rides on stderr, the wrapper's own channel
6045
+ * since the command's is folded into stdout.
6046
+ */
6047
+ const streaming = (command, file, limits) => script(command, file, limits, (source) => `${source} | tee -a "$f"`, [`printf '${HEADER} %s %s %s\\n' "$b" "$n" "$keep" >&2`]);
6048
+ /** Read {@link streaming}'s stderr: the report, if it got that far, and anything else the wrapper said. */
6049
+ const report = (stderr) => {
6050
+ const match = header.exec(stderr);
6051
+ if (match === null) return {
6052
+ report: void 0,
6053
+ rest: stderr
6054
+ };
6055
+ return {
6056
+ report: parse$3(match),
6057
+ rest: stderr.slice(0, match.index) + stderr.slice(match.index + match[0].length).replace(/^\n/, "")
6058
+ };
6059
+ };
6060
+ /**
6061
+ * For a backend that returns output only at the end: the output goes to the
6062
+ * file, and only a header — the report plus the window's raw size — and a tail
6063
+ * window come back. The bytes before the window are skipped, and counted.
6064
+ */
6065
+ const buffered = (command, file, limits) => {
6066
+ const tail = `tail -n ${limits.maxLines} "$f" | tail -c ${windowBytes(limits)}`;
6067
+ return script(command, file, limits, (source) => `{ ${source} | cat; } >> "$f"`, [
6068
+ `w=$(${tail} | wc -c | awk '{ print $1 }')`,
6069
+ `printf '${HEADER} %s %s %s %s\\n' "$b" "$n" "$keep" "$w"`,
6070
+ tail
6071
+ ]);
6072
+ };
6073
+ /** Read {@link buffered}'s result; `undefined` when the output was not saved and came back whole. */
6074
+ const window = (stdout) => {
6075
+ const newline = stdout.indexOf("\n");
6076
+ const match = header.exec(newline === -1 ? stdout : stdout.slice(0, newline));
6077
+ if (match === null || match[4] === void 0) return void 0;
6078
+ return {
6079
+ ...parse$3(match),
6080
+ text: stdout.slice(newline + 1),
6081
+ textBytes: Number(match[4])
6082
+ };
6083
+ };
6084
+ /**
6085
+ * After a deadline or an interrupt the wrapper never reaches its own cleanup.
6086
+ * Kill what it started — its process group when it led one, else its process
6087
+ * tree, whole while the wrapper itself still runs — and remove the side files,
6088
+ * and the output file unless `keep`.
6089
+ */
6090
+ const cleanup = (file, keep) => [
6091
+ `f=${(0, shell_exports.quote)(file)}`,
6092
+ "set -- $(cat \"$f.pid\" 2>/dev/null)",
6093
+ `tree() { echo "$1"; for c in $(ps -A -o pid= -o ppid= 2>/dev/null | awk -v p="$1" '$2 == p { print $1 }'); do tree "$c"; done; }`,
6094
+ "if [ -n \"${1:-}\" ]; then",
6095
+ " pids=$(tree \"$1\")",
6096
+ " [ \"${2:-$1}\" = \"$1\" ] && kill -s KILL -- \"-$1\" 2>/dev/null",
6097
+ " kill -9 $pids 2>/dev/null",
6098
+ "fi",
6099
+ `rm -f -- ${keep ? "\"$f.rc\" \"$f.pid\"" : "\"$f\" \"$f.rc\" \"$f.pid\""}`
6100
+ ].join("\n");
6101
+ /** Remove an output file that {@link cleanup} kept. */
6102
+ const remove$1 = (file) => `rm -f -- ${(0, shell_exports.quote)(file)}`;
6103
+ //#endregion
5853
6104
  //#region src/tool/progress.ts
5854
6105
  var progress_exports = /* @__PURE__ */ __exportAll({});
5855
6106
  import * as import__codeworksh_plugin_tool_progress from "@codeworksh/plugin/tool/progress";
@@ -5859,16 +6110,11 @@ __reExport(progress_exports, import__codeworksh_plugin_tool_progress);
5859
6110
  /**
5860
6111
  * `ToolShell` — the tool-owned, cancellable command-execution capability the bash tool depends on.
5861
6112
  *
5862
- * It deliberately does **not** reuse `sandbox/Shell` directly: the local OS path
5863
- * provides no `Shell` at all, and `sandbox/Shell` models neither cancellation
5864
- * nor a timeout. `ToolShell` makes both part of the contract. Adapters honor the
5865
- * mid-flight process kill where the SDK allows it (local, Vercel) and fall back
5866
- * to the timeout where it does not (Daytona) — "kill where it's easy".
5867
- *
5868
- * Two modes, and Local has two variants:
5869
- * - Local / full OS shell → {@link local} (default)
5870
- * - Local / just-bash → {@link fromSandboxShell} over `EnvBash`
5871
- * - Remote / provider → {@link fromSandboxShell} over `EnvDaytona` / `EnvVercel`
6113
+ * It deliberately does **not** reuse `sandbox/Shell` directly: `sandbox/Shell`
6114
+ * models no timeout, and `ToolShell` makes it part of the contract. Every
6115
+ * backend reaches it through {@link fromSandboxShell}; the process dies
6116
+ * mid-flight where the backend can kill it (host, Vercel) and the call returns
6117
+ * at the deadline either way.
5872
6118
  */
5873
6119
  /** Infra/spawn failure — not model-actionable; handlers should treat it as a defect. */
5874
6120
  var ToolShellError = class extends Schema.TaggedError()("ToolShellError", {
@@ -5923,6 +6169,7 @@ const fromSandboxShell = Layer.effect(ToolShell, Effect.gen(function* () {
5923
6169
  exitCode: chunk.exitCode
5924
6170
  } : {
5925
6171
  _tag: "Output",
6172
+ channel: chunk._tag,
5926
6173
  bytes: chunk.bytes
5927
6174
  }), Stream.mapError((cause) => new ToolShellError({
5928
6175
  command,
@@ -5933,7 +6180,6 @@ const fromSandboxShell = Layer.effect(ToolShell, Effect.gen(function* () {
5933
6180
  stream
5934
6181
  } : { exec });
5935
6182
  }));
5936
- Duration.seconds(1);
5937
6183
  //#endregion
5938
6184
  //#region src/tool/tool.ts
5939
6185
  var tool_exports = /* @__PURE__ */ __exportAll({});
@@ -5943,28 +6189,32 @@ __reExport(tool_exports, import__codeworksh_plugin_tool);
5943
6189
  //#region src/plugin/builtin/tool/bash.ts
5944
6190
  /**
5945
6191
  * The self-contained bash plugin — a worked example for tool plugins. The definition is pure data;
5946
- * the handler depends only on {@link ToolShell} / {@link ToolProgress}, never
5947
- * `sandbox/Shell`, so the backend (local OS / just-bash / remote provider) is
5948
- * swapped by changing the provided Layer with no change to the tool.
6192
+ * the handler depends only on {@link ToolShell} / {@link ToolProgress} and the mount's
6193
+ * {@link SandboxIO.Current}, never `sandbox/Shell`, so the backend (host / just-bash / remote
6194
+ * provider) is swapped by changing the provided Layer with no change to the tool.
5949
6195
  *
5950
- * Two execution paths, picked by capability:
5951
- * - **variant A (buffered)** otherwise: `ToolShell.exec` returns the full output,
5952
- * truncated once after completion.
5953
- * - **variant B (streaming)** when the backend offers `ToolShell.stream`: output
5954
- * flows through an {@link Accumulator} (bounded memory + temp-file spill)
5955
- * and is reported live via {@link ToolProgress}; on timeout the partial output
5956
- * produced so far is preserved.
6196
+ * The command runs wrapped (`Output`) so its full output is written to a file inside the sandbox,
6197
+ * kept only when it exceeds the display limits. Two paths, picked by capability:
6198
+ * - **variant A (buffered)** otherwise: the sandbox returns only the tail of the output with its
6199
+ * size, so a huge output never crosses to the harness.
6200
+ * - **variant B (streaming)** when the backend offers `ToolShell.stream`: output flows through
6201
+ * an {@link Accumulator} (bounded memory) and is reported live via {@link ToolProgress}; on
6202
+ * timeout the partial output produced so far is preserved.
5957
6203
  *
5958
6204
  * Success and failure carry the *same* structured shape
5959
6205
  * (`output`, `exitCode`, `truncated`, `fullOutputPath`)
5960
6206
  * — a non-zero exit is just `exitCode !== 0`, not a different kind of result.
5961
6207
  */
6208
+ const limits = {
6209
+ maxLines: DEFAULT_MAX_LINES,
6210
+ maxBytes: DEFAULT_MAX_BYTES
6211
+ };
5962
6212
  const BashParams = Schema.Struct({
5963
6213
  command: Schema.String.annotate({ description: "The bash command to execute." }),
5964
6214
  timeout: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0)).annotate({ description: "Optional timeout in seconds (must be greater than 0)." }))
5965
6215
  });
5966
6216
  const outputFields = {
5967
- /** Combined stdout + stderr, truncated for display (full output is on disk when `truncated`). */
6217
+ /** Combined stdout + stderr, truncated for display (full output is in the sandbox when `truncated`). */
5968
6218
  output: Schema.String,
5969
6219
  truncated: Schema.Boolean,
5970
6220
  fullOutputPath: Schema.optional(Schema.String)
@@ -5984,21 +6234,30 @@ var BashTimedOut = class extends Schema.TaggedError()("BashTimedOut", {
5984
6234
  timeoutSeconds: Schema.Finite
5985
6235
  }) {};
5986
6236
  const BashFailure = Schema.Union([BashFailed, BashTimedOut]);
6237
+ /** The reason for a failure, after any output: the model sees nothing else of `details`. */
6238
+ const withStatus = (failure) => {
6239
+ const status = failure._tag === "BashTimedOut" ? `Command timed out after ${failure.timeoutSeconds} seconds` : failure.exitCode === -1 ? "Command terminated without an exit code" : `Command exited with code ${failure.exitCode}`;
6240
+ return failure.output ? `${failure.output.replace(/\n$/, "")}\n\n${status}` : status;
6241
+ };
5987
6242
  const bashDef = tool_exports.define({
5988
6243
  name: "bash",
5989
6244
  label: "bash",
5990
6245
  promptSnippet: "Execute bash commands (ls, grep, find, etc.).",
5991
- description: `Execute a bash command in the working directory and return its combined stdout/stderr output. Output is truncated to the last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES}KB (whichever is hit first); when truncated, the full output is saved to a temp file. A non-zero exit code is reported as an error carrying the captured output.`,
6246
+ description: `Execute a bash command in the working directory and return its combined stdout/stderr output. Output is truncated to the last ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first); when truncated, a note says which lines are shown and gives the path of a file holding the full output when one was saved. A non-zero exit code is reported as an error carrying the captured output.`,
5992
6247
  parameters: BashParams,
6248
+ constrainedSampling: {
6249
+ type: "json_schema",
6250
+ strict: "prefer"
6251
+ },
5993
6252
  success: BashSuccess,
5994
6253
  failure: BashFailure,
5995
6254
  encodeContent: (success) => [{
5996
6255
  type: "text",
5997
- text: success.output
6256
+ text: success.output || "(no output)"
5998
6257
  }],
5999
6258
  encodeFailureContent: (failure) => [{
6000
6259
  type: "text",
6001
- text: failure.output
6260
+ text: withStatus(failure)
6002
6261
  }]
6003
6262
  });
6004
6263
  /** Combine stdout and stderr into one stream of text, stderr after stdout. */
@@ -6018,66 +6277,78 @@ const footer = (t, fullOutputPath, lastLineBytes) => {
6018
6277
  if (t.truncatedBy === "lines") return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines}.${where}]`;
6019
6278
  return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines} (${formatSize(t.maxBytes)} limit).${where}]`;
6020
6279
  };
6021
- /** Write full output to a host temp file (best-effort; undefined on failure). */
6022
- const spillToTempFile = (content) => Effect.gen(function* () {
6023
- const suffix = Hex.encode(yield* crypto.randomBytes(6));
6024
- const path = posix.join(tmpdir(), `codework-bash-${suffix}.log`);
6025
- yield* fileSystem.writeFileString(path, content);
6026
- return path;
6027
- }).pipe(Effect.orElseSucceed(() => void 0));
6028
- /** Variant A: truncate the buffered output once, spilling the full output if truncated. */
6029
- const presentBuffered = (combined) => Effect.gen(function* () {
6030
- const t = truncateTail(combined);
6031
- if (!t.truncated) return {
6032
- output: t.content,
6033
- truncated: false
6034
- };
6035
- const fullOutputPath = yield* spillToTempFile(combined);
6036
- return {
6037
- output: t.content + footer(t, fullOutputPath),
6038
- truncated: true,
6039
- ...fullOutputPath !== void 0 ? { fullOutputPath } : {}
6040
- };
6041
- });
6042
- /** Variant B: present an accumulator snapshot (the temp file is spilled incrementally). */
6043
- const presentSnapshot = (snap, lastLineBytes) => {
6280
+ /** Present a snapshot; `saved` is the output file, when the sandbox kept one. */
6281
+ const present = (snap, lastLineBytes, saved) => {
6044
6282
  if (!snap.truncation.truncated) return {
6045
6283
  output: snap.content,
6046
6284
  truncated: false
6047
6285
  };
6048
6286
  return {
6049
- output: snap.content + footer(snap.truncation, snap.fullOutputPath, lastLineBytes),
6287
+ output: snap.content.replace(/\n$/, "") + footer(snap.truncation, saved, lastLineBytes),
6050
6288
  truncated: true,
6051
- ...snap.fullOutputPath !== void 0 ? { fullOutputPath: snap.fullOutputPath } : {}
6289
+ ...saved !== void 0 ? { fullOutputPath: saved } : {}
6052
6290
  };
6053
6291
  };
6054
- /** Variant A — buffered: one `exec`, truncate after completion. No live progress. */
6055
- const runBuffered = (shell, params) => Effect.gen(function* () {
6056
- const result = yield* shell.exec(params.command, params.timeout !== void 0 ? { timeout: Duration.seconds(params.timeout) } : void 0).pipe(Effect.catchTags({
6057
- ToolShellTimeout: (timeout) => Effect.fail(new BashTimedOut({
6292
+ const utf8 = new TextEncoder();
6293
+ /**
6294
+ * A command cut off by its deadline never reached the wrapper's own cleanup: kill
6295
+ * what is left of it and remove its side files, keeping the output file only when
6296
+ * the result points at it.
6297
+ */
6298
+ const cleanUp = (shell, file, presented) => sweep(shell, cleanup(file, presented.fullOutputPath !== void 0));
6299
+ /** Best-effort housekeeping script: bounded, and never a failure of the tool. */
6300
+ const sweep = (shell, script) => shell.exec(script, { timeout: Duration.seconds(5) }).pipe(Effect.ignore);
6301
+ /** Variant A — buffered: one `exec` that returns the tail of the output and its size. No live progress. */
6302
+ const runBuffered = (shell, params, file) => Effect.gen(function* () {
6303
+ const result = yield* shell.exec(buffered(params.command, file, limits), params.timeout !== void 0 ? { timeout: Duration.seconds(params.timeout) } : void 0).pipe(Effect.catchTags({
6304
+ ToolShellTimeout: (timeout) => cleanUp(shell, file, {
6305
+ output: "",
6306
+ truncated: false
6307
+ }).pipe(Effect.andThen(Effect.fail(new BashTimedOut({
6058
6308
  timeoutSeconds: timeout.timeoutMillis / 1e3,
6059
6309
  output: "",
6060
6310
  truncated: false
6061
- })),
6311
+ })))),
6062
6312
  ToolShellError: (cause) => Effect.die(cause)
6063
6313
  }));
6064
- const present = yield* presentBuffered(combineOutput(result.stdout, result.stderr));
6314
+ const acc = new Accumulator(limits);
6315
+ const combined = combineOutput(result.stdout, result.stderr);
6316
+ const window$1 = window(combined);
6317
+ if (window$1 === void 0) acc.append(utf8.encode(combined));
6318
+ else {
6319
+ acc.skip(window$1.bytes - window$1.textBytes, window$1.newlines - countNewlines(window$1.text), window$1.textBytes < windowBytes(limits));
6320
+ acc.append(utf8.encode(window$1.text), window$1.textBytes);
6321
+ }
6322
+ acc.finish();
6323
+ const presented = present(acc.snapshot(), acc.getLastLineBytes(), window$1?.kept === true ? file : void 0);
6065
6324
  if (result.exitCode !== 0) return yield* new BashFailed({
6066
6325
  exitCode: result.exitCode,
6067
- ...present
6326
+ ...presented
6068
6327
  });
6069
6328
  return {
6070
6329
  exitCode: result.exitCode,
6071
- ...present
6330
+ ...presented
6072
6331
  };
6073
6332
  });
6333
+ const countNewlines = (text) => {
6334
+ let count = 0;
6335
+ for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) count++;
6336
+ return count;
6337
+ };
6074
6338
  /** Variant B — streaming: accumulate output, report progress, keep partial output on timeout. */
6075
- const runStreaming = (stream, params) => Effect.scoped(Effect.gen(function* () {
6076
- const acc = new Accumulator({ tempFilePrefix: "codework-bash" });
6339
+ const runStreaming = (shell, stream, params, file) => Effect.gen(function* () {
6340
+ const acc = new Accumulator(limits);
6077
6341
  const progress = yield* progress_exports.ToolProgress;
6078
6342
  const exitCode = yield* Ref.make(null);
6079
- const consume = stream(params.command).pipe(Stream.runForEach((event) => event._tag === "Exit" ? Ref.set(exitCode, event.exitCode) : Effect.gen(function* () {
6080
- yield* acc.append(Buffer.from(event.bytes));
6343
+ const notice = unsaved(file);
6344
+ let head = "";
6345
+ const control = new TextDecoder();
6346
+ let said = "";
6347
+ const consume = stream(streaming(params.command, file, limits)).pipe(Stream.runForEach((event) => event._tag === "Exit" ? Ref.set(exitCode, event.exitCode) : event.channel === "stderr" ? Effect.sync(() => {
6348
+ said += control.decode(event.bytes, { stream: true });
6349
+ }) : Effect.gen(function* () {
6350
+ if (head.length < notice.length) head += new TextDecoder().decode(event.bytes.slice(0, notice.length));
6351
+ acc.append(event.bytes);
6081
6352
  yield* progress.report({ content: [{
6082
6353
  type: "text",
6083
6354
  text: acc.snapshot().content
@@ -6085,28 +6356,45 @@ const runStreaming = (stream, params) => Effect.scoped(Effect.gen(function* () {
6085
6356
  })), Effect.catchTag("ToolShellError", (cause) => Effect.die(cause)));
6086
6357
  let timedOutAfter;
6087
6358
  if (params.timeout !== void 0) {
6088
- const finished = yield* consume.pipe(Effect.timeoutOption(Duration.seconds(params.timeout)));
6089
- if (Option.isNone(finished)) timedOutAfter = params.timeout;
6359
+ const fiber = yield* Effect.forkChild(consume);
6360
+ const finished = yield* Fiber.await(fiber).pipe(Effect.timeoutOption(Duration.seconds(params.timeout)));
6361
+ if (Option.isSome(finished)) yield* Fiber.join(fiber);
6362
+ else {
6363
+ timedOutAfter = params.timeout;
6364
+ yield* sweep(shell, cleanup(file, true));
6365
+ yield* Fiber.interrupt(fiber);
6366
+ }
6090
6367
  } else yield* consume;
6091
- yield* acc.finish();
6092
- const present = presentSnapshot(acc.snapshot(), acc.getLastLineBytes());
6093
- if (timedOutAfter !== void 0) return yield* new BashTimedOut({
6094
- timeoutSeconds: timedOutAfter,
6095
- ...present
6096
- });
6368
+ const { report: report$1, rest } = report(said + control.decode());
6369
+ if (rest.length > 0) acc.append(utf8.encode(rest));
6370
+ acc.finish();
6371
+ const snapshot = acc.snapshot();
6372
+ const kept = report$1 !== void 0 ? report$1.kept : snapshot.beyondLimits && !head.startsWith(notice);
6373
+ const presented = present(snapshot, acc.getLastLineBytes(), kept ? file : void 0);
6374
+ if (timedOutAfter !== void 0) {
6375
+ if (!kept) yield* sweep(shell, remove$1(file));
6376
+ return yield* new BashTimedOut({
6377
+ timeoutSeconds: timedOutAfter,
6378
+ ...presented
6379
+ });
6380
+ }
6097
6381
  const code = yield* Ref.get(exitCode);
6098
6382
  if (code === null || code !== 0) return yield* new BashFailed({
6099
6383
  exitCode: code ?? -1,
6100
- ...present
6384
+ ...presented
6101
6385
  });
6102
6386
  return {
6103
6387
  exitCode: code,
6104
- ...present
6388
+ ...presented
6105
6389
  };
6106
- }));
6390
+ });
6107
6391
  const bashHandler = (params) => Effect.gen(function* () {
6108
6392
  const shell = yield* ToolShell;
6109
- return shell.stream !== void 0 ? yield* runStreaming(shell.stream, params) : yield* runBuffered(shell, params);
6393
+ const file = yield* path$1((yield* io_exports.SandboxIO.Current).spillPath, "codework-bash");
6394
+ return yield* (shell.stream !== void 0 ? runStreaming(shell, shell.stream, params, file) : runBuffered(shell, params, file)).pipe(Effect.onInterrupt(() => cleanUp(shell, file, {
6395
+ output: "",
6396
+ truncated: false
6397
+ })));
6110
6398
  });
6111
6399
  /** The bash tool: definition + handler, wired the testable (def/exec split) way. */
6112
6400
  const bashTool = tool_exports.implement(bashDef, bashHandler);
@@ -6115,18 +6403,1402 @@ const bashPlugin = (0, plugin_exports$1.define)({
6115
6403
  kind: "tool",
6116
6404
  setup: Effect.fn("BashPlugin.setup")(function* (ctx) {
6117
6405
  const shell = yield* io_exports.SandboxIO.Shell;
6118
- const mounted = fromSandboxShell.pipe(Layer.provide(Layer.succeed(io_exports.SandboxIO.Shell, shell)));
6406
+ const current = yield* io_exports.SandboxIO.Current;
6407
+ const mounted = Layer.merge(fromSandboxShell.pipe(Layer.provide(Layer.succeed(io_exports.SandboxIO.Shell, shell))), Layer.succeed(io_exports.SandboxIO.Current, current));
6119
6408
  ctx.plugin.tools.add(tool_exports.provide(bashTool, mounted));
6120
6409
  })
6121
6410
  });
6122
6411
  //#endregion
6412
+ //#region src/tool/diff.ts
6413
+ const detectLineEnding = (content) => {
6414
+ const crlfIdx = content.indexOf("\r\n");
6415
+ const lfIdx = content.indexOf("\n");
6416
+ if (lfIdx === -1 || crlfIdx === -1) return "\n";
6417
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
6418
+ };
6419
+ const normalizeToLF = (text) => text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
6420
+ const restoreLineEndings = (text, ending) => ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
6421
+ /** Strip a UTF-8 BOM if present; the BOM is written back after editing. */
6422
+ const stripBom = (content) => content.startsWith("") ? {
6423
+ bom: "",
6424
+ text: content.slice(1)
6425
+ } : {
6426
+ bom: "",
6427
+ text: content
6428
+ };
6429
+ /**
6430
+ * The form both sides are compared in when the exact text is not found:
6431
+ * NFKC, no trailing whitespace per line, ASCII quotes, dashes and spaces.
6432
+ */
6433
+ const normalizeForFuzzyMatch = (text) => text.normalize("NFKC").split("\n").map((line) => line.trimEnd()).join("\n").replace(/[‘’‚‛]/g, "'").replace(/[“”„‟]/g, "\"").replace(/[‐‑‒–—―−]/g, "-").replace(/[  -    ]/g, " ");
6434
+ const splitLinesWithEndings = (content) => content.match(/[^\n]*\n|[^\n]+/g) ?? [];
6435
+ const getLineSpans = (content) => {
6436
+ let offset = 0;
6437
+ return splitLinesWithEndings(content).map((line) => {
6438
+ const span = {
6439
+ start: offset,
6440
+ end: offset + line.length
6441
+ };
6442
+ offset = span.end;
6443
+ return span;
6444
+ });
6445
+ };
6446
+ const getReplacementLineRange = (lines, replacement) => {
6447
+ const replacementStart = replacement.matchIndex;
6448
+ const replacementEnd = replacement.matchIndex + replacement.matchLength;
6449
+ const startLine = lines.findIndex((line) => replacementStart >= line.start && replacementStart < line.end);
6450
+ if (startLine === -1) throw new Error("Replacement range is outside the base content.");
6451
+ let endLine = startLine;
6452
+ while (endLine < lines.length && lines[endLine].end < replacementEnd) endLine++;
6453
+ if (endLine >= lines.length) throw new Error("Replacement range is outside the base content.");
6454
+ return {
6455
+ startLine,
6456
+ endLine: endLine + 1
6457
+ };
6458
+ };
6459
+ const applyReplacements = (content, replacements, offset = 0) => {
6460
+ let result = content;
6461
+ for (let i = replacements.length - 1; i >= 0; i--) {
6462
+ const replacement = replacements[i];
6463
+ const matchIndex = replacement.matchIndex - offset;
6464
+ result = result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
6465
+ }
6466
+ return result;
6467
+ };
6468
+ /**
6469
+ * Apply replacements matched against `baseContent`, a normalized view of
6470
+ * `originalContent`: the lines a replacement touches are rewritten from the
6471
+ * normalized base, every other line is copied back from the original. The
6472
+ * replacement ranges drive this, so duplicate normalized lines cannot be
6473
+ * aligned to the wrong occurrence.
6474
+ */
6475
+ const applyReplacementsPreservingUnchangedLines = (originalContent, baseContent, replacements) => {
6476
+ const originalLines = splitLinesWithEndings(originalContent);
6477
+ const baseLines = getLineSpans(baseContent);
6478
+ if (originalLines.length !== baseLines.length) throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
6479
+ const groups = [];
6480
+ for (const replacement of [...replacements].sort((a, b) => a.matchIndex - b.matchIndex)) {
6481
+ const range = getReplacementLineRange(baseLines, replacement);
6482
+ const current = groups[groups.length - 1];
6483
+ if (current && range.startLine < current.endLine) {
6484
+ current.endLine = Math.max(current.endLine, range.endLine);
6485
+ current.replacements.push(replacement);
6486
+ continue;
6487
+ }
6488
+ groups.push({
6489
+ ...range,
6490
+ replacements: [replacement]
6491
+ });
6492
+ }
6493
+ let originalLineIndex = 0;
6494
+ let result = "";
6495
+ for (const group of groups) {
6496
+ result += originalLines.slice(originalLineIndex, group.startLine).join("");
6497
+ const groupStartOffset = baseLines[group.startLine].start;
6498
+ const groupEndOffset = baseLines[group.endLine - 1].end;
6499
+ result += applyReplacements(baseContent.slice(groupStartOffset, groupEndOffset), group.replacements, groupStartOffset);
6500
+ originalLineIndex = group.endLine;
6501
+ }
6502
+ return result + originalLines.slice(originalLineIndex).join("");
6503
+ };
6504
+ /** Find `oldText`, exactly first, then in fuzzy-normalized space. */
6505
+ const fuzzyFindText = (content, oldText) => {
6506
+ const exactIndex = content.indexOf(oldText);
6507
+ if (exactIndex !== -1) return {
6508
+ found: true,
6509
+ index: exactIndex,
6510
+ matchLength: oldText.length,
6511
+ usedFuzzyMatch: false,
6512
+ contentForReplacement: content
6513
+ };
6514
+ const fuzzyContent = normalizeForFuzzyMatch(content);
6515
+ const fuzzyOldText = normalizeForFuzzyMatch(oldText);
6516
+ const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
6517
+ if (fuzzyIndex === -1) return {
6518
+ found: false,
6519
+ index: -1,
6520
+ matchLength: 0,
6521
+ usedFuzzyMatch: false,
6522
+ contentForReplacement: content
6523
+ };
6524
+ return {
6525
+ found: true,
6526
+ index: fuzzyIndex,
6527
+ matchLength: fuzzyOldText.length,
6528
+ usedFuzzyMatch: true,
6529
+ contentForReplacement: fuzzyContent
6530
+ };
6531
+ };
6532
+ const countOccurrences = (content, oldText) => normalizeForFuzzyMatch(content).split(normalizeForFuzzyMatch(oldText)).length - 1;
6533
+ const notFound$1 = (path, editIndex, totalEdits) => totalEdits === 1 ? `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.` : `Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`;
6534
+ const duplicate = (path, editIndex, totalEdits, occurrences) => totalEdits === 1 ? `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.` : `Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`;
6535
+ const emptyOldText = (path, editIndex, totalEdits) => totalEdits === 1 ? `oldText must not be empty in ${path}.` : `edits[${editIndex}].oldText must not be empty in ${path}.`;
6536
+ const noChange = (path, totalEdits) => totalEdits === 1 ? `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.` : `No changes made to ${path}. The replacements produced identical content.`;
6537
+ /**
6538
+ * Apply exact-text replacements to LF-normalized content. Every edit is matched
6539
+ * against the same original, then applied last to first so offsets stay valid.
6540
+ * If any edit needs fuzzy matching, all run in fuzzy space and only the touched
6541
+ * lines are written from it. Throws, with the message the model reads, when an
6542
+ * edit is empty, missing, ambiguous, overlapping, or changes nothing.
6543
+ */
6544
+ const applyEditsToNormalizedContent = (normalizedContent, edits, path) => {
6545
+ const normalizedEdits = edits.map((edit) => ({
6546
+ oldText: normalizeToLF(edit.oldText),
6547
+ newText: normalizeToLF(edit.newText)
6548
+ }));
6549
+ normalizedEdits.forEach((edit, i) => {
6550
+ if (edit.oldText.length === 0) throw new Error(emptyOldText(path, i, normalizedEdits.length));
6551
+ });
6552
+ const usedFuzzyMatch = normalizedEdits.some((edit) => fuzzyFindText(normalizedContent, edit.oldText).usedFuzzyMatch);
6553
+ const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
6554
+ const matchedEdits = normalizedEdits.map((edit, i) => {
6555
+ const match = fuzzyFindText(replacementBaseContent, edit.oldText);
6556
+ if (!match.found) throw new Error(notFound$1(path, i, normalizedEdits.length));
6557
+ const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
6558
+ if (occurrences > 1) throw new Error(duplicate(path, i, normalizedEdits.length, occurrences));
6559
+ return {
6560
+ editIndex: i,
6561
+ matchIndex: match.index,
6562
+ matchLength: match.matchLength,
6563
+ newText: edit.newText
6564
+ };
6565
+ });
6566
+ matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
6567
+ for (let i = 1; i < matchedEdits.length; i++) {
6568
+ const previous = matchedEdits[i - 1];
6569
+ const current = matchedEdits[i];
6570
+ if (previous.matchIndex + previous.matchLength > current.matchIndex) throw new Error(`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`);
6571
+ }
6572
+ const newContent = usedFuzzyMatch ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits) : applyReplacements(replacementBaseContent, matchedEdits);
6573
+ if (normalizedContent === newContent) throw new Error(noChange(path, normalizedEdits.length));
6574
+ return {
6575
+ baseContent: normalizedContent,
6576
+ newContent
6577
+ };
6578
+ };
6579
+ /** A standard unified patch. */
6580
+ const generateUnifiedPatch = (path, oldContent, newContent, contextLines = 4) => Diff.createTwoFilesPatch(path, path, oldContent, newContent, void 0, void 0, {
6581
+ context: contextLines,
6582
+ headerOptions: Diff.FILE_HEADERS_ONLY
6583
+ });
6584
+ /**
6585
+ * A display diff with line numbers and `contextLines` of context around each
6586
+ * change, plus the first changed line in the new file.
6587
+ */
6588
+ const generateDiffString = (oldContent, newContent, contextLines = 4) => {
6589
+ const parts = Diff.diffLines(oldContent, newContent);
6590
+ const output = [];
6591
+ const lineNumWidth = String(Math.max(oldContent.split("\n").length, newContent.split("\n").length)).length;
6592
+ const numbered = (prefix, lineNum, line) => `${prefix}${String(lineNum).padStart(lineNumWidth, " ")} ${line}`;
6593
+ const ellipsis = ` ${"".padStart(lineNumWidth, " ")} ...`;
6594
+ let oldLineNum = 1;
6595
+ let newLineNum = 1;
6596
+ let lastWasChange = false;
6597
+ let firstChangedLine;
6598
+ parts.forEach((part, i) => {
6599
+ const raw = part.value.split("\n");
6600
+ if (raw[raw.length - 1] === "") raw.pop();
6601
+ if (part.added || part.removed) {
6602
+ firstChangedLine ??= newLineNum;
6603
+ for (const line of raw) if (part.added) output.push(numbered("+", newLineNum++, line));
6604
+ else output.push(numbered("-", oldLineNum++, line));
6605
+ lastWasChange = true;
6606
+ return;
6607
+ }
6608
+ const context = (lines) => {
6609
+ for (const line of lines) {
6610
+ output.push(numbered(" ", oldLineNum, line));
6611
+ oldLineNum++;
6612
+ newLineNum++;
6613
+ }
6614
+ };
6615
+ const skip = (count) => {
6616
+ output.push(ellipsis);
6617
+ oldLineNum += count;
6618
+ newLineNum += count;
6619
+ };
6620
+ const next = parts[i + 1];
6621
+ const hasTrailingChange = next !== void 0 && (next.added || next.removed);
6622
+ if (lastWasChange && hasTrailingChange) {
6623
+ if (raw.length <= contextLines * 2) context(raw);
6624
+ else {
6625
+ context(raw.slice(0, contextLines));
6626
+ skip(raw.length - contextLines * 2);
6627
+ context(raw.slice(raw.length - contextLines));
6628
+ }
6629
+ } else if (lastWasChange) {
6630
+ context(raw.slice(0, contextLines));
6631
+ if (raw.length > contextLines) skip(raw.length - contextLines);
6632
+ } else if (hasTrailingChange) {
6633
+ const skipped = Math.max(0, raw.length - contextLines);
6634
+ if (skipped > 0) skip(skipped);
6635
+ context(raw.slice(skipped));
6636
+ } else {
6637
+ oldLineNum += raw.length;
6638
+ newLineNum += raw.length;
6639
+ }
6640
+ lastWasChange = false;
6641
+ });
6642
+ return {
6643
+ diff: output.join("\n"),
6644
+ firstChangedLine
6645
+ };
6646
+ };
6647
+ //#endregion
6648
+ //#region src/tool/path.ts
6649
+ /**
6650
+ * How file tools read the path a model wrote. Models copy paths out of prose and
6651
+ * terminals, so a path arrives with Unicode spaces or an `@` mention prefix the
6652
+ * file never had. Relative paths stay relative: the mount resolves them.
6653
+ */
6654
+ const UNICODE_SPACES = /[  -    ]/g;
6655
+ const NARROW_NO_BREAK_SPACE = " ";
6656
+ /** The path a file tool acts on. */
6657
+ const normalize$1 = (path) => {
6658
+ const normalized = path.replace(UNICODE_SPACES, " ");
6659
+ return normalized.startsWith("@") ? normalized.slice(1) : normalized;
6660
+ };
6661
+ /**
6662
+ * The path to read: {@link normalize}d, or the first existing spelling the model
6663
+ * could not have typed — macOS screenshot names use a narrow no-break space
6664
+ * before AM/PM, decomposed (NFD) accents, and curly apostrophes. Falls back to
6665
+ * the normalized path, so a missing file is reported under the name asked for.
6666
+ */
6667
+ const resolveRead = (fs, path) => Effect.gen(function* () {
6668
+ const normalized = normalize$1(path);
6669
+ const variants = /* @__PURE__ */ new Set([
6670
+ normalized,
6671
+ normalized.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`),
6672
+ normalized.normalize("NFD"),
6673
+ normalized.replace(/'/g, "’"),
6674
+ normalized.normalize("NFD").replace(/'/g, "’")
6675
+ ]);
6676
+ for (const variant of variants) if (yield* fs.exists(variant)) return variant;
6677
+ return normalized;
6678
+ });
6679
+ //#endregion
6680
+ //#region src/plugin/builtin/tool/edit.ts
6681
+ /**
6682
+ * The built-in edit plugin: exact-text replacements, all matched against the
6683
+ * original file, under the file's mutation lock for the whole read → match →
6684
+ * write. The model reads a one-line summary; `details` carries the diff.
6685
+ */
6686
+ const ReplaceEdit = Schema.Struct({
6687
+ oldText: Schema.String.annotate({ description: "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call." }),
6688
+ newText: Schema.String.annotate({ description: "Replacement text for this targeted edit." })
6689
+ });
6690
+ const EditParams = Schema.Struct({
6691
+ path: Schema.String.annotate({ description: "Path to the file to edit (relative or absolute)" }),
6692
+ edits: Schema.Array(ReplaceEdit).annotate({ description: "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead." })
6693
+ });
6694
+ const EditSuccess = Schema.Struct({
6695
+ path: Schema.String,
6696
+ replaced: Schema.Finite,
6697
+ diff: Schema.String,
6698
+ patch: Schema.String,
6699
+ firstChangedLine: Schema.optional(Schema.Finite)
6700
+ });
6701
+ var EditFailed = class extends Schema.TaggedError()("EditFailed", { message: Schema.String }) {};
6702
+ const isSingleEdit = (value) => {
6703
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
6704
+ const edit = value;
6705
+ return typeof edit.oldText === "string" && typeof edit.newText === "string";
6706
+ };
6707
+ /**
6708
+ * Repair shapes models commonly send: `edits` as a JSON string or as a single
6709
+ * edit object, and a top-level `oldText`/`newText` pair. Works on a copy; the
6710
+ * call's arguments stay unchanged.
6711
+ */
6712
+ const prepareEditArguments = (input) => {
6713
+ if (!input || typeof input !== "object" || Array.isArray(input)) return input;
6714
+ const args = { ...input };
6715
+ if (typeof args.edits === "string") try {
6716
+ const parsed = JSON.parse(args.edits);
6717
+ if (Array.isArray(parsed)) args.edits = parsed;
6718
+ else if (isSingleEdit(parsed)) args.edits = [parsed];
6719
+ } catch {}
6720
+ else if (isSingleEdit(args.edits)) args.edits = [args.edits];
6721
+ if (typeof args.oldText !== "string" || typeof args.newText !== "string") return args;
6722
+ const edits = Array.isArray(args.edits) ? [...args.edits] : [];
6723
+ edits.push({
6724
+ oldText: args.oldText,
6725
+ newText: args.newText
6726
+ });
6727
+ const { oldText: _oldText, newText: _newText, ...rest } = args;
6728
+ return {
6729
+ ...rest,
6730
+ edits
6731
+ };
6732
+ };
6733
+ const editDef = tool_exports.define({
6734
+ name: "edit",
6735
+ label: "edit",
6736
+ description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
6737
+ promptSnippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
6738
+ promptGuidelines: [
6739
+ "Use edit for precise changes (edits[].oldText must match exactly)",
6740
+ "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
6741
+ "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
6742
+ "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions."
6743
+ ],
6744
+ prepareArguments: prepareEditArguments,
6745
+ parameters: EditParams,
6746
+ constrainedSampling: {
6747
+ type: "json_schema",
6748
+ strict: "prefer"
6749
+ },
6750
+ success: EditSuccess,
6751
+ failure: EditFailed,
6752
+ encodeContent: (success) => [{
6753
+ type: "text",
6754
+ text: `Successfully replaced ${success.replaced} block(s) in ${success.path}.`
6755
+ }],
6756
+ encodeFailureContent: (failure) => [{
6757
+ type: "text",
6758
+ text: failure.message
6759
+ }],
6760
+ encodeDetails: ({ diff, patch, firstChangedLine }) => ({
6761
+ diff,
6762
+ patch,
6763
+ ...firstChangedLine === void 0 ? {} : { firstChangedLine }
6764
+ })
6765
+ });
6766
+ const fail$4 = (message) => Effect.fail(new EditFailed({ message }));
6767
+ /** Filesystem failures name the backend's error code, as Pi's do. */
6768
+ const accessFailure = (path) => (error) => {
6769
+ const code = error.cause?.code;
6770
+ return fail$4(`Could not edit file: ${path}. Error code: ${typeof code === "string" ? code : "unknown"}.`);
6771
+ };
6772
+ const editHandler = (params) => Effect.gen(function* () {
6773
+ const { path, edits } = params;
6774
+ if (edits.length === 0) return yield* fail$4("Edit tool input is invalid. edits must contain at least one replacement.");
6775
+ const fs = yield* io_exports.SandboxIO.FileSystem;
6776
+ const mutation = yield* io_exports.SandboxIO.Mutation;
6777
+ const resolved = normalize$1(path);
6778
+ return yield* mutation.withFile(resolved, Effect.gen(function* () {
6779
+ if (!(yield* fs.stat(resolved)).isFile) return yield* fail$4(`Could not edit file: ${path}. Path is not a file.`);
6780
+ const { bom, text: content } = stripBom(yield* fs.readFile(resolved));
6781
+ const originalEnding = detectLineEnding(content);
6782
+ const { baseContent, newContent } = yield* Effect.try({
6783
+ try: () => applyEditsToNormalizedContent(normalizeToLF(content), edits, path),
6784
+ catch: (cause) => new EditFailed({ message: cause instanceof Error ? cause.message : String(cause) })
6785
+ });
6786
+ yield* fs.writeFile(resolved, bom + restoreLineEndings(newContent, originalEnding));
6787
+ const { diff, firstChangedLine } = generateDiffString(baseContent, newContent);
6788
+ return {
6789
+ path,
6790
+ replaced: edits.length,
6791
+ diff,
6792
+ patch: generateUnifiedPatch(path, baseContent, newContent),
6793
+ ...firstChangedLine === void 0 ? {} : { firstChangedLine }
6794
+ };
6795
+ }));
6796
+ }).pipe(Effect.catchTag("SandboxFileSystemError", accessFailure(params.path)));
6797
+ /** The edit tool: definition + handler, wired the testable (def/exec split) way. */
6798
+ const editTool = tool_exports.implement(editDef, editHandler);
6799
+ const editPlugin = (0, plugin_exports$1.define)({
6800
+ id: "codework.tool.edit",
6801
+ kind: "tool",
6802
+ setup: Effect.fn("EditPlugin.setup")(function* (ctx) {
6803
+ const mounted = Layer.merge(Layer.succeed(io_exports.SandboxIO.FileSystem, yield* io_exports.SandboxIO.FileSystem), Layer.succeed(io_exports.SandboxIO.Mutation, yield* io_exports.SandboxIO.Mutation));
6804
+ ctx.plugin.tools.add(tool_exports.provide(editTool, mounted));
6805
+ })
6806
+ });
6807
+ //#endregion
6808
+ //#region src/tool/binary.ts
6809
+ /** `<home>/bin` on the host, where binaries for `local` mounts are installed. */
6810
+ var HostBin = class extends Context.Service()("@codeworksh/harness/tool/binary/HostBin") {};
6811
+ const specs = {
6812
+ rg: {
6813
+ label: "ripgrep (rg)",
6814
+ repo: "BurntSushi/ripgrep",
6815
+ tagPrefix: "",
6816
+ commands: ["rg"],
6817
+ asset: (version, os, arch) => `ripgrep-${version}-${arch}-${os === "darwin" ? "apple-darwin" : "unknown-linux-musl"}.tar.gz`
6818
+ },
6819
+ fd: {
6820
+ label: "fd",
6821
+ repo: "sharkdp/fd",
6822
+ tagPrefix: "v",
6823
+ commands: ["fd", "fdfind"],
6824
+ asset: (version, os, arch) => `fd-v${version}-${arch}-${os === "darwin" ? "apple-darwin" : "unknown-linux-musl"}.tar.gz`
6825
+ }
6826
+ };
6827
+ const NETWORK_TIMEOUT = Duration.seconds(10);
6828
+ const DOWNLOAD_TIMEOUT = Duration.seconds(120);
6829
+ /** Why a missing binary could not be installed; the model reads the message. */
6830
+ var InstallError$1 = class extends Schema.TaggedError()("InstallError", { message: Schema.String }) {};
6831
+ const installError = (message) => new InstallError$1({ message });
6832
+ const available = /* @__PURE__ */ new Map();
6833
+ const keyOf = (sandbox, name) => `${sandbox.id}\0${name}`;
6834
+ const run$2 = (shell, command) => shell.exec(command).pipe(Effect.orElseSucceed(() => ({
6835
+ stdout: "",
6836
+ stderr: "",
6837
+ exitCode: 127
6838
+ })));
6839
+ const works = (shell, command) => Effect.map(run$2(shell, `${(0, shell_exports.quote)(command)} --version`), (result) => result.exitCode === 0);
6840
+ /** A GET whose `read` of the response, body included, finishes within `timeout`. */
6841
+ const get$2 = (url, timeout, read, init = {}) => HttpClient.get(url).pipe(Effect.flatMap(read), Effect.timeout(timeout), Effect.mapError((cause) => Schema.is(InstallError$1)(cause) ? cause : installError(`${url}: ${cause instanceof Error ? cause.message : String(cause)}`)), Effect.provide(FetchHttpClient.layer), Effect.provideService(FetchHttpClient.RequestInit, init));
6842
+ /** The latest release, read from the redirect of the release page; the API endpoint is rate limited. */
6843
+ const latestVersion = (repo) => Effect.gen(function* () {
6844
+ const response = yield* get$2(`https://github.com/${repo}/releases/latest`, NETWORK_TIMEOUT, Effect.succeed, { redirect: "manual" });
6845
+ const location = response.headers["location"] ?? "";
6846
+ const tag = new URL(location, "https://github.com").pathname.split("/").pop();
6847
+ if (!location.includes("/releases/tag/") || !tag) return yield* installError(`Failed to resolve latest ${repo} release: HTTP ${response.status}`);
6848
+ return decodeURIComponent(tag).replace(/^v/, "");
6849
+ });
6850
+ const platformOf = (shell) => Effect.gen(function* () {
6851
+ const [os, arch] = (yield* run$2(shell, "uname -sm")).stdout.trim().toLowerCase().split(/\s+/);
6852
+ const mappedOs = os === "darwin" || os === "linux" ? os : void 0;
6853
+ const mappedArch = arch === "x86_64" || arch === "amd64" ? "x86_64" : arch === "aarch64" || arch === "arm64" ? "aarch64" : void 0;
6854
+ if (mappedOs === void 0 || mappedArch === void 0) return yield* installError(`Unsupported platform: ${os ?? "unknown"}/${arch ?? "unknown"}`);
6855
+ return {
6856
+ os: mappedOs,
6857
+ arch: mappedArch
6858
+ };
6859
+ });
6860
+ /** Unpacks with the sandbox's own `tar`, as Pi does on its host; the archive layout varies by release. */
6861
+ const unpack = [
6862
+ "set -e",
6863
+ "dir=$1; archive=$2; name=$3; tmp=\"$dir/.extract-$name-$$\"",
6864
+ "trap 'rm -rf \"$tmp\" \"$archive\"' EXIT",
6865
+ "mkdir -p \"$tmp\"",
6866
+ "tar -xzf \"$archive\" -C \"$tmp\"",
6867
+ "found=$(find \"$tmp\" -type f -name \"$name\" | head -n 1)",
6868
+ "[ -n \"$found\" ] || { echo \"binary $name not found in archive\" >&2; exit 1; }",
6869
+ "mv \"$found\" \"$dir/$name\"",
6870
+ "chmod 755 \"$dir/$name\""
6871
+ ].join("\n");
6872
+ const install$1 = (name, shell, fs, dir) => Effect.gen(function* () {
6873
+ const spec = specs[name];
6874
+ const { os, arch } = yield* platformOf(shell);
6875
+ const version = name === "fd" && os === "darwin" && arch === "x86_64" ? "10.3.0" : yield* latestVersion(spec.repo);
6876
+ const asset = spec.asset(version, os, arch);
6877
+ const url = `https://github.com/${spec.repo}/releases/download/${spec.tagPrefix}${version}/${asset}`;
6878
+ const bytes = new Uint8Array(yield* get$2(url, DOWNLOAD_TIMEOUT, (response) => response.status === 200 ? response.arrayBuffer : Effect.fail(installError(`Download failed with HTTP ${response.status}: ${url}`))));
6879
+ const archive = posix.join(dir, `.${asset}`);
6880
+ yield* fs.writeFile(archive, bytes).pipe(Effect.mapError((error) => installError(`Cannot write ${archive}: ${error.cause instanceof Error ? error.cause.message : error.message}`)));
6881
+ const result = yield* run$2(shell, `sh -c ${(0, shell_exports.quote)(unpack)} _ ${(0, shell_exports.quoteArgv)([
6882
+ dir,
6883
+ archive,
6884
+ name
6885
+ ])}`);
6886
+ if (result.exitCode !== 0) return yield* installError(`Failed to unpack ${asset}: ${(result.stderr || result.stdout).trim()}`);
6887
+ return posix.join(dir, name);
6888
+ });
6889
+ /** Where `kind` keeps installed binaries. */
6890
+ const binDir = (sandbox, shell) => Effect.gen(function* () {
6891
+ if (sandbox.kind === "local") return yield* HostBin;
6892
+ const home = (yield* run$2(shell, "printf %s \"$HOME\"")).stdout.trim();
6893
+ return posix.join(home || "/tmp", ".codework", "bin");
6894
+ });
6895
+ const resolve$3 = (name, sandbox, shell, fs) => Effect.gen(function* () {
6896
+ const dir = yield* binDir(sandbox, shell);
6897
+ for (const command of [posix.join(dir, name), ...specs[name].commands]) if (yield* works(shell, command)) return {
6898
+ _tag: "Available",
6899
+ command
6900
+ };
6901
+ const installed = yield* install$1(name, shell, fs, dir).pipe(Effect.result);
6902
+ if (installed._tag === "Success" && (yield* works(shell, installed.success))) return {
6903
+ _tag: "Available",
6904
+ command: installed.success
6905
+ };
6906
+ const reason = installed._tag === "Failure" ? `: ${installed.failure.message}` : "";
6907
+ return {
6908
+ _tag: "Unavailable",
6909
+ message: `${specs[name].label} is not available in this sandbox and could not be installed${reason}`
6910
+ };
6911
+ });
6912
+ const virtual = (name) => name === "rg" ? {
6913
+ _tag: "Available",
6914
+ command: "rg"
6915
+ } : {
6916
+ _tag: "Unavailable",
6917
+ message: `${specs[name].label} is not available in this sandbox`
6918
+ };
6919
+ /**
6920
+ * The shared probe for `name` in this sandbox, started when none is remembered.
6921
+ * It runs detached, so cancelling the call that started it cannot cancel it for
6922
+ * the others waiting on it; an unavailable result is not remembered, so the next
6923
+ * call tries again.
6924
+ */
6925
+ const probe$1 = (name, sandbox, shell, fs) => Effect.gen(function* () {
6926
+ const key = keyOf(sandbox, name);
6927
+ const existing = available.get(key);
6928
+ if (existing !== void 0) return existing;
6929
+ const deferred = Deferred.makeUnsafe();
6930
+ available.set(key, deferred);
6931
+ yield* resolve$3(name, sandbox, shell, fs).pipe(Effect.onExit((exit) => Effect.sync(() => {
6932
+ if ((exit._tag === "Failure" || exit.value._tag === "Unavailable") && available.get(key) === deferred) available.delete(key);
6933
+ })), Effect.onExit((exit) => Deferred.done(deferred, exit)), Effect.forkDetach);
6934
+ return deferred;
6935
+ });
6936
+ /**
6937
+ * Run `use` with the command for `name`. When the run reports the binary gone
6938
+ * (`exitCode` 127, e.g. a sandbox reset to a fresh image), the remembered probe is
6939
+ * dropped and the run is tried once more with a fresh one. Only the probe that
6940
+ * gave the stale command is dropped, so concurrent retries share one new probe.
6941
+ */
6942
+ const withCommand = (name, sandbox, shell, fs, use) => Effect.gen(function* () {
6943
+ if (sandbox.kind === "virtual") {
6944
+ const resolved = virtual(name);
6945
+ return resolved._tag === "Unavailable" ? resolved : yield* use(resolved.command);
6946
+ }
6947
+ for (let attempt = 0;; attempt++) {
6948
+ const deferred = yield* probe$1(name, sandbox, shell, fs);
6949
+ const resolved = yield* Deferred.await(deferred);
6950
+ if (resolved._tag === "Unavailable") return resolved;
6951
+ const result = yield* use(resolved.command);
6952
+ if (result.exitCode !== 127 || attempt > 0) return result;
6953
+ const key = keyOf(sandbox, name);
6954
+ if (available.get(key) === deferred) available.delete(key);
6955
+ }
6956
+ });
6957
+ //#endregion
6958
+ //#region src/plugin/builtin/tool/find.ts
6959
+ /**
6960
+ * The built-in find plugin: fd in the mounted sandbox. A virtual mount has no fd,
6961
+ * and the tool says so rather than emulating it.
6962
+ */
6963
+ const FindParams = Schema.Struct({
6964
+ pattern: Schema.String.annotate({ description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'" }),
6965
+ path: Schema.optional(Schema.String.annotate({ description: "Directory to search in (default: current directory)" })),
6966
+ limit: Schema.optional(Schema.Finite.annotate({ description: "Maximum number of results (default: 1000)" }))
6967
+ });
6968
+ const FindDetails = Schema.Struct({
6969
+ truncation: Schema.optional(Schema.Unknown),
6970
+ resultLimitReached: Schema.optional(Schema.Finite)
6971
+ });
6972
+ const FindSuccess = Schema.Struct({
6973
+ text: Schema.String,
6974
+ details: Schema.optional(FindDetails)
6975
+ });
6976
+ var FindFailed = class extends Schema.TaggedError()("FindFailed", { message: Schema.String }) {};
6977
+ const DEFAULT_LIMIT$2 = 1e3;
6978
+ const FD_ERROR = "[fd error]:";
6979
+ const findDef = tool_exports.define({
6980
+ name: "find",
6981
+ label: "find",
6982
+ description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT$2} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
6983
+ promptSnippet: "Find files by glob pattern (respects .gitignore)",
6984
+ parameters: FindParams,
6985
+ constrainedSampling: {
6986
+ type: "json_schema",
6987
+ strict: "prefer"
6988
+ },
6989
+ success: FindSuccess,
6990
+ failure: FindFailed,
6991
+ encodeContent: (success) => [{
6992
+ type: "text",
6993
+ text: success.text
6994
+ }],
6995
+ encodeFailureContent: (failure) => [{
6996
+ type: "text",
6997
+ text: failure.message
6998
+ }],
6999
+ encodeDetails: (success) => success.details
7000
+ });
7001
+ const fail$3 = (message) => Effect.fail(new FindFailed({ message }));
7002
+ /** Relative to the search root, keeping a directory's trailing slash. */
7003
+ const relativize = (result, searchPath) => {
7004
+ const relative = posix.isAbsolute(result) ? posix.relative(searchPath, result) : result;
7005
+ return result.endsWith("/") && !relative.endsWith("/") ? `${relative}/` : relative;
7006
+ };
7007
+ const insideGitRepo = (fs, searchPath) => Effect.gen(function* () {
7008
+ for (let current = searchPath;; current = posix.dirname(current)) {
7009
+ if (yield* fs.exists(posix.join(current, ".git")).pipe(Effect.orElseSucceed(() => false))) return true;
7010
+ if (posix.dirname(current) === current) return false;
7011
+ }
7012
+ });
7013
+ const findHandler = (params) => Effect.gen(function* () {
7014
+ const fs = yield* io_exports.SandboxIO.FileSystem;
7015
+ const shell = yield* io_exports.SandboxIO.Shell;
7016
+ const sandbox = yield* io_exports.SandboxIO.Current;
7017
+ const searchPath = posix.resolve(sandbox.cwd, normalize$1(params.path || "."));
7018
+ const limit = Math.max(1, Math.trunc(params.limit ?? DEFAULT_LIMIT$2));
7019
+ if (!(yield* fs.exists(searchPath).pipe(Effect.orElseSucceed(() => false)))) return yield* fail$3(`Path not found: ${searchPath}`);
7020
+ const argv = [
7021
+ "--glob",
7022
+ "--color=never",
7023
+ "--hidden",
7024
+ ...(yield* insideGitRepo(fs, searchPath)) ? [] : ["--no-require-git"]
7025
+ ];
7026
+ argv.push("--max-results", String(limit));
7027
+ let pattern = params.pattern;
7028
+ if (pattern.includes("/")) {
7029
+ argv.push("--full-path");
7030
+ if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") pattern = `**/${pattern}`;
7031
+ }
7032
+ argv.push("--", pattern, searchPath);
7033
+ const run = yield* withCommand("fd", sandbox, shell, fs, (command) => shell.exec((0, shell_exports.quoteArgv)([command, ...argv])));
7034
+ if ("_tag" in run) return yield* fail$3(run.message);
7035
+ const output = run.stdout.split("\n").map((line) => line.replace(/\r$/, "").trim()).filter((line) => line.length > 0);
7036
+ const lines = output.filter((line) => !line.startsWith(FD_ERROR));
7037
+ const errors = [run.stderr.trim(), ...output.filter((line) => line.startsWith(FD_ERROR))].filter((text) => text.length > 0).join("\n");
7038
+ if (run.exitCode !== 0 && lines.length === 0) return yield* fail$3(errors || `fd exited with code ${run.exitCode}`);
7039
+ if (lines.length === 0) return { text: "No files found matching pattern" };
7040
+ const results = lines.map((line) => relativize(line, searchPath)).sort();
7041
+ const resultLimitReached = results.length >= limit;
7042
+ const truncation = truncateHead(results.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER });
7043
+ const notices = [];
7044
+ const details = {};
7045
+ if (resultLimitReached) {
7046
+ notices.push(`${limit} results limit reached. Use limit=${limit * 2} for more, or refine pattern`);
7047
+ details.resultLimitReached = limit;
7048
+ }
7049
+ if (truncation.truncated) {
7050
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
7051
+ details.truncation = truncation;
7052
+ }
7053
+ const text = notices.length > 0 ? `${truncation.content}\n\n[${notices.join(". ")}]` : truncation.content;
7054
+ return Object.keys(details).length > 0 ? {
7055
+ text,
7056
+ details
7057
+ } : { text };
7058
+ }).pipe(Effect.catchTag("ShellError", (error) => Effect.die(error)));
7059
+ /** The find tool: definition + handler, wired the testable (def/exec split) way. */
7060
+ const findTool = tool_exports.implement(findDef, findHandler);
7061
+ const findPlugin = (0, plugin_exports$1.define)({
7062
+ id: "codework.tool.find",
7063
+ kind: "tool",
7064
+ optIn: true,
7065
+ setup: Effect.fn("FindPlugin.setup")(function* (ctx) {
7066
+ const mounted = Layer.mergeAll(Layer.succeed(io_exports.SandboxIO.FileSystem, yield* io_exports.SandboxIO.FileSystem), Layer.succeed(io_exports.SandboxIO.Shell, yield* io_exports.SandboxIO.Shell), Layer.succeed(io_exports.SandboxIO.Current, yield* io_exports.SandboxIO.Current), Layer.succeed(HostBin, ctx.paths.bin));
7067
+ ctx.plugin.tools.add(tool_exports.provide(findTool, mounted));
7068
+ })
7069
+ });
7070
+ //#endregion
7071
+ //#region src/plugin/builtin/tool/grep.ts
7072
+ /**
7073
+ * The built-in grep plugin: ripgrep in the mounted sandbox — the host's or the
7074
+ * remote's own binary, or just-bash's built-in `rg` on a virtual mount. Only the
7075
+ * first `limit` match events leave the sandbox; context lines are read through
7076
+ * `scanLines`, so only their window crosses.
7077
+ */
7078
+ const GrepParams = Schema.Struct({
7079
+ pattern: Schema.String.annotate({ description: "Search pattern (regex or literal string)" }),
7080
+ path: Schema.optional(Schema.String.annotate({ description: "Directory or file to search (default: current directory)" })),
7081
+ glob: Schema.optional(Schema.String.annotate({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
7082
+ ignoreCase: Schema.optional(Schema.Boolean.annotate({ description: "Case-insensitive search (default: false)" })),
7083
+ literal: Schema.optional(Schema.Boolean.annotate({ description: "Treat pattern as literal string instead of regex (default: false)" })),
7084
+ context: Schema.optional(Schema.Finite.annotate({ description: "Number of lines to show before and after each match (default: 0)" })),
7085
+ limit: Schema.optional(Schema.Finite.annotate({ description: "Maximum number of matches to return (default: 100)" }))
7086
+ });
7087
+ const GrepDetails = Schema.Struct({
7088
+ truncation: Schema.optional(Schema.Unknown),
7089
+ matchLimitReached: Schema.optional(Schema.Finite),
7090
+ linesTruncated: Schema.optional(Schema.Boolean)
7091
+ });
7092
+ const GrepSuccess = Schema.Struct({
7093
+ text: Schema.String,
7094
+ details: Schema.optional(GrepDetails)
7095
+ });
7096
+ var GrepFailed = class extends Schema.TaggedError()("GrepFailed", { message: Schema.String }) {};
7097
+ const DEFAULT_LIMIT$1 = 100;
7098
+ const grepDef = tool_exports.define({
7099
+ name: "grep",
7100
+ label: "grep",
7101
+ description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT$1} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to 500 chars.`,
7102
+ promptSnippet: "Search file contents for patterns (respects .gitignore)",
7103
+ parameters: GrepParams,
7104
+ constrainedSampling: {
7105
+ type: "json_schema",
7106
+ strict: "prefer"
7107
+ },
7108
+ success: GrepSuccess,
7109
+ failure: GrepFailed,
7110
+ encodeContent: (success) => [{
7111
+ type: "text",
7112
+ text: success.text
7113
+ }],
7114
+ encodeFailureContent: (failure) => [{
7115
+ type: "text",
7116
+ text: failure.message
7117
+ }],
7118
+ encodeDetails: (success) => success.details
7119
+ });
7120
+ const fail$2 = (message) => Effect.fail(new GrepFailed({ message }));
7121
+ /** One `--json` match event; anything else (begin/end/summary, a non-UTF-8 path) is skipped. */
7122
+ const parseMatch = (line) => {
7123
+ try {
7124
+ const event = JSON.parse(line);
7125
+ const path = event.data?.path?.text;
7126
+ const number = event.data?.line_number;
7127
+ const text = event.data?.lines?.text;
7128
+ if (event.type !== "match" || typeof path !== "string" || typeof number !== "number") return void 0;
7129
+ return {
7130
+ path,
7131
+ line: number,
7132
+ text: typeof text === "string" ? text : ""
7133
+ };
7134
+ } catch {
7135
+ return;
7136
+ }
7137
+ };
7138
+ /**
7139
+ * The search runs as one pipeline so a broad pattern never ships its whole result:
7140
+ * only match events pass, and `head` stops ripgrep at the limit. Some backends
7141
+ * merge stderr into stdout, so nothing relies on the two staying apart: ripgrep's
7142
+ * status rides the pipe as a last event, and its stderr waits in `errors` until the
7143
+ * pipeline is done, where it can no longer land inside a match.
7144
+ */
7145
+ const pipeline = (argv, limit, errors) => [
7146
+ `mkdir -p ${(0, shell_exports.quote)(posix.dirname(errors))} 2>/dev/null`,
7147
+ `{ : > ${(0, shell_exports.quote)(errors)}; } 2>/dev/null || { echo ${(0, shell_exports.quote)(`cannot write ${errors}`)} >&2; exit 2; }`,
7148
+ `{ ${(0, shell_exports.quoteArgv)(argv)} 2>${(0, shell_exports.quote)(errors)}; echo '{"type":"rc","code":'"$?"'}'; } | grep -E '^\\{"type":"(match|rc)"' | head -n ${limit}`,
7149
+ `cat ${(0, shell_exports.quote)(errors)} >&2 2>/dev/null`,
7150
+ `rm -f ${(0, shell_exports.quote)(errors)}`
7151
+ ].join("; ");
7152
+ const STATUS = /^\{"type":"rc","code":(\d+)\}$/;
7153
+ /** The pipeline's output: match events, ripgrep's status, and anything else, which is its stderr. */
7154
+ const parse$2 = (stdout, stderr) => {
7155
+ const matches = [];
7156
+ const errors = [];
7157
+ let status;
7158
+ for (const line of stdout.split("\n")) {
7159
+ const rc = STATUS.exec(line);
7160
+ if (rc !== null) status ??= Number(rc[1]);
7161
+ else if (line.startsWith("{\"type\":\"match\"")) {
7162
+ const match = parseMatch(line);
7163
+ if (match !== void 0) matches.push(match);
7164
+ } else if (line.trim().length > 0) errors.push(line);
7165
+ }
7166
+ return {
7167
+ matches,
7168
+ status,
7169
+ errors: [stderr.trim(), ...errors].filter((text) => text.length > 0).join("\n")
7170
+ };
7171
+ };
7172
+ /** A line as shown: no line ending, cut to {@link GREP_MAX_LINE_LENGTH}. */
7173
+ const shown = (line) => truncateLine(line.replace(/\r\n/g, "\n").replace(/\r/g, "").replace(/\n$/, ""));
7174
+ /**
7175
+ * The match with its context lines. The window is read in one bounded scan; a
7176
+ * line it could not fit is read on its own, and a line too long even for that is
7177
+ * marked rather than shown blank. The match line itself is ripgrep's.
7178
+ */
7179
+ const contextBlock = (fs, match, context, label) => Effect.gen(function* () {
7180
+ const startLine = Math.max(0, match.line - 1 - context);
7181
+ const scan = yield* fs.scanLines(match.path, {
7182
+ startLine,
7183
+ endLine: match.line + context,
7184
+ maxBytes: DEFAULT_MAX_BYTES
7185
+ }).pipe(Effect.option);
7186
+ if (scan._tag === "None" || scan.value.totalLines === 0) return {
7187
+ lines: [`${label}:${match.line}: (unable to read file)`],
7188
+ truncated: false
7189
+ };
7190
+ const window = scan.value.lines === 0 ? [] : scan.value.text.split("\n");
7191
+ const lineAt = (index) => index - startLine < window.length ? Effect.succeed(window[index - startLine]) : fs.scanLines(match.path, {
7192
+ startLine: index,
7193
+ endLine: index + 1,
7194
+ maxBytes: DEFAULT_MAX_BYTES
7195
+ }).pipe(Effect.map((one) => one.lines === 1 ? one.text : void 0), Effect.orElseSucceed(() => void 0));
7196
+ const end = Math.min(scan.value.totalLines, match.line + context);
7197
+ const block = [];
7198
+ let truncated = false;
7199
+ for (let current = startLine + 1; current <= end; current++) {
7200
+ const line = current === match.line ? match.text : yield* lineAt(current - 1);
7201
+ const { text, wasTruncated } = line === void 0 ? {
7202
+ text: "... [truncated]",
7203
+ wasTruncated: true
7204
+ } : shown(line);
7205
+ if (wasTruncated) truncated = true;
7206
+ block.push(current === match.line ? `${label}:${current}: ${text}` : `${label}-${current}- ${text}`);
7207
+ }
7208
+ return {
7209
+ lines: block,
7210
+ truncated
7211
+ };
7212
+ });
7213
+ const grepHandler = (params) => Effect.gen(function* () {
7214
+ const fs = yield* io_exports.SandboxIO.FileSystem;
7215
+ const shell = yield* io_exports.SandboxIO.Shell;
7216
+ const sandbox = yield* io_exports.SandboxIO.Current;
7217
+ const searchPath = posix.resolve(sandbox.cwd, normalize$1(params.path || "."));
7218
+ const stat = yield* fs.stat(searchPath).pipe(Effect.option);
7219
+ if (stat._tag === "None") return yield* fail$2(`Path not found: ${searchPath}`);
7220
+ const isDirectory = stat.value.isDirectory;
7221
+ const context = params.context && params.context > 0 ? Math.trunc(params.context) : 0;
7222
+ const limit = Math.max(1, Math.trunc(params.limit ?? DEFAULT_LIMIT$1));
7223
+ const errors = yield* path$1(sandbox.spillPath, "codework-grep");
7224
+ const run = yield* withCommand("rg", sandbox, shell, fs, (command) => Effect.gen(function* () {
7225
+ const argv = [
7226
+ command,
7227
+ "--json",
7228
+ "--line-number",
7229
+ "--hidden",
7230
+ "--sort",
7231
+ "path"
7232
+ ];
7233
+ argv.push(params.ignoreCase ? "--ignore-case" : "-s");
7234
+ if (params.literal) argv.push("--fixed-strings");
7235
+ if (params.glob) argv.push("--glob", params.glob);
7236
+ argv.push("-e", params.pattern, searchPath);
7237
+ const result = yield* shell.exec(pipeline(argv, limit, errors));
7238
+ const parsed = parse$2(result.stdout, result.stderr);
7239
+ return {
7240
+ ...parsed,
7241
+ exitCode: parsed.status ?? result.exitCode
7242
+ };
7243
+ }));
7244
+ if ("_tag" in run) return yield* fail$2(run.message);
7245
+ const { matches } = run;
7246
+ const matchLimitReached = matches.length >= limit;
7247
+ if (!matchLimitReached && run.exitCode !== 0 && run.exitCode !== 1) return yield* fail$2(run.errors || `ripgrep exited with code ${run.exitCode}`);
7248
+ if (matches.length === 0) return { text: "No matches found" };
7249
+ const label = (path) => {
7250
+ if (isDirectory) {
7251
+ const relative = posix.relative(searchPath, path);
7252
+ if (relative && !relative.startsWith("..")) return relative;
7253
+ }
7254
+ return posix.basename(path);
7255
+ };
7256
+ const output = [];
7257
+ let linesTruncated = false;
7258
+ for (const match of matches) if (context === 0) {
7259
+ const { text, wasTruncated } = shown(match.text);
7260
+ if (wasTruncated) linesTruncated = true;
7261
+ output.push(`${label(match.path)}:${match.line}: ${text}`);
7262
+ } else {
7263
+ const block = yield* contextBlock(fs, match, context, label(match.path));
7264
+ if (block.truncated) linesTruncated = true;
7265
+ output.push(...block.lines);
7266
+ }
7267
+ const truncation = truncateHead(output.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER });
7268
+ const notices = [];
7269
+ const details = {};
7270
+ if (matchLimitReached) {
7271
+ notices.push(`${limit} matches limit reached. Use limit=${limit * 2} for more, or refine pattern`);
7272
+ details.matchLimitReached = limit;
7273
+ }
7274
+ if (truncation.truncated) {
7275
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
7276
+ details.truncation = truncation;
7277
+ }
7278
+ if (linesTruncated) {
7279
+ notices.push(`Some lines truncated to 500 chars. Use read tool to see full lines`);
7280
+ details.linesTruncated = true;
7281
+ }
7282
+ const text = notices.length > 0 ? `${truncation.content}\n\n[${notices.join(". ")}]` : truncation.content;
7283
+ return Object.keys(details).length > 0 ? {
7284
+ text,
7285
+ details
7286
+ } : { text };
7287
+ }).pipe(Effect.catchTag("ShellError", (error) => Effect.die(error)));
7288
+ /** The grep tool: definition + handler, wired the testable (def/exec split) way. */
7289
+ const grepTool = tool_exports.implement(grepDef, grepHandler);
7290
+ const grepPlugin = (0, plugin_exports$1.define)({
7291
+ id: "codework.tool.grep",
7292
+ kind: "tool",
7293
+ optIn: true,
7294
+ setup: Effect.fn("GrepPlugin.setup")(function* (ctx) {
7295
+ const mounted = Layer.mergeAll(Layer.succeed(io_exports.SandboxIO.FileSystem, yield* io_exports.SandboxIO.FileSystem), Layer.succeed(io_exports.SandboxIO.Shell, yield* io_exports.SandboxIO.Shell), Layer.succeed(io_exports.SandboxIO.Current, yield* io_exports.SandboxIO.Current), Layer.succeed(HostBin, ctx.paths.bin));
7296
+ ctx.plugin.tools.add(tool_exports.provide(grepTool, mounted));
7297
+ })
7298
+ });
7299
+ //#endregion
7300
+ //#region src/plugin/builtin/tool/ls.ts
7301
+ /** The built-in ls plugin: one directory through the sandbox filesystem, no binaries involved. */
7302
+ const LsParams = Schema.Struct({
7303
+ path: Schema.optional(Schema.String.annotate({ description: "Directory to list (default: current directory)" })),
7304
+ limit: Schema.optional(Schema.Finite.annotate({ description: "Maximum number of entries to return (default: 500)" }))
7305
+ });
7306
+ const LsDetails = Schema.Struct({
7307
+ truncation: Schema.optional(Schema.Unknown),
7308
+ entryLimitReached: Schema.optional(Schema.Finite)
7309
+ });
7310
+ const LsSuccess = Schema.Struct({
7311
+ text: Schema.String,
7312
+ details: Schema.optional(LsDetails)
7313
+ });
7314
+ var LsFailed = class extends Schema.TaggedError()("LsFailed", { message: Schema.String }) {};
7315
+ const DEFAULT_LIMIT = 500;
7316
+ const lsDef = tool_exports.define({
7317
+ name: "ls",
7318
+ label: "ls",
7319
+ description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
7320
+ promptSnippet: "List directory contents",
7321
+ parameters: LsParams,
7322
+ constrainedSampling: {
7323
+ type: "json_schema",
7324
+ strict: "prefer"
7325
+ },
7326
+ success: LsSuccess,
7327
+ failure: LsFailed,
7328
+ encodeContent: (success) => [{
7329
+ type: "text",
7330
+ text: success.text
7331
+ }],
7332
+ encodeFailureContent: (failure) => [{
7333
+ type: "text",
7334
+ text: failure.message
7335
+ }],
7336
+ encodeDetails: (success) => success.details
7337
+ });
7338
+ const fail$1 = (message) => Effect.fail(new LsFailed({ message }));
7339
+ const lsHandler = (params) => Effect.gen(function* () {
7340
+ const fs = yield* io_exports.SandboxIO.FileSystem;
7341
+ const sandbox = yield* io_exports.SandboxIO.Current;
7342
+ const dirPath = posix.resolve(sandbox.cwd, normalize$1(params.path || "."));
7343
+ const limit = params.limit ?? DEFAULT_LIMIT;
7344
+ const stat = yield* fs.stat(dirPath).pipe(Effect.option);
7345
+ if (stat._tag === "None") return yield* fail$1(`Path not found: ${dirPath}`);
7346
+ if (!stat.value.isDirectory) return yield* fail$1(`Not a directory: ${dirPath}`);
7347
+ const read = yield* fs.readdir(dirPath).pipe(Effect.result);
7348
+ if (read._tag === "Failure") {
7349
+ const cause = read.failure.cause;
7350
+ return yield* fail$1(`Cannot read directory: ${cause instanceof Error ? cause.message : String(cause)}`);
7351
+ }
7352
+ const entries = [...read.success].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
7353
+ const results = [];
7354
+ let entryLimitReached = false;
7355
+ for (const entry of entries) {
7356
+ if (results.length >= limit) {
7357
+ entryLimitReached = true;
7358
+ break;
7359
+ }
7360
+ const entryStat = yield* fs.stat(posix.join(dirPath, entry)).pipe(Effect.option);
7361
+ if (entryStat._tag === "None") continue;
7362
+ results.push(entryStat.value.isDirectory ? `${entry}/` : entry);
7363
+ }
7364
+ if (results.length === 0) return { text: "(empty directory)" };
7365
+ const truncation = truncateHead(results.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER });
7366
+ const notices = [];
7367
+ const details = {};
7368
+ if (entryLimitReached) {
7369
+ notices.push(`${limit} entries limit reached. Use limit=${limit * 2} for more`);
7370
+ details.entryLimitReached = limit;
7371
+ }
7372
+ if (truncation.truncated) {
7373
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
7374
+ details.truncation = truncation;
7375
+ }
7376
+ const text = notices.length > 0 ? `${truncation.content}\n\n[${notices.join(". ")}]` : truncation.content;
7377
+ return Object.keys(details).length > 0 ? {
7378
+ text,
7379
+ details
7380
+ } : { text };
7381
+ });
7382
+ /** The ls tool: definition + handler, wired the testable (def/exec split) way. */
7383
+ const lsTool = tool_exports.implement(lsDef, lsHandler);
7384
+ const lsPlugin = (0, plugin_exports$1.define)({
7385
+ id: "codework.tool.ls",
7386
+ kind: "tool",
7387
+ optIn: true,
7388
+ setup: Effect.fn("LsPlugin.setup")(function* (ctx) {
7389
+ const mounted = Layer.merge(Layer.succeed(io_exports.SandboxIO.FileSystem, yield* io_exports.SandboxIO.FileSystem), Layer.succeed(io_exports.SandboxIO.Current, yield* io_exports.SandboxIO.Current));
7390
+ ctx.plugin.tools.add(tool_exports.provide(lsTool, mounted));
7391
+ })
7392
+ });
7393
+ //#endregion
7394
+ //#region src/tool/image/mime.ts
7395
+ /**
7396
+ * Image formats a model takes inline, told apart by their bytes, never by the
7397
+ * file name: a text file that starts with `GIF` is still text.
7398
+ */
7399
+ const PNG_SIGNATURE = [
7400
+ 137,
7401
+ 80,
7402
+ 78,
7403
+ 71,
7404
+ 13,
7405
+ 10,
7406
+ 26,
7407
+ 10
7408
+ ];
7409
+ /** Bytes every check except the APNG chunk walk needs: BMP reads up to offset 29. */
7410
+ const HEADER_BYTES = 32;
7411
+ const BLOCK_BYTES = 65536;
7412
+ /**
7413
+ * The image MIME type of a whole file, reading only its header and, for PNG, the
7414
+ * chunk headers up to the first `acTL` or `IDAT`. Animated PNGs are not images
7415
+ * here: a model would see only their first frame.
7416
+ */
7417
+ const detectOf = (source) => Effect.gen(function* () {
7418
+ const header = yield* source.read(0, HEADER_BYTES);
7419
+ if (!startsWith(header, PNG_SIGNATURE)) return detect(header);
7420
+ return isPng(header) && !(yield* isAnimatedPngOf(source)) ? "image/png" : void 0;
7421
+ });
7422
+ const isAnimatedPngOf = (source) => Effect.gen(function* () {
7423
+ let block = /* @__PURE__ */ new Uint8Array(0);
7424
+ let blockStart = 0;
7425
+ let offset = PNG_SIGNATURE.length;
7426
+ while (offset + 8 <= source.size) {
7427
+ if (offset < blockStart || offset + 8 > blockStart + block.length) {
7428
+ blockStart = offset;
7429
+ block = yield* source.read(offset, BLOCK_BYTES);
7430
+ }
7431
+ const chunkHeader = block.subarray(offset - blockStart, offset - blockStart + 8);
7432
+ if (chunkHeader.length < 8) return false;
7433
+ if (startsWithAscii(chunkHeader, 4, "acTL")) return true;
7434
+ if (startsWithAscii(chunkHeader, 4, "IDAT")) return false;
7435
+ const nextOffset = offset + 8 + readUint32BE(chunkHeader, 0) + 4;
7436
+ if (nextOffset <= offset || nextOffset > source.size) return false;
7437
+ offset = nextOffset;
7438
+ }
7439
+ return false;
7440
+ });
7441
+ /** The image MIME type of a header; a PNG still needs {@link isAnimatedPngOf}. */
7442
+ const detect = (buffer) => {
7443
+ if (startsWith(buffer, [
7444
+ 255,
7445
+ 216,
7446
+ 255
7447
+ ])) return buffer[3] === 247 ? void 0 : "image/jpeg";
7448
+ if (startsWith(buffer, PNG_SIGNATURE)) return isPng(buffer) ? "image/png" : void 0;
7449
+ if (startsWithAscii(buffer, 0, "GIF87a") || startsWithAscii(buffer, 0, "GIF89a")) return "image/gif";
7450
+ if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) return "image/webp";
7451
+ if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) return "image/bmp";
7452
+ };
7453
+ const isPng = (buffer) => buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR");
7454
+ const isBmp = (buffer) => {
7455
+ if (buffer.length < 26) return false;
7456
+ const declaredFileSize = readUint32LE(buffer, 2);
7457
+ const pixelDataOffset = readUint32LE(buffer, 10);
7458
+ const dibHeaderSize = readUint32LE(buffer, 14);
7459
+ if (declaredFileSize !== 0 && declaredFileSize < 26) return false;
7460
+ if (pixelDataOffset < 14 + dibHeaderSize) return false;
7461
+ if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize) return false;
7462
+ let colorPlanes;
7463
+ let bitsPerPixel;
7464
+ if (dibHeaderSize === 12) {
7465
+ colorPlanes = readUint16LE(buffer, 22);
7466
+ bitsPerPixel = readUint16LE(buffer, 24);
7467
+ } else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
7468
+ if (buffer.length < 30) return false;
7469
+ colorPlanes = readUint16LE(buffer, 26);
7470
+ bitsPerPixel = readUint16LE(buffer, 28);
7471
+ } else return false;
7472
+ return colorPlanes === 1 && [
7473
+ 1,
7474
+ 4,
7475
+ 8,
7476
+ 16,
7477
+ 24,
7478
+ 32
7479
+ ].includes(bitsPerPixel);
7480
+ };
7481
+ const readUint16LE = (buffer, offset) => (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
7482
+ const readUint32BE = (buffer, offset) => (buffer[offset] ?? 0) * 16777216 + ((buffer[offset + 1] ?? 0) << 16) + ((buffer[offset + 2] ?? 0) << 8) + (buffer[offset + 3] ?? 0);
7483
+ const readUint32LE = (buffer, offset) => (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8) + ((buffer[offset + 2] ?? 0) << 16) + (buffer[offset + 3] ?? 0) * 16777216;
7484
+ const startsWith = (buffer, bytes) => buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte);
7485
+ const startsWithAscii = (buffer, offset, text) => {
7486
+ if (buffer.length < offset + text.length) return false;
7487
+ for (let index = 0; index < text.length; index++) if (buffer[offset + index] !== text.charCodeAt(index)) return false;
7488
+ return true;
7489
+ };
7490
+ //#endregion
7491
+ //#region src/tool/image/index.ts
7492
+ const INLINE = /* @__PURE__ */ new Set([
7493
+ "image/png",
7494
+ "image/jpeg",
7495
+ "image/gif",
7496
+ "image/webp"
7497
+ ]);
7498
+ const toPng = (bytes) => load$4.pipe(Effect.map((photon) => {
7499
+ if (photon === void 0) return void 0;
7500
+ try {
7501
+ const image = decode(photon, bytes);
7502
+ try {
7503
+ return new Uint8Array(image.get_bytes());
7504
+ } finally {
7505
+ image.free();
7506
+ }
7507
+ } catch {
7508
+ return;
7509
+ }
7510
+ }));
7511
+ /**
7512
+ * Turn an image file into what a model takes inline: a supported format, within
7513
+ * the provider's dimension and size limits. A failure is a note for the model,
7514
+ * never an error — the read itself succeeded.
7515
+ */
7516
+ const inline = Effect.fn("Image.inline")(function* (bytes, mimeType) {
7517
+ let source = {
7518
+ bytes,
7519
+ mimeType,
7520
+ convertedFrom: void 0
7521
+ };
7522
+ if (!INLINE.has(mimeType)) {
7523
+ const png = yield* toPng(bytes);
7524
+ if (png === void 0) return {
7525
+ ok: false,
7526
+ message: "[Image omitted: could not be converted to a supported inline image format.]"
7527
+ };
7528
+ source = {
7529
+ bytes: png,
7530
+ mimeType: "image/png",
7531
+ convertedFrom: mimeType
7532
+ };
7533
+ }
7534
+ const resized = yield* resize(source.bytes, source.mimeType);
7535
+ if (resized === null) return {
7536
+ ok: false,
7537
+ message: "[Image omitted: could not be resized below the inline image size limit.]"
7538
+ };
7539
+ const hints = [];
7540
+ if (source.convertedFrom !== void 0 && source.convertedFrom !== resized.mimeType) hints.push(`[Image converted from ${source.convertedFrom} to ${resized.mimeType}.]`);
7541
+ const note = dimensionNote(resized);
7542
+ if (note !== void 0) hints.push(note);
7543
+ return {
7544
+ ok: true,
7545
+ data: resized.data,
7546
+ mimeType: resized.mimeType,
7547
+ hints
7548
+ };
7549
+ });
7550
+ //#endregion
7551
+ //#region src/plugin/builtin/tool/read.ts
7552
+ /**
7553
+ * The built-in read plugin. Text is read through `scanLines`, so only the shown
7554
+ * window crosses from the sandbox; images, told apart by their bytes, go to the
7555
+ * model as attachments. Continuation notices are part of the text the model
7556
+ * reads; `details` keeps only how the text was cut.
7557
+ */
7558
+ const ReadParams = Schema.Struct({
7559
+ path: Schema.String.annotate({ description: "Path to the file to read (relative or absolute)" }),
7560
+ offset: Schema.optional(Schema.Finite.annotate({ description: "Line number to start reading from (1-indexed)" })),
7561
+ limit: Schema.optional(Schema.Finite.annotate({ description: "Maximum number of lines to read" }))
7562
+ });
7563
+ /** How the shown text was cut. */
7564
+ const Truncation = Schema.Struct({
7565
+ truncated: Schema.Boolean,
7566
+ truncatedBy: Schema.NullOr(Schema.Literals(["lines", "bytes"])),
7567
+ /** Lines selected by `offset`/`limit`, before the limits. */
7568
+ totalLines: Schema.Finite,
7569
+ outputLines: Schema.Finite,
7570
+ outputBytes: Schema.Finite,
7571
+ firstLineExceedsLimit: Schema.Boolean,
7572
+ maxLines: Schema.Finite,
7573
+ maxBytes: Schema.Finite
7574
+ });
7575
+ const ReadText = Schema.Struct({
7576
+ type: Schema.Literal("text"),
7577
+ text: Schema.String,
7578
+ truncation: Schema.optional(Truncation)
7579
+ });
7580
+ const ReadImage = Schema.Struct({
7581
+ type: Schema.Literal("image"),
7582
+ /** What the model reads with the image: its type and any conversion/resize hints. */
7583
+ note: Schema.String,
7584
+ image: Schema.optional(Schema.Struct({
7585
+ data: Schema.String,
7586
+ mimeType: Schema.String
7587
+ }))
7588
+ });
7589
+ const ReadSuccess = Schema.Union([ReadText, ReadImage]);
7590
+ var ReadFailed = class extends Schema.TaggedError()("ReadFailed", { message: Schema.String }) {};
7591
+ const BINARY_PROBE_BYTES = 8e3;
7592
+ const readDef = tool_exports.define({
7593
+ name: "read",
7594
+ label: "read",
7595
+ description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
7596
+ promptSnippet: "Read file contents",
7597
+ promptGuidelines: ["Use read to examine files instead of cat or sed."],
7598
+ parameters: ReadParams,
7599
+ constrainedSampling: {
7600
+ type: "json_schema",
7601
+ strict: "prefer"
7602
+ },
7603
+ success: ReadSuccess,
7604
+ failure: ReadFailed,
7605
+ encodeContent: (success) => success.type === "text" ? [{
7606
+ type: "text",
7607
+ text: success.text
7608
+ }] : [{
7609
+ type: "text",
7610
+ text: success.note
7611
+ }, ...success.image === void 0 ? [] : [{
7612
+ type: "image",
7613
+ ...success.image
7614
+ }]],
7615
+ encodeFailureContent: (failure) => [{
7616
+ type: "text",
7617
+ text: failure.message
7618
+ }],
7619
+ encodeDetails: (success) => success.type === "text" && success.truncation !== void 0 ? { truncation: success.truncation } : void 0
7620
+ });
7621
+ const fail = (message) => Effect.fail(new ReadFailed({ message }));
7622
+ /**
7623
+ * Filesystem failures are the model's to see, as Pi shows them: a missing path in
7624
+ * one wording on every backend, anything else (EACCES, …) as the backend reported it.
7625
+ */
7626
+ const fsFailure$1 = (path) => (error) => fail(filesystem_exports.SandboxFileSystem.isNotFoundError(error.cause) ? `ENOENT: no such file or directory, open '${path}'` : error.cause instanceof Error ? error.cause.message : `Could not read file: ${path}`);
7627
+ const readImage = (fs, resolved, mimeType) => Effect.gen(function* () {
7628
+ const bytes = yield* fs.readFileBuffer(resolved);
7629
+ const inline$1 = yield* inline(bytes, mimeType);
7630
+ if (!inline$1.ok) return {
7631
+ type: "image",
7632
+ note: `Read image file [${mimeType}]\n${inline$1.message}`
7633
+ };
7634
+ return {
7635
+ type: "image",
7636
+ note: [`Read image file [${inline$1.mimeType}]`, ...inline$1.hints].join("\n"),
7637
+ image: {
7638
+ data: inline$1.data,
7639
+ mimeType: inline$1.mimeType
7640
+ }
7641
+ };
7642
+ });
7643
+ const readText = (fs, resolved, params) => Effect.gen(function* () {
7644
+ const { path, offset } = params;
7645
+ const startLine = offset ? Math.max(0, Math.trunc(offset) - 1) : 0;
7646
+ const limit = params.limit === void 0 ? void 0 : Math.max(1, Math.trunc(params.limit));
7647
+ const endLine = startLine + Math.min(limit ?? 2e3, DEFAULT_MAX_LINES);
7648
+ const scan = yield* fs.scanLines(resolved, Number.isSafeInteger(endLine) ? {
7649
+ startLine,
7650
+ endLine,
7651
+ maxBytes: DEFAULT_MAX_BYTES
7652
+ } : {
7653
+ startLine: 0,
7654
+ endLine: 1,
7655
+ maxBytes: DEFAULT_MAX_BYTES
7656
+ });
7657
+ if (startLine >= Math.max(scan.totalLines, 1)) return yield* fail(`Offset ${offset} is beyond end of file (${scan.totalLines} lines total)`);
7658
+ const grown = Buffer.byteLength(scan.text, "utf8") > 51200 ? truncateHead(scan.text) : void 0;
7659
+ const firstLineBytes = grown?.firstLineExceedsLimit ? Buffer.byteLength(scan.text.split("\n", 1)[0] ?? "", "utf8") : scan.firstLineBytes;
7660
+ const shown = grown === void 0 ? scan : {
7661
+ text: grown.content,
7662
+ lines: grown.outputLines
7663
+ };
7664
+ const first = startLine + 1;
7665
+ const selected = Math.min(scan.totalLines, limit === void 0 ? Infinity : startLine + limit) - startLine;
7666
+ const truncation = (truncatedBy, firstLineExceedsLimit) => ({
7667
+ truncated: true,
7668
+ truncatedBy,
7669
+ totalLines: selected,
7670
+ outputLines: shown.lines,
7671
+ outputBytes: Buffer.byteLength(shown.text, "utf8"),
7672
+ firstLineExceedsLimit,
7673
+ maxLines: DEFAULT_MAX_LINES,
7674
+ maxBytes: DEFAULT_MAX_BYTES
7675
+ });
7676
+ if (firstLineBytes > 51200) return {
7677
+ type: "text",
7678
+ text: `[Line ${first} is ${formatSize(firstLineBytes)}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${first}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`,
7679
+ truncation: {
7680
+ ...truncation("bytes", true),
7681
+ outputLines: 0,
7682
+ outputBytes: 0
7683
+ }
7684
+ };
7685
+ if (shown.lines < selected) {
7686
+ const last = startLine + shown.lines;
7687
+ const truncatedBy = shown.lines < Math.min(selected, 2e3) ? "bytes" : "lines";
7688
+ const limitText = truncatedBy === "bytes" ? ` (${formatSize(DEFAULT_MAX_BYTES)} limit)` : "";
7689
+ return {
7690
+ type: "text",
7691
+ text: `${shown.text}\n\n[Showing lines ${first}-${last} of ${scan.totalLines}${limitText}. Use offset=${last + 1} to continue.]`,
7692
+ truncation: truncation(truncatedBy, false)
7693
+ };
7694
+ }
7695
+ if (limit !== void 0 && startLine + limit < scan.totalLines) {
7696
+ const remaining = scan.totalLines - (startLine + limit);
7697
+ return {
7698
+ type: "text",
7699
+ text: `${shown.text}\n\n[${remaining} more lines in file. Use offset=${startLine + limit + 1} to continue.]`
7700
+ };
7701
+ }
7702
+ return {
7703
+ type: "text",
7704
+ text: shown.text
7705
+ };
7706
+ });
7707
+ const readHandler = (params) => Effect.gen(function* () {
7708
+ const fs = yield* io_exports.SandboxIO.FileSystem;
7709
+ const resolved = yield* resolveRead(fs, params.path);
7710
+ const stat = yield* fs.stat(resolved);
7711
+ if (stat.isDirectory) return yield* fail("EISDIR: illegal operation on a directory, read");
7712
+ if (!stat.isFile) return yield* fail("Not a regular file");
7713
+ const head = yield* fs.readBytes(resolved, 0, BINARY_PROBE_BYTES);
7714
+ const mimeType = yield* detectOf({
7715
+ size: stat.size ?? Infinity,
7716
+ read: (offset, length) => offset + length <= head.length ? Effect.succeed(head.subarray(offset, offset + length)) : fs.readBytes(resolved, offset, length)
7717
+ });
7718
+ if (mimeType !== void 0) return yield* readImage(fs, resolved, mimeType);
7719
+ if (head.includes(0)) return yield* fail(`Unsupported read ${params.path}: binary file.`);
7720
+ return yield* readText(fs, resolved, params);
7721
+ }).pipe(Effect.catchTag("SandboxFileSystemError", fsFailure$1(params.path)));
7722
+ /** The read tool: definition + handler, wired the testable (def/exec split) way. */
7723
+ const readTool = tool_exports.implement(readDef, readHandler);
7724
+ const readPlugin = (0, plugin_exports$1.define)({
7725
+ id: "codework.tool.read",
7726
+ kind: "tool",
7727
+ setup: Effect.fn("ReadPlugin.setup")(function* (ctx) {
7728
+ const fs = yield* io_exports.SandboxIO.FileSystem;
7729
+ ctx.plugin.tools.add(tool_exports.provide(readTool, Layer.succeed(io_exports.SandboxIO.FileSystem, fs)));
7730
+ })
7731
+ });
7732
+ //#endregion
7733
+ //#region src/plugin/builtin/tool/write.ts
7734
+ /**
7735
+ * The built-in write plugin. The whole write holds the file's mutation lock, so
7736
+ * it never interleaves with an `edit` of the same file; the filesystem creates
7737
+ * missing parent directories on every backend.
7738
+ */
7739
+ const WriteParams = Schema.Struct({
7740
+ path: Schema.String.annotate({ description: "Path to the file to write (relative or absolute)" }),
7741
+ content: Schema.String.annotate({ description: "Content to write to the file" })
7742
+ });
7743
+ var WriteFailed = class extends Schema.TaggedError()("WriteFailed", { message: Schema.String }) {};
7744
+ const writeDef = tool_exports.define({
7745
+ name: "write",
7746
+ label: "write",
7747
+ description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
7748
+ promptSnippet: "Create or overwrite files",
7749
+ promptGuidelines: ["Use write only for new files or complete rewrites."],
7750
+ parameters: WriteParams,
7751
+ constrainedSampling: {
7752
+ type: "json_schema",
7753
+ strict: "prefer"
7754
+ },
7755
+ success: Schema.Struct({ path: Schema.String }),
7756
+ failure: WriteFailed,
7757
+ encodeContent: (success) => [{
7758
+ type: "text",
7759
+ text: `Successfully wrote to ${success.path}`
7760
+ }],
7761
+ encodeFailureContent: (failure) => [{
7762
+ type: "text",
7763
+ text: failure.message
7764
+ }],
7765
+ encodeDetails: () => void 0
7766
+ });
7767
+ /** Filesystem failures are the model's to see, as the backend reported them. */
7768
+ const fsFailure = (path) => (error) => Effect.fail(new WriteFailed({ message: error.cause instanceof Error ? error.cause.message : `Could not write file: ${path}` }));
7769
+ const writeHandler = (params) => Effect.gen(function* () {
7770
+ const fs = yield* io_exports.SandboxIO.FileSystem;
7771
+ const mutation = yield* io_exports.SandboxIO.Mutation;
7772
+ const path = normalize$1(params.path);
7773
+ yield* mutation.withFile(path, fs.writeFile(path, params.content));
7774
+ return { path: params.path };
7775
+ }).pipe(Effect.catchTag("SandboxFileSystemError", fsFailure(params.path)));
7776
+ /** The write tool: definition + handler, wired the testable (def/exec split) way. */
7777
+ const writeTool = tool_exports.implement(writeDef, writeHandler);
7778
+ const writePlugin = (0, plugin_exports$1.define)({
7779
+ id: "codework.tool.write",
7780
+ kind: "tool",
7781
+ setup: Effect.fn("WritePlugin.setup")(function* (ctx) {
7782
+ const mounted = Layer.merge(Layer.succeed(io_exports.SandboxIO.FileSystem, yield* io_exports.SandboxIO.FileSystem), Layer.succeed(io_exports.SandboxIO.Mutation, yield* io_exports.SandboxIO.Mutation));
7783
+ ctx.plugin.tools.add(tool_exports.provide(writeTool, mounted));
7784
+ })
7785
+ });
7786
+ //#endregion
6123
7787
  //#region src/plugin/builtin.ts
6124
7788
  /**
6125
7789
  * Every plugin the harness ships, and the selection a caller who passes no `plugins` gets. Tool
6126
- * contributors come first; within the prompt domain, declaration order is run order.
7790
+ * contributors come first; within the prompt domain, declaration order is run order. The search
7791
+ * tools (grep, find, ls) are `optIn`, as Pi ships them off: settings turn each on with
7792
+ * `{ "plugin": "codework.tool.grep", "enabled": true }`.
6127
7793
  */
6128
7794
  const builtins = Object.freeze([
6129
7795
  bashPlugin,
7796
+ readPlugin,
7797
+ writePlugin,
7798
+ editPlugin,
7799
+ grepPlugin,
7800
+ findPlugin,
7801
+ lsPlugin,
6130
7802
  defaultPromptPlugin,
6131
7803
  instructionPlugin
6132
7804
  ]);
@@ -7208,6 +8880,7 @@ const Definition = Schema.Struct({
7208
8880
  id: Id,
7209
8881
  kind: Schema.Literals(Object.keys(plugin_exports$1.domains)),
7210
8882
  events: Schema.optional(Schema.Array(Schema.declare((value) => Predicate.hasProperty(value, "type") && Predicate.isString(value.type)))),
8883
+ optIn: Schema.optional(Schema.Boolean),
7211
8884
  setup: Schema.declare((value) => Predicate.isFunction(value))
7212
8885
  });
7213
8886
  /** Every plugin failure says which file to go and edit, when there is one. */
@@ -7493,11 +9166,14 @@ const load$1 = Effect.fn("PluginCatalog.load")(function* (references, options) {
7493
9166
  const select = Effect.fn("PluginCatalog.select")(function* (references, pool) {
7494
9167
  const operations = /* @__PURE__ */ new Map();
7495
9168
  const missing = [];
7496
- const choose = (id, index) => operations.set(id, {
7497
- enabled: true,
7498
- index,
7499
- options: operations.get(id)?.options ?? {}
7500
- });
9169
+ const choose = (id, index) => {
9170
+ const previous = operations.get(id);
9171
+ operations.set(id, {
9172
+ enabled: pool.plugins.get(id)?.optIn === true ? previous?.enabled ?? false : true,
9173
+ index,
9174
+ options: previous?.options ?? {}
9175
+ });
9176
+ };
7501
9177
  for (const [index, reference] of references.entries()) {
7502
9178
  if (isPatch(reference)) {
7503
9179
  const named = reference.plugin ?? reference.package;
@@ -7812,7 +9488,8 @@ function resolveRequest(block, model) {
7812
9488
  *
7813
9489
  * Separate from {@link resolveRequest} because these steer the loop itself -- how hard to
7814
9490
  * think, how to schedule tool calls -- rather than the request body. Runtime bindings
7815
- * outrank a matched block, which outranks the file's top level.
9491
+ * outrank a matched block, which outranks the file's top level. The thinking level is the
9492
+ * requested one; State clamps it to the model once the catalog lookup is in hand.
7816
9493
  */
7817
9494
  const resolveControls = (settings, block, runtime) => ({
7818
9495
  thinkingLevel: runtime.thinkingLevel ?? block.thinkingLevel ?? settings.model.thinkingLevel,
@@ -7833,6 +9510,25 @@ function compose(settings, options = {}, session = {}) {
7833
9510
  };
7834
9511
  }
7835
9512
  //#endregion
9513
+ //#region src/runner/attribution.ts
9514
+ const OPENROUTER_HOST = "openrouter.ai";
9515
+ const isOpenRouter = (model) => model.provider.id === "openrouter" || (model.api?.url ?? model.baseUrl).includes(OPENROUTER_HOST);
9516
+ const defaults = (model) => isOpenRouter(model) ? {
9517
+ "HTTP-Referer": "https://codework.sh",
9518
+ "X-OpenRouter-Title": "CodeWork",
9519
+ "X-OpenRouter-Categories": "cli-agent,personal-agent"
9520
+ } : void 0;
9521
+ const withAttribution = (model) => {
9522
+ const headers = defaults(model);
9523
+ return headers === void 0 ? model : {
9524
+ ...model,
9525
+ headers: {
9526
+ ...headers,
9527
+ ...model.headers
9528
+ }
9529
+ };
9530
+ };
9531
+ //#endregion
7836
9532
  //#region src/runner/event.ts
7837
9533
  const make$6 = Effect.fn("LLMEventPublisher.make")(function* (input) {
7838
9534
  const events = yield* Service$16;
@@ -8087,7 +9783,7 @@ const oauthCredentials = async (protocol, options) => {
8087
9783
  };
8088
9784
  /** Start a provider stream using the model pinned by State. */
8089
9785
  const openWith = (options = {}) => Effect.fn("LLM.open")(function* (input, signal) {
8090
- const model = input.resolvedModel;
9786
+ const model = withAttribution(input.resolvedModel);
8091
9787
  const configured = runtimeOptions(input, model, signal);
8092
9788
  const resolved = configured.apiKey === void 0 ? yield* Effect.tryPromise({
8093
9789
  try: () => oauthCredentials(model.protocol, options),
@@ -8220,8 +9916,9 @@ const encodeOutcome = (def, call, exit, latest) => Effect.gen(function* () {
8220
9916
  if (Exit.isSuccess(exit)) {
8221
9917
  const encoded = yield* Schema.encodeUnknownEffect(asCodec(def.success))(exit.value).pipe(Effect.orDie);
8222
9918
  const content = def.encodeContent ? def.encodeContent(exit.value) : [yield* jsonText(encoded)];
9919
+ const details = def.encodeDetails ? def.encodeDetails(exit.value) : encoded;
8223
9920
  const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
8224
- return completed(call, content, now, encoded);
9921
+ return completed(call, content, now, details);
8225
9922
  }
8226
9923
  const cause = exit.cause;
8227
9924
  if (Cause.hasInterrupts(cause)) {
@@ -8286,6 +9983,15 @@ const make$4 = (tools) => {
8286
9983
  impls.set(name, entry);
8287
9984
  }
8288
9985
  const wire = [...impls.values()].map(({ tool }) => (0, tool_exports.toAikitTool)(tool.definition));
9986
+ const decoders = new Map(wire.map((tool) => {
9987
+ const decode = Schema.decodeUnknownEffect(Schema.toCodecJson(asCodec(impls.get(tool.name).tool.definition.parameters)));
9988
+ const parse = (args) => {
9989
+ const normalized = structuredClone(args);
9990
+ normalizeOptionalNulls(normalized, tool.parameters);
9991
+ return decode(normalized);
9992
+ };
9993
+ return [tool.name, parse];
9994
+ }));
8289
9995
  const handle = (call, options) => Effect.gen(function* () {
8290
9996
  const entry = impls.get(call.name);
8291
9997
  if (entry === void 0) {
@@ -8297,7 +10003,18 @@ const make$4 = (tools) => {
8297
10003
  }
8298
10004
  const { tool: impl, hooks } = entry;
8299
10005
  const def = impl.definition;
8300
- const decoded = yield* Effect.result(Schema.decodeEffect(asCodec(def.parameters))(call.arguments));
10006
+ const prepared = yield* Effect.result(Effect.try({
10007
+ try: () => def.prepareArguments ? def.prepareArguments(call.arguments) : call.arguments,
10008
+ catch: (cause) => cause instanceof Error ? cause.message : String(cause)
10009
+ }));
10010
+ if (Result.isFailure(prepared)) {
10011
+ const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
10012
+ return errored(call, [text(`Invalid arguments for ${call.name}: ${prepared.failure}`)], now, {
10013
+ error: "invalid_arguments",
10014
+ name: call.name
10015
+ });
10016
+ }
10017
+ const decoded = yield* Effect.result(decoders.get(call.name)(prepared.success));
8301
10018
  if (Result.isFailure(decoded)) {
8302
10019
  const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
8303
10020
  return errored(call, [text(`Invalid arguments for ${call.name}: ${decoded.failure.message}`)], now, {
@@ -8568,6 +10285,8 @@ const assertTool = (tool) => {
8568
10285
  if (def.failure !== void 0 && !Schema.isSchema(def.failure)) throw new Error(`Tool ${name}: failure must be an Effect Schema`);
8569
10286
  if (def.encodeContent !== void 0 && !Predicate.isFunction(def.encodeContent)) throw new Error(`Tool ${name}: encodeContent must be a function`);
8570
10287
  if (def.encodeFailureContent !== void 0 && !Predicate.isFunction(def.encodeFailureContent)) throw new Error(`Tool ${name}: encodeFailureContent must be a function`);
10288
+ if (def.prepareArguments !== void 0 && !Predicate.isFunction(def.prepareArguments)) throw new Error(`Tool ${name}: prepareArguments must be a function`);
10289
+ if (def.encodeDetails !== void 0 && !Predicate.isFunction(def.encodeDetails)) throw new Error(`Tool ${name}: encodeDetails must be a function`);
8571
10290
  if (!Predicate.isFunction(tool.handler)) throw new Error(`Tool ${name}: handler must be a function`);
8572
10291
  };
8573
10292
  const assertHooks = (name, hooks) => {
@@ -8783,6 +10502,7 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
8783
10502
  const eventRegistry = yield* Service$4;
8784
10503
  const eventService = yield* Service$16;
8785
10504
  const events = makeEvents(eventService);
10505
+ const global = yield* Service$17;
8786
10506
  const loading = yield* Semaphore.make(1);
8787
10507
  const updated = (origin, id, status) => eventService.publish(PluginUpdated, {
8788
10508
  status,
@@ -8846,10 +10566,22 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
8846
10566
  ...Option.getOrElse(yield* runtime.get(sessionId), () => ({}))
8847
10567
  };
8848
10568
  const loadedSettings = yield* settings.load(hostDir);
10569
+ const composed = compose(loadedSettings, options, sessionOptions);
10570
+ const resolvedModel = yield* resolve$1({
10571
+ provider: composed.provider,
10572
+ model: composed.model,
10573
+ settings: composed.block,
10574
+ models: loadedSettings.models
10575
+ });
10576
+ const thinkingLevel = Model.clampThinkingLevel(resolvedModel, composed.thinkingLevel);
8849
10577
  return {
8850
10578
  hostDir,
8851
10579
  loadedSettings,
8852
- configured: compose(loadedSettings, options, sessionOptions)
10580
+ configured: {
10581
+ ...composed,
10582
+ resolvedModel,
10583
+ thinkingLevel
10584
+ }
8853
10585
  };
8854
10586
  });
8855
10587
  return Service$1.of({
@@ -8881,16 +10613,10 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
8881
10613
  snapshot: Effect.fn("State.snapshot")(function* (sessionId) {
8882
10614
  const { hostDir, loadedSettings, configured } = yield* configure(sessionId);
8883
10615
  const { promptCustom, promptSystemAppend, provider: _provider, model: _model, thinkingLevel: _thinkingLevel, toolExecution: _toolExecution, ...rest } = configured.runtime;
8884
- const { provider, model, thinkingLevel, toolExecution } = configured;
10616
+ const { provider, model, resolvedModel, thinkingLevel, toolExecution } = configured;
8885
10617
  const request = merge(resolveOptions(configured.block), rest);
8886
10618
  const sandbox = yield* io_exports.SandboxIO.Current;
8887
10619
  const location = yield* Service$8;
8888
- const resolvedModel = yield* resolve$1({
8889
- provider,
8890
- model,
8891
- settings: configured.block,
8892
- models: loadedSettings.models
8893
- });
8894
10620
  const refs = references(loadedSettings);
8895
10621
  const failed = (cause) => new SnapshotError({
8896
10622
  sessionId,
@@ -8925,6 +10651,7 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
8925
10651
  ...yield* run(chosen.selection, {
8926
10652
  sessionId,
8927
10653
  sandbox,
10654
+ paths: global,
8928
10655
  location,
8929
10656
  settings: loadedSettings,
8930
10657
  model: resolvedModel,
@@ -9467,7 +11194,7 @@ var harness_exports = /* @__PURE__ */ __exportAll({
9467
11194
  });
9468
11195
  const layer = (options = {}) => Layer.unwrap(Effect.gen(function* () {
9469
11196
  const hostCwd = options.hostCwd ?? process.cwd();
9470
- const paths = yield* resolve$3(options.home === void 0 ? {} : { home: hostPath.resolve(hostCwd, expandTilde(options.home, hostPath)) });
11197
+ const paths = yield* resolve$4(options.home === void 0 ? {} : { home: hostPath.resolve(hostCwd, expandTilde(options.home, hostPath)) });
9471
11198
  const global = layerWith(paths);
9472
11199
  const settingsOptions = options.userConfigDir === void 0 ? {} : { userConfigDir: userConfigDir(options.userConfigDir, hostCwd) };
9473
11200
  const config = yield* load$3({