@codeworksh/harness 0.0.1-dev.20260925163524 → 0.1.0-canary.1.1.gc0ff5572

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,34 +1,39 @@
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
- import { Cause, Clock, Config, Context, Crypto, DateTime, Deferred, Duration, Effect, Encoding, Exit, Fiber, FiberSet, FileSystem, Layer, LayerMap, Option, Path, Predicate, PubSub, Queue, Ref, Result, Schedule, Schema, SchemaIssue, Scope, Semaphore, Stream, String as String$1 } from "effect";
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";
8
+ import { Message, Model, getEnvApiKey, llm, normalizeOptionalNulls, stream } from "@codeworksh/aikit";
7
9
  import { MessageID as ID, SessionID as ID$1 } from "@codeworksh/plugin/ids";
8
- import { Migrator, SqlClient, SqlClient as SqlClient$1, SqlError, SqlSchema, SqlSchema as SqlSchema$1 } from "effect/unstable/sql";
10
+ import { Migrator, SqlClient, SqlClient as SqlClient$1, SqlError, SqlSchema, SqlSchema as SqlSchema$1 } from "effect/sql";
9
11
  import { uuidv7 } from "uuidv7";
10
12
  import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node";
11
13
  import * as os from "node:os";
12
- import { homedir, tmpdir } from "node:os";
14
+ import { homedir } from "node:os";
13
15
  import { NodeChildProcessSpawner, NodeCrypto, NodeFileSystem, NodePath } from "@effect/platform-node";
14
- import { Model, VariantSchema } from "effect/unstable/schema";
15
- import { Message, Model as Model$1, llm, stream } from "@codeworksh/aikit";
16
+ import { Model as Model$1, VariantSchema } from "effect/schema";
17
+ import { Hex } from "effect/encoding";
16
18
  import { MemoryProvider, RealFSProvider, SqliteProvider, create } from "@platformatic/vfs";
17
19
  import fs, { statSync } from "node:fs";
18
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
20
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
19
21
  import { Buffer as Buffer$1 } from "node:buffer";
20
22
  import { Bash } from "just-bash";
21
- import { DirectoryNotFoundError, DirectoryNotFoundError as DirectoryNotFoundError$1, Info, Info as Info$1, NotDirectoryError, NotDirectoryError as NotDirectoryError$1, Ref as Ref$2, Service, Service as Service$8 } from "@codeworksh/plugin/location";
23
+ import { DirectoryNotFoundError, DirectoryNotFoundError as DirectoryNotFoundError$1, Info, Info as Info$1, NotDirectoryError, NotDirectoryError as NotDirectoryError$1, Service, Service as Service$8 } from "@codeworksh/plugin/location";
22
24
  import { generateModels } from "@codeworksh/aikit/modelgen";
25
+ import { JsonGitHubCopilotAuthStorage, getGitHubCopilotApiKey } from "@codeworksh/aikit/oauth/github/copilot";
26
+ import { JsonOpenAICodexAuthStorage, getOpenAICodexApiKey } from "@codeworksh/aikit/oauth/openai/codex";
27
+ import { parse, printParseErrorCode } from "jsonc-parser";
28
+ import * as Section from "@codeworksh/plugin/plugin/section";
29
+ import * as Diff from "diff";
30
+ import { FetchHttpClient, HttpClient } from "effect/http";
23
31
  import { fileURLToPath, pathToFileURL } from "node:url";
24
32
  import path from "node:path";
25
33
  import { Script, constants } from "node:vm";
26
34
  import npa from "npm-package-arg";
27
35
  import * as AikitFailure from "@codeworksh/aikit/failure";
28
- import { JsonGitHubCopilotAuthStorage, getGitHubCopilotApiKey } from "@codeworksh/aikit/oauth/github/copilot";
29
- import { JsonOpenAICodexAuthStorage, getOpenAICodexApiKey } from "@codeworksh/aikit/oauth/openai/codex";
30
36
  import "@codeworksh/plugin/tool/error";
31
- import { parse, printParseErrorCode } from "jsonc-parser";
32
37
  //#region src/schema.ts
33
38
  var schema_exports$9 = /* @__PURE__ */ __exportAll({});
34
39
  import * as import__codeworksh_plugin_schema from "@codeworksh/plugin/schema";
@@ -38,15 +43,46 @@ __reExport(schema_exports$9, import__codeworksh_plugin_schema);
38
43
  var schema_exports$8 = /* @__PURE__ */ __exportAll({
39
44
  AssistantEnvelopeUsage: () => AssistantEnvelopeUsage,
40
45
  CompactionData: () => CompactionData,
46
+ Config: () => Config$1,
47
+ DEFAULT_TITLE: () => DEFAULT_TITLE,
41
48
  ID: () => ID$1,
42
49
  IDFromDb: () => IDFromDb,
43
50
  InterruptReason: () => InterruptReason,
44
51
  JsonObject: () => JsonObject,
45
52
  MessageEnvelopeIdentity: () => MessageEnvelopeIdentity,
46
53
  SessionSchema: () => schema_exports$8,
47
- Usage: () => Usage
54
+ TITLE_MAX_LENGTH: () => 48,
55
+ ThinkingLevel: () => ThinkingLevel,
56
+ Usage: () => Usage,
57
+ titleFrom: () => titleFrom
48
58
  });
49
59
  const IDFromDb = Schema.String.pipe(Schema.brand("Session.ID"));
60
+ /** The title of a session created without one; its first prompt replaces it. */
61
+ const DEFAULT_TITLE = "Session";
62
+ /**
63
+ * A title from a prompt: its first non-empty line, whitespace collapsed, cut on a word boundary
64
+ * to {@link TITLE_MAX_LENGTH} characters including the ellipsis. `undefined` for a blank prompt.
65
+ */
66
+ const titleFrom = (text) => {
67
+ const line = text.split("\n").map((one) => one.replace(/\s+/g, " ").trim()).find((one) => one.length > 0);
68
+ if (line === void 0 || line.length <= 48) return line;
69
+ const cut = line.slice(0, 47);
70
+ const space = cut.lastIndexOf(" ");
71
+ return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}…`;
72
+ };
73
+ const ThinkingLevel = Schema.Literals(Object.values(Model.ThinkingLevelEnum));
74
+ /**
75
+ * The model and thinking level chosen for a session, over its settings. Every key is optional:
76
+ * an absent one means the session follows its settings. `session.config.changed` carries only the
77
+ * keys it changes, and the `session.config` column holds them merged.
78
+ */
79
+ const Config$1 = Schema.Struct({
80
+ model: (0, schema_exports$9.optional)(Schema.Struct({
81
+ provider: Schema.String,
82
+ id: Schema.String
83
+ })),
84
+ thinkingLevel: (0, schema_exports$9.optional)(ThinkingLevel)
85
+ });
50
86
  /**
51
87
  * Why an execution stopped. Supplied by whoever asked for the interruption --
52
88
  * a stop the user asked for and a stop the process imposed on shutdown are
@@ -101,12 +137,12 @@ var global_exports = /* @__PURE__ */ __exportAll({
101
137
  app: () => app,
102
138
  appConfigDir: () => appConfigDir,
103
139
  authFile: () => authFile,
104
- defaultLayer: () => defaultLayer$4,
140
+ defaultLayer: () => defaultLayer$2,
105
141
  homeConfig: () => homeConfig,
106
142
  layer: () => layer$27,
107
- layerWith: () => layerWith$1,
143
+ layerWith: () => layerWith,
108
144
  make: () => make$18,
109
- resolve: () => resolve$3
145
+ resolve: () => resolve$4
110
146
  });
111
147
  const appConfigDir = ".codework";
112
148
  const app = "codework";
@@ -120,7 +156,8 @@ function make$18(input = {}) {
120
156
  home,
121
157
  cache: input.cache ?? posix.join(home, "cache"),
122
158
  data: input.data ?? posix.join(home, "data"),
123
- log: input.log ?? posix.join(home, "log")
159
+ log: input.log ?? posix.join(home, "log"),
160
+ bin: input.bin ?? posix.join(home, "bin")
124
161
  };
125
162
  }
126
163
  /**
@@ -133,7 +170,7 @@ function make$18(input = {}) {
133
170
  * would be invisible to the other.
134
171
  */
135
172
  const authFile = (home) => home === void 0 ? void 0 : posix.join(home, "aikit", "auth.json");
136
- const resolve$3 = Effect.fn("Global.resolve")(function* (input = {}) {
173
+ const resolve$4 = Effect.fn("Global.resolve")(function* (input = {}) {
137
174
  const home = input.home === void 0 ? yield* homeConfig : expandHome(input.home);
138
175
  return make$18({
139
176
  ...input,
@@ -141,7 +178,7 @@ const resolve$3 = Effect.fn("Global.resolve")(function* (input = {}) {
141
178
  });
142
179
  });
143
180
  const build = (input) => Effect.gen(function* () {
144
- const paths = yield* resolve$3(input);
181
+ const paths = yield* resolve$4(input);
145
182
  yield* Effect.all([
146
183
  fileSystem.makeDirectory(paths.cache, { recursive: true }),
147
184
  fileSystem.makeDirectory(paths.data, { recursive: true }),
@@ -150,8 +187,8 @@ const build = (input) => Effect.gen(function* () {
150
187
  return Service$17.of(paths);
151
188
  });
152
189
  const layer$27 = Layer.effect(Service$17, build({}));
153
- const defaultLayer$4 = layer$27;
154
- const layerWith$1 = (input) => Layer.effect(Service$17, build(input));
190
+ const defaultLayer$2 = layer$27;
191
+ const layerWith = (input) => Layer.effect(Service$17, build(input));
155
192
  //#endregion
156
193
  //#region src/db/migrations.ts
157
194
  const migrations = {
@@ -329,6 +366,9 @@ const migrations = {
329
366
  }),
330
367
  "202609190001_session_host_dir": Effect.gen(function* () {
331
368
  yield* (yield* SqlClient.SqlClient)`ALTER TABLE session ADD COLUMN host_dir TEXT`;
369
+ }),
370
+ "202609290001_session_config": Effect.gen(function* () {
371
+ yield* (yield* SqlClient.SqlClient)`ALTER TABLE session ADD COLUMN config TEXT`;
332
372
  })
333
373
  };
334
374
  //#endregion
@@ -337,10 +377,10 @@ var db_exports = /* @__PURE__ */ __exportAll({
337
377
  Database: () => db_exports,
338
378
  SqlClient: () => SqlClient$1,
339
379
  SqlSchema: () => SqlSchema$1,
340
- defaultLayer: () => defaultLayer$3,
380
+ defaultLayer: () => defaultLayer$1,
341
381
  layer: () => layer$26,
342
382
  locationConfig: () => locationConfig,
343
- path: () => path$1,
383
+ path: () => path$2,
344
384
  resolveDatabaseLocation: () => resolveDatabaseLocation
345
385
  });
346
386
  const setup = Effect.gen(function* () {
@@ -367,11 +407,11 @@ function resolveDatabaseLocation(configured, data) {
367
407
  if (configured === ":memory:" || posix.isAbsolute(configured)) return configured;
368
408
  return posix.join(data, configured);
369
409
  }
370
- const path$1 = Effect.fn("Database.path")(function* (data) {
371
- 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;
372
412
  return resolveDatabaseLocation(yield* locationConfig, directory);
373
413
  });
374
- const defaultLayer$3 = Layer.unwrap(path$1().pipe(Effect.map(layer$26)));
414
+ const defaultLayer$1 = Layer.unwrap(path$2().pipe(Effect.map(layer$26)));
375
415
  //#endregion
376
416
  //#region src/event/schema.ts
377
417
  var schema_exports$7 = /* @__PURE__ */ __exportAll({});
@@ -386,13 +426,54 @@ __reExport(schema_exports$6, import__codeworksh_plugin_project);
386
426
  //#region src/session/prompt/schema.ts
387
427
  var schema_exports = /* @__PURE__ */ __exportAll({
388
428
  Delivery: () => Delivery,
429
+ IMAGE_MAX_BASE64: () => IMAGE_MAX_BASE64,
430
+ IMAGE_TYPES: () => IMAGE_TYPES,
431
+ ImagePart: () => ImagePart,
432
+ InvalidPromptError: () => InvalidPromptError,
433
+ Part: () => Part,
389
434
  Prompt: () => Prompt,
390
- PromptSchema: () => schema_exports
435
+ PromptSchema: () => schema_exports,
436
+ TextPart: () => TextPart,
437
+ imageProblem: () => imageProblem
438
+ });
439
+ const IMAGE_TYPES = [
440
+ "image/png",
441
+ "image/jpeg",
442
+ "image/gif",
443
+ "image/webp"
444
+ ];
445
+ const IMAGE_MAX_BASE64 = 4718592;
446
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
447
+ const imageProblem = (image) => {
448
+ if (!IMAGE_TYPES.includes(image.mimeType)) return `Unsupported image type ${image.mimeType}; use ${IMAGE_TYPES.join(", ")}`;
449
+ if (image.data.length > 4718592) return `Image exceeds the ${IMAGE_MAX_BASE64 / 1024 / 1024} MB limit of base64`;
450
+ if (image.data.length === 0 || image.data.length % 4 !== 0 || !BASE64.test(image.data)) return "Image data is not base64";
451
+ };
452
+ const TextPart = Schema.Struct({
453
+ type: Schema.Literal("text"),
454
+ text: Schema.String
391
455
  });
392
- const Prompt = Schema.Struct({ text: Schema.String }).annotate({ identifier: "Prompt" }).pipe((0, schema_exports$9.withStatics)((schema) => ({
456
+ /** aikit's image content: base64 `data` and its `mimeType`. */
457
+ const ImagePart = Schema.Struct({
458
+ type: Schema.Literal("image"),
459
+ data: Schema.String,
460
+ mimeType: Schema.String
461
+ }).check(Schema.makeFilter(imageProblem));
462
+ const Part = Schema.Union([TextPart, ImagePart]);
463
+ /** A user prompt: aikit user-message parts, in the order they were written. */
464
+ const Prompt = Schema.Struct({ parts: Schema.Array(Part).check(Schema.isMinLength(1)) }).annotate({ identifier: "Prompt" }).pipe((0, schema_exports$9.withStatics)((schema) => ({
393
465
  equivalence: Schema.toEquivalence(schema),
394
- fromUserMessage: (input) => schema.make({ text: input.text })
466
+ fromText: (text) => schema.make({ parts: [{
467
+ type: "text",
468
+ text
469
+ }] }),
470
+ text: (prompt) => prompt.parts.flatMap((part) => part.type === "text" ? [part.text] : []).join("\n\n")
395
471
  })));
472
+ var InvalidPromptError = class extends Schema.TaggedError()("InvalidPromptError", { reason: Schema.String }) {
473
+ get message() {
474
+ return this.reason;
475
+ }
476
+ };
396
477
  const Delivery = Schema.Literals(["steer", "followUp"]);
397
478
  //#endregion
398
479
  //#region src/space/schema.ts
@@ -402,8 +483,8 @@ __reExport(schema_exports$5, import__codeworksh_plugin_space);
402
483
  //#endregion
403
484
  //#region src/db/schema.sql.ts
404
485
  const Timestamps = {
405
- createdAt: Model.DateTimeInsertFromNumber,
406
- updatedAt: Model.DateTimeUpdateFromNumber
486
+ createdAt: Model$1.DateTimeInsertFromNumber,
487
+ updatedAt: Model$1.DateTimeUpdateFromNumber
407
488
  };
408
489
  const Metadata = Schema.Record(Schema.String, Schema.String);
409
490
  const entryTypes = [
@@ -441,19 +522,19 @@ const toolStatuses = [
441
522
  ];
442
523
  const inputDeliveries = ["steer", "followUp"];
443
524
  const EventData = Schema.Record(Schema.String, Schema.Unknown);
444
- Model.Class("EventSequenceRow")({
525
+ Model$1.Class("EventSequenceRow")({
445
526
  aggregateId: Schema.String,
446
527
  seq: Schema.Natural,
447
- ownerId: Model.FieldOption(Schema.String)
528
+ ownerId: Model$1.FieldOption(Schema.String)
448
529
  });
449
- var EventRow = class extends Model.Class("EventRow")({
530
+ var EventRow = class extends Model$1.Class("EventRow")({
450
531
  id: schema_exports$7.EventSchema.ID,
451
532
  aggregateId: Schema.String,
452
533
  seq: Schema.Natural,
453
534
  type: Schema.String,
454
- data: Model.JsonFromString(EventData)
535
+ data: Model$1.JsonFromString(EventData)
455
536
  }) {};
456
- var ProjectRow = class extends Model.Class("ProjectRow")({
537
+ var ProjectRow = class extends Model$1.Class("ProjectRow")({
457
538
  id: Schema.String,
458
539
  name: Schema.String,
459
540
  status: schema_exports$6.ProjectSchema.Status,
@@ -467,81 +548,82 @@ var ProjectRow = class extends Model.Class("ProjectRow")({
467
548
  * The host has no row: `local` is reserved and `NULL` is its storage form, so
468
549
  * this table only ever holds namespaces something had to provision.
469
550
  */
470
- var SandboxInstanceRow = class extends Model.Class("SandboxInstanceRow")({
551
+ var SandboxInstanceRow = class extends Model$1.Class("SandboxInstanceRow")({
471
552
  id: instance_exports.SandboxInstance.ID,
472
553
  driver: Schema.String,
473
554
  kind: instance_exports.SandboxInstance.Kind,
474
- providerResourceId: Model.FieldOption(Schema.String),
475
- runtimeConfig: Model.FieldOption(Schema.String),
555
+ providerResourceId: Model$1.FieldOption(Schema.String),
556
+ runtimeConfig: Model$1.FieldOption(Schema.String),
476
557
  ownership: instance_exports.SandboxInstance.Ownership,
477
558
  status: instance_exports.SandboxInstance.Status,
478
- providerStatus: Model.FieldOption(Schema.String),
479
- stateObservedAt: Model.DateTimeFromNumberWithNow,
480
- metadata: Model.FieldOption(Model.JsonFromString(Metadata)),
481
- lastError: Model.FieldOption(Model.JsonFromString(instance_exports.SandboxInstance.PersistedError)),
482
- lastMountedAt: Model.FieldOption(Schema.DateTimeUtcFromMillis),
483
- lastUnmountedAt: Model.FieldOption(Schema.DateTimeUtcFromMillis),
484
- lastUsedAt: Model.FieldOption(Schema.DateTimeUtcFromMillis),
485
- removedAt: Model.FieldOption(Schema.DateTimeUtcFromMillis),
559
+ providerStatus: Model$1.FieldOption(Schema.String),
560
+ stateObservedAt: Model$1.DateTimeFromNumberWithNow,
561
+ metadata: Model$1.FieldOption(Model$1.JsonFromString(Metadata)),
562
+ lastError: Model$1.FieldOption(Model$1.JsonFromString(instance_exports.SandboxInstance.PersistedError)),
563
+ lastMountedAt: Model$1.FieldOption(Schema.DateTimeUtcFromMillis),
564
+ lastUnmountedAt: Model$1.FieldOption(Schema.DateTimeUtcFromMillis),
565
+ lastUsedAt: Model$1.FieldOption(Schema.DateTimeUtcFromMillis),
566
+ removedAt: Model$1.FieldOption(Schema.DateTimeUtcFromMillis),
486
567
  ...Timestamps
487
568
  }) {};
488
- var SpaceRow = class extends Model.Class("SpaceRow")({
569
+ var SpaceRow = class extends Model$1.Class("SpaceRow")({
489
570
  id: Schema.String,
490
571
  projectId: Schema.String,
491
572
  location: schema_exports$9.AbsolutePath,
492
573
  kind: schema_exports$5.SpaceSchema.Kind,
493
- env: Model.FieldOption(instance_exports.SandboxInstance.ID),
574
+ env: Model$1.FieldOption(instance_exports.SandboxInstance.ID),
494
575
  status: schema_exports$5.SpaceSchema.Status,
495
576
  ...Timestamps
496
577
  }) {};
497
- var SessionRow = class extends Model.Class("SessionRow")({
578
+ var SessionRow = class extends Model$1.Class("SessionRow")({
498
579
  id: IDFromDb,
499
580
  spaceId: Schema.String,
500
- parentId: Model.FieldOption(IDFromDb),
581
+ parentId: Model$1.FieldOption(IDFromDb),
501
582
  slug: Schema.String,
502
583
  directory: schema_exports$9.AbsolutePath,
503
- hostDir: Model.FieldOption(schema_exports$9.AbsolutePath),
584
+ hostDir: Model$1.FieldOption(schema_exports$9.AbsolutePath),
585
+ config: Model$1.FieldOption(Model$1.JsonFromString(Config$1)),
504
586
  title: Schema.String,
505
- tag: Model.FieldOption(Schema.String),
506
- metadata: Model.FieldOption(Model.JsonFromString(Metadata)),
507
- leafEntryId: Model.FieldOption(Schema.String),
508
- cost: Model.GeneratedByDb(Schema.Finite),
509
- tokensInput: Model.GeneratedByDb(Schema.Int),
510
- tokensOutput: Model.GeneratedByDb(Schema.Int),
511
- tokensCacheRead: Model.GeneratedByDb(Schema.Int),
512
- tokensCacheWrite: Model.GeneratedByDb(Schema.Int),
587
+ tag: Model$1.FieldOption(Schema.String),
588
+ metadata: Model$1.FieldOption(Model$1.JsonFromString(Metadata)),
589
+ leafEntryId: Model$1.FieldOption(Schema.String),
590
+ cost: Model$1.GeneratedByDb(Schema.Finite),
591
+ tokensInput: Model$1.GeneratedByDb(Schema.Int),
592
+ tokensOutput: Model$1.GeneratedByDb(Schema.Int),
593
+ tokensCacheRead: Model$1.GeneratedByDb(Schema.Int),
594
+ tokensCacheWrite: Model$1.GeneratedByDb(Schema.Int),
513
595
  ...Timestamps
514
596
  }) {};
515
- var SessionInputRow = class extends Model.Class("SessionInputRow")({
597
+ var SessionInputRow = class extends Model$1.Class("SessionInputRow")({
516
598
  id: Schema.String,
517
599
  sessionId: IDFromDb,
518
- prompt: Model.JsonFromString(Prompt),
600
+ prompt: Model$1.JsonFromString(Prompt),
519
601
  delivery: Schema.Literals(inputDeliveries),
520
602
  admittedSeq: Schema.Int,
521
- promotedSeq: Model.FieldOption(Schema.Int),
522
- createdAt: Model.DateTimeInsertFromNumber
603
+ promotedSeq: Model$1.FieldOption(Schema.Int),
604
+ createdAt: Model$1.DateTimeInsertFromNumber
523
605
  }) {};
524
- var SessionEntryRow = class extends Model.Class("SessionEntryRow")({
606
+ var SessionEntryRow = class extends Model$1.Class("SessionEntryRow")({
525
607
  id: Schema.String,
526
608
  sessionId: IDFromDb,
527
- parentId: Model.FieldOption(Schema.String),
609
+ parentId: Model$1.FieldOption(Schema.String),
528
610
  seq: schema_exports$9.NonNegativeInt,
529
611
  type: Schema.Literals(entryTypes),
530
612
  state: Schema.Literals(entryStates),
531
613
  data: Schema.String,
532
- label: Model.FieldOption(Schema.String),
533
- metadata: Model.FieldOption(Model.JsonFromString(Metadata)),
614
+ label: Model$1.FieldOption(Schema.String),
615
+ metadata: Model$1.FieldOption(Model$1.JsonFromString(Metadata)),
534
616
  ...Timestamps
535
617
  }) {};
536
- var SessionEntryPartRow = class extends Model.Class("SessionEntryPartRow")({
618
+ var SessionEntryPartRow = class extends Model$1.Class("SessionEntryPartRow")({
537
619
  id: Schema.String,
538
620
  entryId: Schema.String,
539
621
  sessionId: IDFromDb,
540
622
  partIndex: Schema.Int,
541
623
  type: Schema.Literals(partTypes),
542
- status: Model.FieldOption(Schema.Literals(toolStatuses)),
543
- callId: Model.FieldOption(Schema.String),
544
- toolName: Model.FieldOption(Schema.String),
624
+ status: Model$1.FieldOption(Schema.Literals(toolStatuses)),
625
+ callId: Model$1.FieldOption(Schema.String),
626
+ toolName: Model$1.FieldOption(Schema.String),
545
627
  data: Schema.String,
546
628
  ...Timestamps
547
629
  }) {};
@@ -631,6 +713,7 @@ var schema_exports$4 = /* @__PURE__ */ __exportAll({
631
713
  //#endregion
632
714
  //#region src/event/list.ts
633
715
  var list_exports = /* @__PURE__ */ __exportAll({
716
+ ConfigChanged: () => ConfigChanged,
634
717
  Definitions: () => Definitions,
635
718
  DurableDefinitions: () => DurableDefinitions,
636
719
  EventList: () => list_exports,
@@ -651,6 +734,8 @@ var list_exports = /* @__PURE__ */ __exportAll({
651
734
  PromptAdmitted: () => PromptAdmitted,
652
735
  Prompted: () => Prompted,
653
736
  PublicDefinitions: () => PublicDefinitions,
737
+ RetryFinished: () => RetryFinished,
738
+ RetryScheduled: () => RetryScheduled,
654
739
  SessionForked: () => SessionForked,
655
740
  ToolProgress: () => ToolProgress$2,
656
741
  ToolSettled: () => ToolSettled,
@@ -684,6 +769,14 @@ const Prompted = schema_exports$7.EventSchema.define({
684
769
  ...durableOptions,
685
770
  schema: PromptFields
686
771
  });
772
+ const ConfigChanged = schema_exports$7.EventSchema.define({
773
+ type: "session.config.changed",
774
+ ...durableOptions,
775
+ schema: {
776
+ ...baseOptions,
777
+ ...Config$1.fields
778
+ }
779
+ });
687
780
  const LLMFields = {
688
781
  ...baseOptions,
689
782
  messageId: ID
@@ -713,6 +806,30 @@ const TurnAborted = schema_exports$7.EventSchema.define({
713
806
  cause: TurnAbortCause
714
807
  }
715
808
  });
809
+ /**
810
+ * A failed provider request will be repeated after `delayMs`. `attempt` counts retries from 1, so
811
+ * the request that failed was attempt `attempt - 1`; the failed draft is already settled.
812
+ */
813
+ const RetryScheduled = schema_exports$7.EventSchema.define({
814
+ type: "session.retry.scheduled",
815
+ schema: {
816
+ ...baseOptions,
817
+ attempt: schema_exports$9.NonNegativeInt,
818
+ maxRetries: schema_exports$9.NonNegativeInt,
819
+ delayMs: schema_exports$9.NonNegativeInt,
820
+ message: Schema.String
821
+ }
822
+ });
823
+ /** Once per turn that retried: whether a retry succeeded, after how many. */
824
+ const RetryFinished = schema_exports$7.EventSchema.define({
825
+ type: "session.retry.finished",
826
+ schema: {
827
+ ...baseOptions,
828
+ attempt: schema_exports$9.NonNegativeInt,
829
+ success: Schema.Boolean,
830
+ message: (0, schema_exports$9.optional)(Schema.String)
831
+ }
832
+ });
716
833
  /** Durable insertion of the request's draft assistant placeholder. */
717
834
  const LLMStarted = schema_exports$7.EventSchema.define({
718
835
  type: "session.llm.started",
@@ -787,7 +904,10 @@ const ToolStarted = schema_exports$7.EventSchema.define({
787
904
  schema: {
788
905
  ...LLMFields,
789
906
  callID: Schema.String,
790
- name: Schema.String
907
+ name: Schema.String,
908
+ /** The tool's display label, when its definition declares one. */
909
+ label: (0, schema_exports$9.optional)(Schema.String),
910
+ arguments: Schema.Record(Schema.String, Schema.Json)
791
911
  }
792
912
  });
793
913
  const ToolProgress$2 = schema_exports$7.EventSchema.define({
@@ -883,14 +1003,14 @@ const PluginUpdated = schema_exports$7.EventSchema.define({
883
1003
  * Everything the kernel can publish. `PublicDefinitions` is the narrower set
884
1004
  * that reaches clients -- adding an event here does not publish it.
885
1005
  */
886
- const Definitions = schema_exports$7.EventSchema.inventory(ExecutionStarted, ExecutionSucceeded, ExecutionFailed, ExecutionInterrupted, PromptAdmitted, Prompted, SessionForked, TurnStarted, TurnEnded, TurnAborted, LLMStarted, LLMTextStart, LLMTextDelta, LLMTextEnd, LLMThinkingStart, LLMThinkingDelta, LLMThinkingEnd, LLMEnded, LLMFailed, ToolStarted, ToolProgress$2, ToolSettled, PluginUpdated);
1006
+ const Definitions = schema_exports$7.EventSchema.inventory(ExecutionStarted, ExecutionSucceeded, ExecutionFailed, ExecutionInterrupted, PromptAdmitted, Prompted, ConfigChanged, SessionForked, TurnStarted, TurnEnded, TurnAborted, RetryScheduled, RetryFinished, LLMStarted, LLMTextStart, LLMTextDelta, LLMTextEnd, LLMThinkingStart, LLMThinkingDelta, LLMThinkingEnd, LLMEnded, LLMFailed, ToolStarted, ToolProgress$2, ToolSettled, PluginUpdated);
887
1007
  /**
888
1008
  * The wire contract: what a client is allowed to receive. Identical to
889
1009
  * `Definitions` today, and separate so that adding a kernel event is not by
890
1010
  * itself a decision to publish it.
891
1011
  */
892
1012
  const PublicDefinitions = Definitions;
893
- const DurableDefinitions = schema_exports$7.EventSchema.inventory(PromptAdmitted, Prompted, SessionForked, TurnEnded, LLMStarted, LLMEnded, LLMFailed, ToolSettled);
1013
+ const DurableDefinitions = schema_exports$7.EventSchema.inventory(PromptAdmitted, Prompted, ConfigChanged, SessionForked, TurnEnded, LLMStarted, LLMEnded, LLMFailed, ToolSettled);
894
1014
  const Manifest$2 = {
895
1015
  definitions: schema_exports$7.EventSchema.durable([...DurableDefinitions]),
896
1016
  schema: Schema.Union([...DurableDefinitions], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
@@ -1235,7 +1355,7 @@ const layer$25 = Layer.effect(Service$16, Effect.gen(function* () {
1235
1355
  const encoder = new TextEncoder();
1236
1356
  function fast(input) {
1237
1357
  const data = typeof input === "string" ? encoder.encode(input) : input;
1238
- return Encoding.encodeHex(Effect.runSync(crypto.digest("SHA-1", data)));
1358
+ return Hex.encode(Effect.runSync(crypto.digest("SHA-1", data)));
1239
1359
  }
1240
1360
  //#endregion
1241
1361
  //#region src/space/space.ts
@@ -1377,6 +1497,77 @@ const layer$24 = Layer.effect(Service$15, Effect.gen(function* () {
1377
1497
  });
1378
1498
  }));
1379
1499
  //#endregion
1500
+ //#region src/util/slug.ts
1501
+ const PREFIXES = [
1502
+ "binary",
1503
+ "protocol",
1504
+ "astromech",
1505
+ "thermal",
1506
+ "ionic",
1507
+ "encrypted",
1508
+ "holonet",
1509
+ "hyperspace",
1510
+ "magnetic",
1511
+ "logic",
1512
+ "plasma",
1513
+ "vector",
1514
+ "optical",
1515
+ "quantum",
1516
+ "mechanical",
1517
+ "servo",
1518
+ "fusion",
1519
+ "flux",
1520
+ "sychro",
1521
+ "static"
1522
+ ];
1523
+ const HARDWARE = [
1524
+ "core",
1525
+ "unit",
1526
+ "node",
1527
+ "array",
1528
+ "link",
1529
+ "processor",
1530
+ "module",
1531
+ "uplink",
1532
+ "interface",
1533
+ "splicer",
1534
+ "matrix",
1535
+ "buffer",
1536
+ "relay",
1537
+ "circuit",
1538
+ "sensor",
1539
+ "driver",
1540
+ "bridge",
1541
+ "manifold",
1542
+ "oscillator",
1543
+ "terminal"
1544
+ ];
1545
+ const SECTORS = [
1546
+ "kuat",
1547
+ "corellia",
1548
+ "coruscant",
1549
+ "bespin",
1550
+ "kamino",
1551
+ "mustafar",
1552
+ "fondor",
1553
+ "hosnian",
1554
+ "lothal",
1555
+ "scarif",
1556
+ "sub-level",
1557
+ "deep-space",
1558
+ "outer-rim",
1559
+ "mid-rim",
1560
+ "sector-7"
1561
+ ];
1562
+ const CONNECTORS = [
1563
+ "at",
1564
+ "of",
1565
+ "in"
1566
+ ];
1567
+ const create$3 = Effect.fnUntraced(function* (id) {
1568
+ return `${yield* Random.choice(PREFIXES)}-${yield* Random.choice(HARDWARE)}-${yield* Random.choice(CONNECTORS)}-${yield* Random.choice(SECTORS)}-${id.slice(-8)}`;
1569
+ });
1570
+ //#endregion
1380
1571
  //#region src/session/session.ts
1381
1572
  var session_exports$1 = /* @__PURE__ */ __exportAll({
1382
1573
  EntryNotFoundError: () => EntryNotFoundError,
@@ -1391,7 +1582,7 @@ var session_exports$1 = /* @__PURE__ */ __exportAll({
1391
1582
  SessionNotLinkedError: () => SessionNotLinkedError,
1392
1583
  SessionRow: () => SessionRow,
1393
1584
  ToolCallNotFoundError: () => ToolCallNotFoundError,
1394
- defaultLayer: () => defaultLayer$2,
1585
+ defaultLayer: () => defaultLayer,
1395
1586
  entryStates: () => entryStates,
1396
1587
  entryTypes: () => entryTypes,
1397
1588
  layer: () => layer$23,
@@ -1603,9 +1794,10 @@ const layer$23 = Layer.effect(Service$14, Effect.gen(function* () {
1603
1794
  id,
1604
1795
  spaceId: input.spaceId,
1605
1796
  parentId: Option.fromUndefinedOr(input.parentId),
1606
- slug: input.slug,
1797
+ slug: yield* create$3(id),
1607
1798
  directory: input.directory,
1608
1799
  hostDir: Option.fromUndefinedOr(input.hostDir),
1800
+ config: Option.none(),
1609
1801
  title: input.title,
1610
1802
  tag: Option.fromUndefinedOr(input.tag),
1611
1803
  metadata: Option.fromUndefinedOr(input.metadata),
@@ -1948,9 +2140,10 @@ const layer$23 = Layer.effect(Service$14, Effect.gen(function* () {
1948
2140
  id: newSessionId,
1949
2141
  spaceId: source.value.spaceId,
1950
2142
  parentId: Option.some(source.value.id),
1951
- slug: input.slug,
2143
+ slug: yield* create$3(newSessionId),
1952
2144
  directory: source.value.directory,
1953
2145
  hostDir: source.value.hostDir,
2146
+ config: source.value.config,
1954
2147
  title: input.title ?? source.value.title,
1955
2148
  tag: input.tag === void 0 ? source.value.tag : Option.some(input.tag),
1956
2149
  metadata: source.value.metadata,
@@ -2045,6 +2238,21 @@ const layer$23 = Layer.effect(Service$14, Effect.gen(function* () {
2045
2238
  if (Option.isNone(updated)) return yield* Effect.die(/* @__PURE__ */ new Error("session link did not persist"));
2046
2239
  return updated.value;
2047
2240
  });
2241
+ const retitle = Effect.fn("Session.retitle")(function* (sessionId, title) {
2242
+ return (yield* sql`
2243
+ UPDATE session SET title = ${title}
2244
+ WHERE id = ${sessionId} AND title = ${DEFAULT_TITLE}
2245
+ RETURNING id
2246
+ `.pipe(Effect.orDie)).length > 0;
2247
+ });
2248
+ const encodeConfig = Schema.encodeEffect(Schema.fromJsonString(Config$1));
2249
+ const configure = Effect.fn("Session.configure")(function* (sessionId, config) {
2250
+ const patch = yield* encodeConfig(config);
2251
+ yield* sql`
2252
+ UPDATE session SET config = json_patch(COALESCE(config, '{}'), ${patch})
2253
+ WHERE id = ${sessionId}
2254
+ `;
2255
+ }, Effect.orDie);
2048
2256
  const relink = Effect.fn("Session.relink")(function* (input) {
2049
2257
  const reject = (reason) => new RelinkError({
2050
2258
  sessionId: input.sessionId,
@@ -2104,6 +2312,8 @@ const layer$23 = Layer.effect(Service$14, Effect.gen(function* () {
2104
2312
  return Service$14.of({
2105
2313
  create,
2106
2314
  link,
2315
+ retitle,
2316
+ configure,
2107
2317
  get,
2108
2318
  space,
2109
2319
  list,
@@ -2122,7 +2332,7 @@ const layer$23 = Layer.effect(Service$14, Effect.gen(function* () {
2122
2332
  setLabel
2123
2333
  });
2124
2334
  }));
2125
- const defaultLayer$2 = layer$23.pipe(Layer.provide(defaultLayer$3));
2335
+ const defaultLayer = layer$23.pipe(Layer.provide(defaultLayer$1));
2126
2336
  //#endregion
2127
2337
  //#region src/context/errors.ts
2128
2338
  var ContextDecodeError = class extends Schema.TaggedError()("ContextDecodeError", {
@@ -2459,6 +2669,7 @@ const toFileStat = (stats) => ({
2459
2669
  size: stats.size,
2460
2670
  mtime: stats.mtime
2461
2671
  });
2672
+ const SCAN_CHUNK = 65536;
2462
2673
  const bytes = (content) => typeof content === "string" ? Buffer$1.from(content, "utf8") : Buffer$1.from(content);
2463
2674
  /**
2464
2675
  * Provider factory method that implements the SandboxFileSytem
@@ -2470,6 +2681,8 @@ const make$17 = (vfs) => {
2470
2681
  await vfs.promises.writeFile(path, bytes(content));
2471
2682
  };
2472
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()));
2473
2686
  const rm = async (path, options) => {
2474
2687
  let stats;
2475
2688
  try {
@@ -2509,7 +2722,36 @@ const make$17 = (vfs) => {
2509
2722
  await vfs.promises.mkdir(path, options);
2510
2723
  },
2511
2724
  rm,
2512
- 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
+ }
2513
2755
  };
2514
2756
  };
2515
2757
  /** The `SandboxFileSystem.Service` backed by the local {@link Vfs}. */
@@ -2637,10 +2879,12 @@ const layer$19 = (...drivers) => Layer.effect(Registry, make$16(drivers));
2637
2879
  //#endregion
2638
2880
  //#region src/sandbox/shell/host.ts
2639
2881
  const DEFAULT_SHELL = "sh";
2882
+ const DEFAULT_FORCE_KILL_AFTER = Duration.seconds(1);
2640
2883
  const layer$18 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(function* () {
2641
2884
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2642
- const run = (label, file, args, opts) => Effect.scoped(Effect.gen(function* () {
2643
- 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], {
2644
2888
  cwd: opts?.cwd,
2645
2889
  env: {
2646
2890
  ...options.env,
@@ -2651,6 +2895,32 @@ const layer$18 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(
2651
2895
  stdout: "pipe",
2652
2896
  stderr: "pipe"
2653
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);
2654
2924
  return yield* Effect.all({
2655
2925
  exitCode: handle.exitCode,
2656
2926
  stdout: handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
@@ -2665,9 +2935,23 @@ const layer$18 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(
2665
2935
  command: "",
2666
2936
  cause: /* @__PURE__ */ new Error("execArgv requires a program")
2667
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
+ })));
2668
2951
  return shell_exports.Shell.of({
2669
2952
  exec,
2670
- execArgv
2953
+ execArgv,
2954
+ stream
2671
2955
  });
2672
2956
  }));
2673
2957
  //#endregion
@@ -3052,7 +3336,8 @@ const make$14 = Effect.fn("Sandbox.Controller.make")(function* (options) {
3052
3336
  id,
3053
3337
  driver: driver_exports.SandboxDriver.Name.make("local"),
3054
3338
  kind: "local",
3055
- 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
3056
3341
  };
3057
3342
  else {
3058
3343
  const row = yield* requireRow(id);
@@ -3074,7 +3359,8 @@ const make$14 = Effect.fn("Sandbox.Controller.make")(function* (options) {
3074
3359
  id,
3075
3360
  driver: attached.driver.name,
3076
3361
  kind: row.kind,
3077
- 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
3078
3364
  };
3079
3365
  const wasIdle = lease.held ? false : yield* gate.withPermits(1)(Effect.gen(function* () {
3080
3366
  const latest = yield* requireRow(id);
@@ -3121,7 +3407,7 @@ const make$14 = Effect.fn("Sandbox.Controller.make")(function* (options) {
3121
3407
  WHERE id = ${id}
3122
3408
  `.pipe(Effect.orDie);
3123
3409
  }
3124
- 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);
3125
3411
  }));
3126
3412
  const mount = (id, mountOptions) => mountLayer(id, mountOptions);
3127
3413
  const withMount = (id, use, mountOptions) => use.pipe(Effect.provide(mount(id, mountOptions)), Effect.scoped);
@@ -3857,17 +4143,6 @@ const make$9 = () => {
3857
4143
  }) };
3858
4144
  };
3859
4145
  //#endregion
3860
- //#region src/sandbox/sandbox.ts
3861
- /**
3862
- * Assemble a local backend into a sandbox with a **real host shell** — the
3863
- * right choice whenever commands must invoke actual binaries (`git`, package
3864
- * managers). For a sandbox whose shell should stay inside the VFS, use
3865
- * `EnvBash.services` instead, which wires just-bash over the same tree.
3866
- */
3867
- const services = (backend, identity) => Layer.provideMerge(io_exports.SandboxIO.mount(identity), Layer.provideMerge(Layer.merge(layer$21, layer$18()), backend));
3868
- /** Default sandbox: the real OS filesystem and processes, mounted at `cwd`. */
3869
- const defaultLayer$1 = (cwd) => services(layer$20(), io_exports.SandboxIO.host(cwd));
3870
- //#endregion
3871
4146
  //#region src/git/git.ts
3872
4147
  var AppProcessError = class extends Schema.TaggedError()("AppProcessError", {
3873
4148
  command: Schema.String,
@@ -4082,7 +4357,7 @@ var Service$11 = class extends Context.Service()("@codeworksh/harness/repo/repo/
4082
4357
  * `.git` suffix and surrounding slashes stripped, scp form accepted.
4083
4358
  * `file:` urls and empty input yield undefined.
4084
4359
  */
4085
- function normalize$2(url) {
4360
+ function normalize$3(url) {
4086
4361
  const value = url.trim();
4087
4362
  if (!value) return void 0;
4088
4363
  try {
@@ -4124,7 +4399,7 @@ const layer$14 = Layer.effect(Service$11, Effect.gen(function* () {
4124
4399
  const marker = posix.join(repo.store, MARKER$1);
4125
4400
  const pinned = (yield* readFileSafe(fs, marker))?.trim() || void 0;
4126
4401
  const url = yield* git.remote(repo.directory);
4127
- const normalized = url === void 0 ? void 0 : normalize$2(url);
4402
+ const normalized = url === void 0 ? void 0 : normalize$3(url);
4128
4403
  const remote = normalized === void 0 ? void 0 : {
4129
4404
  id: fast(`git:${normalized}`),
4130
4405
  name: posix.basename(normalized)
@@ -4351,16 +4626,13 @@ var location_exports = /* @__PURE__ */ __exportAll({
4351
4626
  Info: () => Info$1,
4352
4627
  Location: () => location_exports,
4353
4628
  NotDirectoryError: () => NotDirectoryError,
4354
- Ref: () => Ref$2,
4355
4629
  Service: () => Service$8,
4356
- defaultLayer: () => defaultLayer,
4357
4630
  layer: () => layer$11,
4358
- layerMounted: () => layerMounted,
4359
- layerWith: () => layerWith
4631
+ layerMounted: () => layerMounted
4360
4632
  });
4361
- const layer$11 = (ref = {}) => Layer.effect(Service, Effect.gen(function* () {
4633
+ const layer$11 = () => Layer.effect(Service, Effect.gen(function* () {
4362
4634
  const instance = yield* io_exports.SandboxIO.Current;
4363
- const directory = schema_exports$9.AbsolutePath.make(ref.directory ?? instance.cwd);
4635
+ const directory = schema_exports$9.AbsolutePath.make(instance.cwd);
4364
4636
  const resolved = yield* (yield* Service$9).resolveOrCreate(directory);
4365
4637
  return Service.of(new Info({
4366
4638
  directory: resolved.directory,
@@ -4373,9 +4645,7 @@ const layer$11 = (ref = {}) => Layer.effect(Service, Effect.gen(function* () {
4373
4645
  * folded in. What remains is exactly the mount and the database — for callers
4374
4646
  * that already hold both, such as the runner and `Session.create`.
4375
4647
  */
4376
- const layerMounted = (ref = {}) => layer$11(ref).pipe(Layer.provide(layer$12.pipe(Layer.provide(Layer.mergeAll(layer$14, layer$13, layer$24)), Layer.provide(layer$15))));
4377
- const layerWith = (ref, sandbox) => layerMounted(ref).pipe(Layer.provide(sandbox), Layer.provide(defaultLayer$3));
4378
- const defaultLayer = (ref, path) => layerWith(ref, defaultLayer$1(path));
4648
+ const layerMounted = () => layer$11().pipe(Layer.provide(layer$12.pipe(Layer.provide(Layer.mergeAll(layer$14, layer$13, layer$24)), Layer.provide(layer$15))));
4379
4649
  //#endregion
4380
4650
  //#region src/runner/run.ts
4381
4651
  var run_exports = /* @__PURE__ */ __exportAll({
@@ -4870,78 +5140,327 @@ const layer$9 = (definitions = Definitions) => Layer.sync(Service$4, () => {
4870
5140
  });
4871
5141
  });
4872
5142
  //#endregion
4873
- //#region src/model/catalog.ts
4874
- var catalog_exports = /* @__PURE__ */ __exportAll({
4875
- ModelCatalog: () => catalog_exports,
4876
- RefreshError: () => RefreshError,
4877
- filename: () => filename,
5143
+ //#region src/settings/schema.ts
5144
+ var schema_exports$3 = /* @__PURE__ */ __exportAll({});
5145
+ import * as import__codeworksh_plugin_settings from "@codeworksh/plugin/settings";
5146
+ __reExport(schema_exports$3, import__codeworksh_plugin_settings);
5147
+ //#endregion
5148
+ //#region src/settings/merge.ts
5149
+ /**
5150
+ * A plain string-keyed record: what a settings patch merges, and what a plugin's `options` block
5151
+ * has to be. A Map, a typed array or a class instance is an object to `typeof` and not one of
5152
+ * these — `Object.freeze` alone does not tell them apart, and it throws on some of them.
5153
+ */
5154
+ const isRecord$1 = (value) => Predicate.isObject(value) && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
5155
+ const copy = (value) => {
5156
+ if (Array.isArray(value)) return value.map(copy);
5157
+ return isRecord$1(value) ? merge({}, value) : value;
5158
+ };
5159
+ /** Null/undefined properties are absent, objects merge, and arrays replace. Never mutates inputs. */
5160
+ function merge(base, ...patches) {
5161
+ const result = /* @__PURE__ */ new Map();
5162
+ for (const patch of [base, ...patches]) {
5163
+ if (patch === void 0) continue;
5164
+ for (const [key, value] of Object.entries(patch)) {
5165
+ if (value === null || value === void 0) continue;
5166
+ const previous = result.get(key);
5167
+ result.set(key, isRecord$1(previous) && isRecord$1(value) ? merge(previous, value) : copy(value));
5168
+ }
5169
+ }
5170
+ return Object.fromEntries(result);
5171
+ }
5172
+ /** Normalize nullable properties before schema validation, without changing array positions. */
5173
+ const normalize$2 = copy;
5174
+ //#endregion
5175
+ //#region src/settings/settings.ts
5176
+ var settings_exports = /* @__PURE__ */ __exportAll({
5177
+ Service: () => Service$3,
5178
+ Settings: () => settings_exports,
5179
+ SettingsError: () => SettingsError,
5180
+ defaults: () => schema_exports$3.defaults,
4878
5181
  layer: () => layer$8,
4879
- loadError: () => loadError,
4880
- models: () => models,
4881
- providers: () => providers,
4882
- refresh: () => refresh$1,
4883
- sync: () => sync,
4884
- ttlConfig: () => ttlConfig
5182
+ load: () => load$3,
5183
+ modules: () => modules,
5184
+ parse: () => parse$4,
5185
+ paths: () => paths,
5186
+ projectRoot: () => projectRoot,
5187
+ userConfigDir: () => userConfigDir
4885
5188
  });
4886
- const filename = "models.gen.json";
4887
- /** How old the catalog may get before a check downloads a new one. */
4888
- const ttlConfig = Config.Duration("CODEWORK_MODELS_TTL").pipe(Config.withDefault(Duration.minutes(15)));
4889
- /** How often a server looks at the file. A look is a `stat`; only a stale file costs a download. */
4890
- const checkEvery = Duration.minutes(1);
4891
- var RefreshError = class extends Schema.TaggedError()("ModelCatalog.RefreshError", {
5189
+ /**
5190
+ * An app-level `--user-config-dir` as the absolute directory it names: `~` is the user's home, and
5191
+ * a relative spelling is relative to `hostCwd` -- never to a session's `hostDir`, which is not
5192
+ * where the flag was typed.
5193
+ */
5194
+ const userConfigDir = (raw, hostCwd) => hostPath.resolve(hostCwd, expandTilde(raw, hostPath));
5195
+ /** The settings files a directory may hold, in the order it is searched. */
5196
+ const settingsIn = (directory) => [hostPath.join(directory, "settings.jsonc"), hostPath.join(directory, "settings.json")];
5197
+ /** A directory and every ancestor above it, nearest first. */
5198
+ const ancestors$1 = (from) => {
5199
+ const chain = [];
5200
+ for (let directory = from;; directory = hostPath.dirname(directory)) {
5201
+ chain.push(directory);
5202
+ if (hostPath.dirname(directory) === directory) return chain;
5203
+ }
5204
+ };
5205
+ const isDirectory = (path) => fileSystem.stat(path).pipe(Effect.map((info) => info.type === "Directory"), Effect.orElseSucceed(() => false));
5206
+ /**
5207
+ * The project a directory is in: the nearest ancestor holding a `.codework` **directory**.
5208
+ *
5209
+ * This is git's rule. `~/workspace/app/.codework` makes `~/workspace/app` a project, and
5210
+ * `~/workspace/app/packages/fubar` is inside it. Nearest wins outright -- the walk stops at the
5211
+ * first marker, so an inner project is never merged with an outer one.
5212
+ *
5213
+ * The marker is the directory, not a file in it. An empty `.codework/` is still a project root,
5214
+ * which is the point: creating the directory is how a person says "a project begins here", and it
5215
+ * has to mean that before any settings exist. It also makes the question one `stat` per ancestor
5216
+ * whose answer does not change as files come and go inside.
5217
+ *
5218
+ * `<home>` is skipped. `~/.codework` is the user layer, not a project; without this, running
5219
+ * anywhere under `$HOME` would find it as one and read the user layer twice -- and since plugin
5220
+ * entries accumulate across layers, every user plugin would load twice.
5221
+ */
5222
+ const projectRoot = Effect.fn("Settings.projectRoot")(function* (from, home) {
5223
+ const userConfig = hostPath.resolve(home);
5224
+ for (const directory of ancestors$1(hostPath.resolve(from))) {
5225
+ const marker = hostPath.join(directory, appConfigDir);
5226
+ if (hostPath.resolve(marker) === userConfig) continue;
5227
+ if (yield* isDirectory(marker)) return directory;
5228
+ }
5229
+ });
5230
+ /**
5231
+ * Ordered layers, each a group of candidates where the first file that exists is selected -- a
5232
+ * group never contributes more than one file.
5233
+ *
5234
+ * `.jsonc` is the canonical spelling: these files are written by hand and read as JSONC, so the
5235
+ * extension says what the file is and an editor validates it as such. `.json` remains a fallback
5236
+ * everywhere, tried second, because the parser reads either and a file already named that must
5237
+ * keep working.
5238
+ *
5239
+ * `root` is an already-discovered project root ({@link projectRoot}), not a directory to search
5240
+ * from: the walk is a `stat` per ancestor and belongs with the other I/O. `undefined` contributes
5241
+ * no project layer at all, which is what a session with no host directory gets.
5242
+ */
5243
+ function paths(input) {
5244
+ return [settingsIn(input.userConfigDir ?? input.home), input.root === void 0 ? [] : settingsIn(hostPath.join(input.root, appConfigDir))];
5245
+ }
5246
+ var SettingsError = class extends Schema.TaggedError()("SettingsError", {
4892
5247
  path: Schema.String,
5248
+ reason: Schema.Literals([
5249
+ "read",
5250
+ "parse",
5251
+ "decode"
5252
+ ]),
4893
5253
  detail: Schema.String
4894
5254
  }) {
4895
5255
  get message() {
4896
- return `failed to refresh the model catalog at ${this.path}: ${this.detail}`;
5256
+ return `${this.path}: ${this.reason}: ${this.detail}`;
4897
5257
  }
4898
5258
  };
4899
- /** Point aikit at `<home>/models.gen.json`, which `CODEWORK_MODELS_FILE` overrides. */
4900
- const use = (home) => {
4901
- const own = hostPath.resolve(home, filename);
4902
- Model$1.configureCatalog(own);
4903
- const path = Model$1.catalogPath();
4904
- return {
4905
- path,
4906
- pinned: path !== own
4907
- };
5259
+ /** `line:column`, 1-based, for a parser offset into the source. */
5260
+ const at = (source, offset) => {
5261
+ const before = source.slice(0, offset).split("\n");
5262
+ return `${before.length}:${(before.at(-1)?.length ?? 0) + 1}`;
4908
5263
  };
4909
- const modified = (path) => fileSystem.stat(path).pipe(Effect.map((info) => Option.map(info.mtime, (date) => date.getTime())), Effect.orElseSucceed(() => Option.none()));
4910
- const stale = Effect.fn("ModelCatalog.stale")(function* (path) {
4911
- const mtime = yield* modified(path);
4912
- if (Option.isNone(mtime)) return true;
4913
- const ttl = yield* ttlConfig;
4914
- return (yield* Clock.currentTimeMillis) - mtime.value >= Duration.toMillis(ttl);
4915
- });
4916
- const download$1 = (path) => Effect.tryPromise({
4917
- try: () => generateModels({ path }),
4918
- catch: (cause) => new RefreshError({
4919
- path,
4920
- detail: cause instanceof Error ? cause.message : String(cause)
4921
- })
4922
- });
4923
- /** Download a new catalog for the home when it is missing, older than the TTL, or `force` asks. */
4924
- const refresh$1 = Effect.fn("ModelCatalog.refresh")(function* (home, options = {}) {
4925
- const { path } = use(home);
4926
- if (options.force !== true && !(yield* stale(path))) return {
5264
+ /**
5265
+ * Decode failures name the field, and explain it only with a message a schema check wrote. Effect's
5266
+ * defaults quote the rejected value, and in a settings file that value can be a secret, so any
5267
+ * other failure reads "invalid value".
5268
+ */
5269
+ const decodeFormatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => {
5270
+ const message = "annotations" in issue ? issue.annotations?.message : void 0;
5271
+ return typeof message === "string" ? message.replaceAll(/\s*\n\s*/g, " ") : "invalid value";
5272
+ } });
5273
+ const parse$4 = Effect.fn("Settings.parse")(function* (path, source) {
5274
+ const errors = [];
5275
+ const json = parse(source, errors, {
5276
+ allowTrailingComma: true,
5277
+ disallowComments: false
5278
+ });
5279
+ const first = errors[0];
5280
+ if (first !== void 0) return yield* new SettingsError({
4927
5281
  path,
4928
- refreshed: false
4929
- };
4930
- yield* download$1(path);
4931
- return {
5282
+ reason: "parse",
5283
+ detail: `${printParseErrorCode(first.error)} at ${at(source, first.offset)}`
5284
+ });
5285
+ const document = normalize$2(json);
5286
+ if (Predicate.isObject(document) && Predicate.isObject(json) && "plugins" in json) Object.assign(document, { plugins: json.plugins });
5287
+ return yield* Schema.decodeUnknownEffect(schema_exports$3.Patch)(document).pipe(Effect.mapError((error) => new SettingsError({
4932
5288
  path,
4933
- refreshed: true
4934
- };
5289
+ reason: "decode",
5290
+ detail: decodeFormatter(error.issue).issues.map((issue) => `${issue.path?.map((part) => typeof part === "object" ? String(part.key) : part).join(".") ?? "settings"}: ${issue.message}`).join("; ")
5291
+ })));
4935
5292
  });
5293
+ var Service$3 = class extends Context.Service()("@codeworksh/harness/settings/settings/Service") {};
4936
5294
  /**
4937
- * The automatic check: refresh a stale catalog unless it is pinned, and never fail. A failed
4938
- * download leaves the old file in place; a missing one surfaces later as a
4939
- * `Runner.ModelCatalogError` from whatever needed it.
4940
- */
4941
- const check$1 = Effect.fn("ModelCatalog.check")(function* ({ path, pinned }) {
4942
- if (pinned || !(yield* stale(path))) return;
4943
- yield* download$1(path);
4944
- });
5295
+ * Anchor relative plugin entries to the file that declared them.
5296
+ *
5297
+ * A reference is otherwise resolved against the host startup directory, which is right for
5298
+ * a project file sitting in it and meaningless for `~/.codework/settings.json`, where
5299
+ * `./plugins/x.ts` would name a different file in every project the process is started in.
5300
+ * `file:` URLs, package specs and package names are left exactly as written, and a `plugin`
5301
+ * key is an ID rather than a location. A configuration entry's `package` is anchored like a
5302
+ * module entry, so a relative path names the same module in both spellings.
5303
+ */
5304
+ const anchored = (entry, file) => {
5305
+ const directory = hostPath.dirname(file);
5306
+ const resolve = (reference) => reference.startsWith("./") || reference.startsWith("../") ? hostPath.resolve(directory, reference) : reference;
5307
+ if (typeof entry === "string") return resolve(entry);
5308
+ return "package" in entry ? {
5309
+ ...entry,
5310
+ package: resolve(entry.package)
5311
+ } : entry;
5312
+ };
5313
+ const anchor$1 = (patch, file) => patch.plugins === void 0 ? patch : {
5314
+ ...patch,
5315
+ plugins: patch.plugins.map((entry) => anchored(entry, file))
5316
+ };
5317
+ const attempt = (path) => fileSystem.readFileString(path).pipe(Effect.mapError((error) => new SettingsError({
5318
+ path,
5319
+ reason: "read",
5320
+ detail: error.reason._tag
5321
+ })), Effect.flatMap((source) => parse$4(path, source)));
5322
+ /**
5323
+ * One read of every layer. Exported for the harness constructor, which needs the plugin
5324
+ * selection before any layer is built; everything else goes through `Service`.
5325
+ */
5326
+ const load$3 = Effect.fn("Settings.load")(function* (options) {
5327
+ const root = options.hostDir === void 0 ? void 0 : yield* projectRoot(options.hostDir, options.home);
5328
+ const files = paths({
5329
+ home: options.home,
5330
+ root,
5331
+ ...options.userConfigDir === void 0 ? {} : { userConfigDir: options.userConfigDir }
5332
+ });
5333
+ let settings = merge(schema_exports$3.defaults);
5334
+ let declared = schema_exports$3.defaults.declared;
5335
+ let models = schema_exports$3.defaults.models;
5336
+ for (const group of files) for (const path of group) {
5337
+ const result = yield* Effect.result(attempt(path));
5338
+ if (Result.isSuccess(result)) {
5339
+ const { models: entries, ...patch } = anchor$1(result.success, path);
5340
+ settings = merge(settings, patch);
5341
+ if (entries !== void 0) models = [...models, ...entries.map((entry) => ({
5342
+ entry,
5343
+ file: path
5344
+ }))];
5345
+ if (result.success.plugins !== void 0) declared = [...declared, ...result.success.plugins.map((written) => ({
5346
+ written,
5347
+ entry: anchored(written, path),
5348
+ file: path
5349
+ }))];
5350
+ break;
5351
+ }
5352
+ const error = result.failure;
5353
+ if (error.reason === "read" && error.detail === "NotFound") continue;
5354
+ return yield* error;
5355
+ }
5356
+ return {
5357
+ ...settings,
5358
+ plugins: declared.map((one) => one.entry),
5359
+ declared,
5360
+ models
5361
+ };
5362
+ });
5363
+ /**
5364
+ * Each module reference once, owned by the **last** file to declare it (D6).
5365
+ *
5366
+ * The owner is not bookkeeping: its `.npmrc` chain keys the store artifact. The CLI that installs
5367
+ * a reference and the runtime that loads it must pick the same owner, or `plugin update` refreshes
5368
+ * an artifact the runtime never reads. Only a string entry loads a module, so only a string entry
5369
+ * owns one -- a `{ package }` patch in another file configures it and must not move its registry.
5370
+ * A reference keeps the position of its first declaration.
5371
+ */
5372
+ const modules = (declared) => {
5373
+ const owners = /* @__PURE__ */ new Map();
5374
+ for (const one of declared) if (typeof one.entry === "string") owners.set(one.entry, one);
5375
+ return owners;
5376
+ };
5377
+ const layer$8 = (options = {}) => Layer.effect(Service$3, Effect.gen(function* () {
5378
+ const global = yield* Service$17;
5379
+ return Service$3.of({ load: (hostDir) => load$3({
5380
+ ...options,
5381
+ home: global.home,
5382
+ hostDir
5383
+ }) });
5384
+ }));
5385
+ //#endregion
5386
+ //#region src/model/catalog.ts
5387
+ var catalog_exports = /* @__PURE__ */ __exportAll({
5388
+ Credentials: () => Credentials,
5389
+ ModelCatalog: () => catalog_exports,
5390
+ RefreshError: () => RefreshError,
5391
+ available: () => available$1,
5392
+ effective: () => effective,
5393
+ entry: () => entry,
5394
+ filename: () => filename,
5395
+ fromSettings: () => fromSettings,
5396
+ layer: () => layer$7,
5397
+ loadError: () => loadError,
5398
+ models: () => models,
5399
+ oauthStorage: () => oauthStorage,
5400
+ providers: () => providers,
5401
+ refresh: () => refresh$1,
5402
+ sync: () => sync,
5403
+ ttlConfig: () => ttlConfig
5404
+ });
5405
+ const filename = "models.gen.json";
5406
+ /** How old the catalog may get before a check downloads a new one. */
5407
+ const ttlConfig = Config.Duration("CODEWORK_MODELS_TTL").pipe(Config.withDefault(Duration.minutes(15)));
5408
+ /** How often a server looks at the file. A look is a `stat`; only a stale file costs a download. */
5409
+ const checkEvery = Duration.minutes(1);
5410
+ var RefreshError = class extends Schema.TaggedError()("ModelCatalog.RefreshError", {
5411
+ path: Schema.String,
5412
+ detail: Schema.String
5413
+ }) {
5414
+ get message() {
5415
+ return `failed to refresh the model catalog at ${this.path}: ${this.detail}`;
5416
+ }
5417
+ };
5418
+ /** Point aikit at `<home>/models.gen.json`, which `CODEWORK_MODELS_FILE` overrides. */
5419
+ const use = (home) => {
5420
+ const own = hostPath.resolve(home, filename);
5421
+ Model.configureCatalog(own);
5422
+ const path = Model.catalogPath();
5423
+ return {
5424
+ path,
5425
+ pinned: path !== own
5426
+ };
5427
+ };
5428
+ const modified = (path) => fileSystem.stat(path).pipe(Effect.map((info) => Option.map(info.mtime, (date) => date.getTime())), Effect.orElseSucceed(() => Option.none()));
5429
+ const stale = Effect.fn("ModelCatalog.stale")(function* (path) {
5430
+ const mtime = yield* modified(path);
5431
+ if (Option.isNone(mtime)) return true;
5432
+ const ttl = yield* ttlConfig;
5433
+ return (yield* Clock.currentTimeMillis) - mtime.value >= Duration.toMillis(ttl);
5434
+ });
5435
+ const download$1 = (path) => Effect.tryPromise({
5436
+ try: () => generateModels({ path }),
5437
+ catch: (cause) => new RefreshError({
5438
+ path,
5439
+ detail: cause instanceof Error ? cause.message : String(cause)
5440
+ })
5441
+ });
5442
+ /** Download a new catalog for the home when it is missing, older than the TTL, or `force` asks. */
5443
+ const refresh$1 = Effect.fn("ModelCatalog.refresh")(function* (home, options = {}) {
5444
+ const { path } = use(home);
5445
+ if (options.force !== true && !(yield* stale(path))) return {
5446
+ path,
5447
+ refreshed: false
5448
+ };
5449
+ yield* download$1(path);
5450
+ return {
5451
+ path,
5452
+ refreshed: true
5453
+ };
5454
+ });
5455
+ /**
5456
+ * The automatic check: refresh a stale catalog unless it is pinned, and never fail. A failed
5457
+ * download leaves the old file in place; a missing one surfaces later as a
5458
+ * `Runner.ModelCatalogError` from whatever needed it.
5459
+ */
5460
+ const check$1 = Effect.fn("ModelCatalog.check")(function* ({ path, pinned }) {
5461
+ if (pinned || !(yield* stale(path))) return;
5462
+ yield* download$1(path);
5463
+ });
4945
5464
  const quietly = (effect) => effect.pipe(Effect.asVoid, Effect.catch((error) => Effect.logWarning(`model catalog: ${error.message}`)));
4946
5465
  /** Check the home's catalog once, for a command that runs and exits; returns the file aikit reads. */
4947
5466
  const sync = Effect.fn("ModelCatalog.sync")(function* (home) {
@@ -4954,7 +5473,7 @@ const sync = Effect.fn("ModelCatalog.sync")(function* (home) {
4954
5473
  * moved -- refreshed here, by `codework models refresh`, or by hand -- is reloaded, so the
4955
5474
  * process never serves a catalog older than what is on disk.
4956
5475
  */
4957
- const layer$8 = (options) => Layer.effectDiscard(Effect.gen(function* () {
5476
+ const layer$7 = (options) => Layer.effectDiscard(Effect.gen(function* () {
4958
5477
  const target = use(options.home);
4959
5478
  yield* quietly(check$1(target));
4960
5479
  if (!options.watch) return;
@@ -4963,7 +5482,7 @@ const layer$8 = (options) => Layer.effectDiscard(Effect.gen(function* () {
4963
5482
  const tick = Effect.gen(function* () {
4964
5483
  yield* quietly(check$1(target));
4965
5484
  const mtime = yield* current;
4966
- if (mtime !== (yield* Ref.getAndSet(seen, mtime))) Model$1.reloadCatalog();
5485
+ if (mtime !== (yield* Ref.getAndSet(seen, mtime))) Model.reloadCatalog();
4967
5486
  });
4968
5487
  yield* Effect.forkScoped(tick.pipe(Effect.repeat(Schedule.spaced(checkEvery))));
4969
5488
  }));
@@ -4980,79 +5499,112 @@ const loadError = (cause) => {
4980
5499
  });
4981
5500
  };
4982
5501
  const catalogError = (cause) => loadError(cause) ?? new ModelCatalogError({
4983
- path: Model$1.catalogPath(),
5502
+ path: Model.catalogPath(),
4984
5503
  reason: "unreadable",
4985
5504
  detail: cause instanceof Error ? cause.message : "failed to load the model catalog"
4986
5505
  });
4987
5506
  const models = Effect.tryPromise({
4988
- try: () => Model$1.getBuiltInModels(),
5507
+ try: () => Model.getBuiltInModels(),
4989
5508
  catch: catalogError
4990
5509
  });
4991
5510
  const providers = Effect.tryPromise({
4992
- try: () => Model$1.getProviders(),
5511
+ try: () => Model.getProviders(),
4993
5512
  catch: catalogError
4994
5513
  });
4995
- //#endregion
4996
- //#region src/plugin/plugin.ts
4997
- var plugin_exports$1 = /* @__PURE__ */ __exportAll({});
4998
- import * as import__codeworksh_plugin_plugin from "@codeworksh/plugin/plugin";
4999
- __reExport(plugin_exports$1, import__codeworksh_plugin_plugin);
5000
- //#endregion
5001
- //#region src/plugin/builtin/prompt/default.ts
5002
- /** The default coding-agent foundation, used unless `promptCustom` replaces it. */
5003
- const foundation = `You are an expert coding assistant operating inside codework, a coding agent harness.`;
5004
5514
  /**
5005
- * Guidelines that hold regardless of which tools are registered.
5006
- *
5007
- * Kept short on purpose. Every line here is spent on every request, so a line
5008
- * earns its place only if a model measurably behaves worse without it.
5515
+ * Where this process reads OAuth logins: `<home>/aikit/auth.json` when a home was chosen,
5516
+ * otherwise `undefined`, which lets aikit resolve it (`CODEWORK_CREDENTIALS`, then the default home).
5517
+ * One value, so the model calls and {@link available} can never read different files.
5009
5518
  */
5010
- const standingGuidelines = [
5011
- "Be concise. Report what you did and what you found, not what you are about to do.",
5012
- "Quote exact paths and command output rather than paraphrasing them.",
5013
- "If a command fails, read the error before retrying."
5014
- ];
5015
- /** Collapse whitespace so two spellings of one guideline dedupe against each other. */
5016
- const normalize$1 = (value) => value.trim().replace(/\s+/g, " ");
5519
+ var Credentials = class extends Context.Service()("@codeworksh/harness/model/catalog/Credentials") {};
5520
+ /** The storage options an aikit OAuth store takes for {@link Credentials.authFile}. */
5521
+ const oauthStorage = (authFile) => authFile === void 0 ? {} : { path: authFile };
5522
+ /** Whether an OAuth login is stored for the protocol. Reads the file; never refreshes a token. */
5523
+ const loggedIn = (protocol, authFile) => {
5524
+ const storage = oauthStorage(authFile);
5525
+ const store = protocol === "openai-codex" ? new JsonOpenAICodexAuthStorage(storage) : protocol === "github-copilot" ? new JsonGitHubCopilotAuthStorage(storage) : void 0;
5526
+ if (store === void 0) return Effect.succeed(false);
5527
+ return Effect.tryPromise(() => store.get()).pipe(Effect.map((stored) => stored !== void 0), Effect.orElseSucceed(() => false));
5528
+ };
5017
5529
  /**
5018
- * Tool-contributed guidelines followed by the standing ones, normalized and
5019
- * deduplicated with first occurrence winning.
5020
- *
5021
- * Tool order is bucket order, which is the same order the index renders in and the
5022
- * same order the provider receives definitions in -- one ordering, not three.
5530
+ * A settings entry as the catalog reads it. Settings entries are read exactly like generated ones;
5531
+ * the one difference is `provider.key`, written `"${NAME}"`, which becomes the variable name aikit
5532
+ * looks up when it calls the model.
5023
5533
  */
5024
- const guidelines = (tools) => {
5025
- const seen = /* @__PURE__ */ new Set();
5026
- const collected = [];
5027
- for (const line of [...tools.flatMap((tool) => tool.promptGuidelines ?? []), ...standingGuidelines]) {
5028
- const normalized = normalize$1(line);
5029
- if (normalized.length === 0 || seen.has(normalized)) continue;
5030
- seen.add(normalized);
5031
- collected.push(normalized);
5032
- }
5033
- return collected;
5534
+ const fromSettings = (entry) => entry.provider.key === void 0 ? entry : {
5535
+ ...entry,
5536
+ provider: {
5537
+ ...entry.provider,
5538
+ key: (0, schema_exports$3.keyName)(entry.provider.key)
5539
+ }
5540
+ };
5541
+ /** The settings entry for a model, if one declares it: the last declaration wins. */
5542
+ const entry = (declared, provider, id) => {
5543
+ const found = declared.findLast((one) => one.entry.provider.id === provider && one.entry.id === id);
5544
+ return found === void 0 ? void 0 : fromSettings(found.entry);
5034
5545
  };
5035
5546
  /**
5036
- * The rendered tool index.
5547
+ * The generated catalog with settings entries appended in layer order: a new provider or model is
5548
+ * added, and an entry for an existing one replaces it, as a later entry in a generated file would.
5549
+ */
5550
+ const effective = Effect.fn("ModelCatalog.effective")(function* (declared) {
5551
+ const catalog = { ...yield* models };
5552
+ for (const { entry: one } of declared) catalog[one.provider.id] = {
5553
+ ...catalog[one.provider.id],
5554
+ [one.id]: fromSettings(one)
5555
+ };
5556
+ return catalog;
5557
+ });
5558
+ const oauthProtocols = /* @__PURE__ */ new Set(["openai-codex", "github-copilot"]);
5559
+ /**
5560
+ * The effective catalog's providers this process has credentials for, optionally only those
5561
+ * credentialed one way. `hostDir` selects the project whose settings entries apply; without it,
5562
+ * only the user's.
5037
5563
  *
5038
- * `(none)` rather than an omitted section: a model told it has no tools behaves
5039
- * better than one left to infer it from silence.
5564
+ * `apiKey`: the provider's `key` or `env` names are set in the host environment (they include the
5565
+ * OAuth providers' env tokens). `oauth`: a login is stored in the {@link Credentials} file. `none`:
5566
+ * the provider names no key and isn't an OAuth provider. A provider with a key and a login reports
5567
+ * `apiKey`, the one a model call uses first.
5568
+ *
5569
+ * Presence, not validity: nothing is sent over the network, so a revoked key still counts. A
5570
+ * client showing a model picker should still add the session's current model.
5040
5571
  */
5041
- const toolIndex = (tools) => {
5042
- const listed = tools.filter((tool) => tool.promptSnippet !== void 0 && tool.promptSnippet.length > 0);
5043
- if (listed.length === 0) return "(none)";
5044
- return listed.map((tool) => `- ${tool.name}: ${tool.promptSnippet}`).join("\n");
5045
- };
5046
- const assemble = (input) => {
5047
- const sections = [input.promptCustom ?? foundation];
5048
- sections.push(`Available tools:\n${toolIndex(input.tools)}`);
5049
- const lines = guidelines(input.tools);
5050
- if (lines.length > 0) sections.push(`Guidelines:\n${lines.map((line) => `- ${line}`).join("\n")}`);
5051
- const append = input.promptSystemAppend?.trim();
5052
- if (append !== void 0 && append.length > 0) sections.push(append);
5053
- sections.push(`Current working directory: ${input.directory}`);
5054
- return sections.join("\n\n");
5055
- };
5572
+ const available$1 = Effect.fn("ModelCatalog.available")(function* (filter = {}) {
5573
+ const { authFile } = yield* Credentials;
5574
+ const settings = yield* Service$3;
5575
+ const catalog = yield* effective((yield* settings.load(filter.hostDir)).models);
5576
+ const usable = [];
5577
+ for (const [id, entries] of Object.entries(catalog)) {
5578
+ const first = entries === void 0 ? void 0 : Object.values(entries)[0];
5579
+ if (entries === void 0 || first === void 0) continue;
5580
+ const keyless = first.provider.key === void 0 && first.provider.env.length === 0 && !oauthProtocols.has(first.protocol);
5581
+ const credential = getEnvApiKey(first.provider) !== void 0 ? "apiKey" : (yield* loggedIn(first.protocol, authFile)) ? "oauth" : keyless ? "none" : void 0;
5582
+ if (credential === void 0 || filter.credential !== void 0 && filter.credential !== credential) continue;
5583
+ usable.push({
5584
+ id,
5585
+ credential,
5586
+ models: entries
5587
+ });
5588
+ }
5589
+ return usable;
5590
+ });
5591
+ //#endregion
5592
+ //#region src/plugin/plugin.ts
5593
+ var plugin_exports$1 = /* @__PURE__ */ __exportAll({});
5594
+ import * as import__codeworksh_plugin_plugin from "@codeworksh/plugin/plugin";
5595
+ __reExport(plugin_exports$1, import__codeworksh_plugin_plugin);
5596
+ //#endregion
5597
+ //#region src/plugin/builtin/prompt/default.ts
5598
+ /** The default prompt foundation, used unless `promptCustom` replaces it. */
5599
+ const foundation = "You are an expert coding assistant operating inside codework, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
5600
+ /** Every line here is spent on every request; keep it short. */
5601
+ const standingRules = ["Be concise in your responses", "Show file paths clearly when working with files"];
5602
+ /** Bash stands in for file tools only until dedicated ones are registered. */
5603
+ const toolRules = (names) => names.has("bash") && ![
5604
+ "grep",
5605
+ "find",
5606
+ "ls"
5607
+ ].some((name) => names.has(name)) ? ["Use bash for file operations like ls, rg, find"] : [];
5056
5608
  /**
5057
5609
  * A caller slot, awaited lazily. A throw or rejection fails the snapshot, which the
5058
5610
  * host attributes to this plugin's id.
@@ -5063,13 +5615,92 @@ const defaultPromptPlugin = (0, plugin_exports$1.define)({
5063
5615
  kind: "prompt",
5064
5616
  setup: Effect.fn("DefaultPromptPlugin.setup")(function* (ctx) {
5065
5617
  const custom = yield* slot(ctx, ctx.config.promptCustom);
5066
- const append = yield* slot(ctx, ctx.config.promptSystemAppend);
5067
- ctx.plugin.prompt.set(assemble({
5068
- tools: ctx.plugin.tools.list(),
5069
- directory: ctx.location.directory,
5070
- ...custom === void 0 ? {} : { promptCustom: custom },
5071
- ...append === void 0 ? {} : { promptSystemAppend: append }
5072
- }));
5618
+ const append = (yield* slot(ctx, ctx.config.promptSystemAppend))?.trim();
5619
+ const prompt = ctx.plugin.prompt;
5620
+ prompt.foundation.set(custom ?? foundation);
5621
+ const names = new Set(ctx.plugin.tools.list().map((tool) => tool.name));
5622
+ for (const rule of [...toolRules(names), ...standingRules]) prompt.sections.append(Section.Rules, rule);
5623
+ if (append !== void 0 && append.length > 0) prompt.sections.append(Section.Addendum, append);
5624
+ })
5625
+ });
5626
+ //#endregion
5627
+ //#region src/plugin/builtin/prompt/instruction.ts
5628
+ /** First readable match per directory wins. */
5629
+ const names = [
5630
+ "AGENTS.override.md",
5631
+ "CLAUDE.override.md",
5632
+ "AGENTS.md",
5633
+ "AGENTS.MD",
5634
+ "CLAUDE.md",
5635
+ "CLAUDE.MD"
5636
+ ];
5637
+ const content = (raw) => {
5638
+ const text = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw;
5639
+ return text.trim().length === 0 ? void 0 : text;
5640
+ };
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) {
5656
+ for (const name of names) {
5657
+ const path = posix.join(directory, name);
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);
5660
+ if (Option.isSome(raw)) return {
5661
+ name,
5662
+ path,
5663
+ content: content(raw.value)
5664
+ };
5665
+ }
5666
+ });
5667
+ /** Only the path is escaped; file content goes in verbatim. */
5668
+ const attribute = (value) => value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;");
5669
+ /**
5670
+ * The main checkout's directory when the cwd is in a linked worktree nested inside it (e.g.
5671
+ * `repo/.claude/worktrees/x`), so its same-named file is not applied twice. Sibling worktrees,
5672
+ * bare layouts and submodules are never shadowed.
5673
+ */
5674
+ const shadowing = (location) => {
5675
+ const store = location.project.vcs?.store;
5676
+ if (location.space.kind !== "linked" || store === void 0 || posix.basename(store) !== ".git") return void 0;
5677
+ const main = posix.dirname(store);
5678
+ return location.space.location.startsWith(`${main}/`) ? main : void 0;
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
+ });
5694
+ const instructionPlugin = (0, plugin_exports$1.define)({
5695
+ id: "codework.prompt.instruction",
5696
+ kind: "prompt",
5697
+ setup: Effect.fn("InstructionPlugin.setup")(function* (ctx) {
5698
+ const fs = yield* io_exports.SandboxIO.FileSystem;
5699
+ const files = (yield* discover(fs, ctx.location)).filter((file) => file.content !== void 0);
5700
+ if (files.length === 0) return;
5701
+ const prompt = ctx.plugin.prompt;
5702
+ prompt.sections.append(Section.ProjectContext, "Project-Specific Instructions & Guidelines:");
5703
+ for (const file of files) prompt.sections.append(Section.ProjectContext, `<project_instructions path="${attribute(file.path)}">\n${file.content}\n</project_instructions>`);
5073
5704
  })
5074
5705
  });
5075
5706
  //#endregion
@@ -5086,6 +5717,17 @@ const defaultPromptPlugin = (0, plugin_exports$1.define)({
5086
5717
  */
5087
5718
  const DEFAULT_MAX_LINES = 2e3;
5088
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
+ }
5089
5731
  function splitLinesForCounting(content) {
5090
5732
  if (content.length === 0) return [];
5091
5733
  const lines = content.split("\n");
@@ -5099,6 +5741,74 @@ function formatSize(bytes) {
5099
5741
  return `${(bytes / 1048576).toFixed(1)}MB`;
5100
5742
  }
5101
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
+ /**
5102
5812
  * Truncate content from the tail (keep the last N lines/bytes). Suitable for
5103
5813
  * bash output where the end matters (errors, final results). May return a
5104
5814
  * partial first line if the last line of the original content exceeds the byte
@@ -5173,9 +5883,6 @@ function truncateStringToBytesFromEnd(str, maxBytes) {
5173
5883
  }
5174
5884
  //#endregion
5175
5885
  //#region src/tool/accumulator.ts
5176
- function defaultTempFilePath(prefix, suffix) {
5177
- return posix.join(tmpdir(), `${prefix}-${suffix}.log`);
5178
- }
5179
5886
  function byteLength(text) {
5180
5887
  return Buffer.byteLength(text, "utf-8");
5181
5888
  }
@@ -5183,9 +5890,7 @@ var Accumulator = class {
5183
5890
  maxLines;
5184
5891
  maxBytes;
5185
5892
  maxRollingBytes;
5186
- tempFilePrefix;
5187
5893
  decoder = new TextDecoder();
5188
- rawChunks = [];
5189
5894
  tailText = "";
5190
5895
  tailBytes = 0;
5191
5896
  tailStartsAtLineBoundary = true;
@@ -5196,45 +5901,43 @@ var Accumulator = class {
5196
5901
  currentLineBytes = 0;
5197
5902
  hasOpenLine = false;
5198
5903
  finished = false;
5199
- tempFilePath;
5200
- tempFile;
5201
5904
  constructor(options = {}) {
5202
5905
  this.maxLines = options.maxLines ?? 2e3;
5203
5906
  this.maxBytes = options.maxBytes ?? 51200;
5204
5907
  this.maxRollingBytes = Math.max(this.maxBytes * 2, 1);
5205
- this.tempFilePrefix = options.tempFilePrefix ?? "codework-output";
5206
- }
5207
- append(data) {
5208
- return Effect.suspend(() => {
5209
- if (this.finished) return Effect.die(/* @__PURE__ */ new Error("cannot append to a finished output accumulator"));
5210
- this.totalRawBytes += data.length;
5211
- this.appendDecodedText(this.decoder.decode(data, { stream: true }));
5212
- if (this.tempFile !== void 0 || this.shouldUseTempFile()) return Effect.gen({ self: this }, function* () {
5213
- yield* this.ensureTempFile();
5214
- yield* this.tempFile.writeAll(data).pipe(Effect.orDie);
5215
- });
5216
- if (data.length > 0) this.rawChunks.push(data);
5217
- return Effect.void;
5218
- });
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;
5219
5927
  }
5220
5928
  finish() {
5221
- return Effect.suspend(() => {
5222
- if (this.finished) return Effect.void;
5223
- this.finished = true;
5224
- this.appendDecodedText(this.decoder.decode());
5225
- return Effect.gen({ self: this }, function* () {
5226
- if (this.shouldUseTempFile()) yield* this.ensureTempFile();
5227
- if (this.tempFile !== void 0) yield* this.tempFile.sync.pipe(Effect.orDie);
5228
- });
5229
- });
5929
+ if (this.finished) return;
5930
+ this.finished = true;
5931
+ this.appendDecodedText(this.decoder.decode());
5230
5932
  }
5231
5933
  snapshot() {
5232
5934
  const tailTruncation = truncateTail(this.getSnapshotText(), {
5233
5935
  maxLines: this.maxLines,
5234
5936
  maxBytes: this.maxBytes
5235
5937
  });
5236
- const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes;
5237
- 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;
5238
5941
  const truncation = {
5239
5942
  ...tailTruncation,
5240
5943
  truncated,
@@ -5247,7 +5950,7 @@ var Accumulator = class {
5247
5950
  return {
5248
5951
  content: truncation.content,
5249
5952
  truncation,
5250
- ...this.tempFilePath === void 0 ? {} : { fullOutputPath: this.tempFilePath }
5953
+ beyondLimits
5251
5954
  };
5252
5955
  }
5253
5956
  /** Bytes in the final (possibly unterminated) line — for the partial-line footer. */
@@ -5293,23 +5996,111 @@ var Accumulator = class {
5293
5996
  getSnapshotText() {
5294
5997
  if (this.tailStartsAtLineBoundary) return this.tailText;
5295
5998
  const firstNewline = this.tailText.indexOf("\n");
5296
- return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1);
5297
- }
5298
- shouldUseTempFile() {
5299
- return this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines;
5300
- }
5301
- ensureTempFile() {
5302
- if (this.tempFile !== void 0) return Effect.void;
5303
- return Effect.gen({ self: this }, function* () {
5304
- const suffix = Encoding.encodeHex(yield* crypto.randomBytes(8));
5305
- this.tempFilePath = defaultTempFilePath(this.tempFilePrefix, suffix);
5306
- this.tempFile = yield* fileSystem.open(this.tempFilePath, { flag: "w" });
5307
- for (const chunk of this.rawChunks) yield* this.tempFile.writeAll(chunk);
5308
- this.rawChunks = [];
5309
- }).pipe(Effect.orDie);
5999
+ return firstNewline === -1 || firstNewline === this.tailText.length - 1 ? this.tailText : this.tailText.slice(firstNewline + 1);
5310
6000
  }
5311
6001
  };
5312
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
5313
6104
  //#region src/tool/progress.ts
5314
6105
  var progress_exports = /* @__PURE__ */ __exportAll({});
5315
6106
  import * as import__codeworksh_plugin_tool_progress from "@codeworksh/plugin/tool/progress";
@@ -5319,16 +6110,11 @@ __reExport(progress_exports, import__codeworksh_plugin_tool_progress);
5319
6110
  /**
5320
6111
  * `ToolShell` — the tool-owned, cancellable command-execution capability the bash tool depends on.
5321
6112
  *
5322
- * It deliberately does **not** reuse `sandbox/Shell` directly: the local OS path
5323
- * provides no `Shell` at all, and `sandbox/Shell` models neither cancellation
5324
- * nor a timeout. `ToolShell` makes both part of the contract. Adapters honor the
5325
- * mid-flight process kill where the SDK allows it (local, Vercel) and fall back
5326
- * to the timeout where it does not (Daytona) — "kill where it's easy".
5327
- *
5328
- * Two modes, and Local has two variants:
5329
- * - Local / full OS shell → {@link local} (default)
5330
- * - Local / just-bash → {@link fromSandboxShell} over `EnvBash`
5331
- * - 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.
5332
6118
  */
5333
6119
  /** Infra/spawn failure — not model-actionable; handlers should treat it as a defect. */
5334
6120
  var ToolShellError = class extends Schema.TaggedError()("ToolShellError", {
@@ -5383,6 +6169,7 @@ const fromSandboxShell = Layer.effect(ToolShell, Effect.gen(function* () {
5383
6169
  exitCode: chunk.exitCode
5384
6170
  } : {
5385
6171
  _tag: "Output",
6172
+ channel: chunk._tag,
5386
6173
  bytes: chunk.bytes
5387
6174
  }), Stream.mapError((cause) => new ToolShellError({
5388
6175
  command,
@@ -5393,7 +6180,6 @@ const fromSandboxShell = Layer.effect(ToolShell, Effect.gen(function* () {
5393
6180
  stream
5394
6181
  } : { exec });
5395
6182
  }));
5396
- Duration.seconds(1);
5397
6183
  //#endregion
5398
6184
  //#region src/tool/tool.ts
5399
6185
  var tool_exports = /* @__PURE__ */ __exportAll({});
@@ -5403,28 +6189,32 @@ __reExport(tool_exports, import__codeworksh_plugin_tool);
5403
6189
  //#region src/plugin/builtin/tool/bash.ts
5404
6190
  /**
5405
6191
  * The self-contained bash plugin — a worked example for tool plugins. The definition is pure data;
5406
- * the handler depends only on {@link ToolShell} / {@link ToolProgress}, never
5407
- * `sandbox/Shell`, so the backend (local OS / just-bash / remote provider) is
5408
- * 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.
5409
6195
  *
5410
- * Two execution paths, picked by capability:
5411
- * - **variant A (buffered)** otherwise: `ToolShell.exec` returns the full output,
5412
- * truncated once after completion.
5413
- * - **variant B (streaming)** when the backend offers `ToolShell.stream`: output
5414
- * flows through an {@link Accumulator} (bounded memory + temp-file spill)
5415
- * and is reported live via {@link ToolProgress}; on timeout the partial output
5416
- * 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.
5417
6203
  *
5418
6204
  * Success and failure carry the *same* structured shape
5419
6205
  * (`output`, `exitCode`, `truncated`, `fullOutputPath`)
5420
6206
  * — a non-zero exit is just `exitCode !== 0`, not a different kind of result.
5421
6207
  */
6208
+ const limits = {
6209
+ maxLines: DEFAULT_MAX_LINES,
6210
+ maxBytes: DEFAULT_MAX_BYTES
6211
+ };
5422
6212
  const BashParams = Schema.Struct({
5423
6213
  command: Schema.String.annotate({ description: "The bash command to execute." }),
5424
6214
  timeout: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0)).annotate({ description: "Optional timeout in seconds (must be greater than 0)." }))
5425
6215
  });
5426
6216
  const outputFields = {
5427
- /** 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`). */
5428
6218
  output: Schema.String,
5429
6219
  truncated: Schema.Boolean,
5430
6220
  fullOutputPath: Schema.optional(Schema.String)
@@ -5444,175 +6234,1574 @@ var BashTimedOut = class extends Schema.TaggedError()("BashTimedOut", {
5444
6234
  timeoutSeconds: Schema.Finite
5445
6235
  }) {};
5446
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
+ };
5447
6242
  const bashDef = tool_exports.define({
5448
6243
  name: "bash",
5449
6244
  label: "bash",
5450
6245
  promptSnippet: "Execute bash commands (ls, grep, find, etc.).",
5451
- 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.`,
5452
6247
  parameters: BashParams,
6248
+ constrainedSampling: {
6249
+ type: "json_schema",
6250
+ strict: "prefer"
6251
+ },
5453
6252
  success: BashSuccess,
5454
6253
  failure: BashFailure,
5455
6254
  encodeContent: (success) => [{
5456
6255
  type: "text",
5457
- text: success.output
6256
+ text: success.output || "(no output)"
6257
+ }],
6258
+ encodeFailureContent: (failure) => [{
6259
+ type: "text",
6260
+ text: withStatus(failure)
6261
+ }]
6262
+ });
6263
+ /** Combine stdout and stderr into one stream of text, stderr after stdout. */
6264
+ const combineOutput = (stdout, stderr) => {
6265
+ if (stderr.length === 0) return stdout;
6266
+ if (stdout.length === 0) return stderr;
6267
+ return `${stdout}\n${stderr}`;
6268
+ };
6269
+ /** One-line footer appended to truncated, model-facing output. */
6270
+ const footer = (t, fullOutputPath, lastLineBytes) => {
6271
+ const where = fullOutputPath ? ` Full output: ${fullOutputPath}` : "";
6272
+ const startLine = t.totalLines - t.outputLines + 1;
6273
+ if (t.lastLinePartial) {
6274
+ const lineSize = lastLineBytes !== void 0 ? ` (line is ${formatSize(lastLineBytes)})` : "";
6275
+ return `\n\n[showing last ${formatSize(t.outputBytes)} of line ${t.totalLines}${lineSize}.${where}]`;
6276
+ }
6277
+ if (t.truncatedBy === "lines") return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines}.${where}]`;
6278
+ return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines} (${formatSize(t.maxBytes)} limit).${where}]`;
6279
+ };
6280
+ /** Present a snapshot; `saved` is the output file, when the sandbox kept one. */
6281
+ const present = (snap, lastLineBytes, saved) => {
6282
+ if (!snap.truncation.truncated) return {
6283
+ output: snap.content,
6284
+ truncated: false
6285
+ };
6286
+ return {
6287
+ output: snap.content.replace(/\n$/, "") + footer(snap.truncation, saved, lastLineBytes),
6288
+ truncated: true,
6289
+ ...saved !== void 0 ? { fullOutputPath: saved } : {}
6290
+ };
6291
+ };
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({
6308
+ timeoutSeconds: timeout.timeoutMillis / 1e3,
6309
+ output: "",
6310
+ truncated: false
6311
+ })))),
6312
+ ToolShellError: (cause) => Effect.die(cause)
6313
+ }));
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);
6324
+ if (result.exitCode !== 0) return yield* new BashFailed({
6325
+ exitCode: result.exitCode,
6326
+ ...presented
6327
+ });
6328
+ return {
6329
+ exitCode: result.exitCode,
6330
+ ...presented
6331
+ };
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
+ };
6338
+ /** Variant B — streaming: accumulate output, report progress, keep partial output on timeout. */
6339
+ const runStreaming = (shell, stream, params, file) => Effect.gen(function* () {
6340
+ const acc = new Accumulator(limits);
6341
+ const progress = yield* progress_exports.ToolProgress;
6342
+ const exitCode = yield* Ref.make(null);
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);
6352
+ yield* progress.report({ content: [{
6353
+ type: "text",
6354
+ text: acc.snapshot().content
6355
+ }] });
6356
+ })), Effect.catchTag("ToolShellError", (cause) => Effect.die(cause)));
6357
+ let timedOutAfter;
6358
+ if (params.timeout !== void 0) {
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
+ }
6367
+ } else yield* consume;
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
+ }
6381
+ const code = yield* Ref.get(exitCode);
6382
+ if (code === null || code !== 0) return yield* new BashFailed({
6383
+ exitCode: code ?? -1,
6384
+ ...presented
6385
+ });
6386
+ return {
6387
+ exitCode: code,
6388
+ ...presented
6389
+ };
6390
+ });
6391
+ const bashHandler = (params) => Effect.gen(function* () {
6392
+ const shell = yield* ToolShell;
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
+ })));
6398
+ });
6399
+ /** The bash tool: definition + handler, wired the testable (def/exec split) way. */
6400
+ const bashTool = tool_exports.implement(bashDef, bashHandler);
6401
+ const bashPlugin = (0, plugin_exports$1.define)({
6402
+ id: "codework.tool.bash",
6403
+ kind: "tool",
6404
+ setup: Effect.fn("BashPlugin.setup")(function* (ctx) {
6405
+ const shell = yield* io_exports.SandboxIO.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));
6408
+ ctx.plugin.tools.add(tool_exports.provide(bashTool, mounted));
6409
+ })
6410
+ });
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
5458
7113
  }],
5459
7114
  encodeFailureContent: (failure) => [{
5460
7115
  type: "text",
5461
- text: failure.output
5462
- }]
7116
+ text: failure.message
7117
+ }],
7118
+ encodeDetails: (success) => success.details
5463
7119
  });
5464
- /** Combine stdout and stderr into one stream of text, stderr after stdout. */
5465
- const combineOutput = (stdout, stderr) => {
5466
- if (stderr.length === 0) return stdout;
5467
- if (stdout.length === 0) return stderr;
5468
- return `${stdout}\n${stderr}`;
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
+ }
5469
7137
  };
5470
- /** One-line footer appended to truncated, model-facing output. */
5471
- const footer = (t, fullOutputPath, lastLineBytes) => {
5472
- const where = fullOutputPath ? ` Full output: ${fullOutputPath}` : "";
5473
- const startLine = t.totalLines - t.outputLines + 1;
5474
- if (t.lastLinePartial) {
5475
- const lineSize = lastLineBytes !== void 0 ? ` (line is ${formatSize(lastLineBytes)})` : "";
5476
- return `\n\n[showing last ${formatSize(t.outputBytes)} of line ${t.totalLines}${lineSize}.${where}]`;
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);
5477
7165
  }
5478
- if (t.truncatedBy === "lines") return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines}.${where}]`;
5479
- return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines} (${formatSize(t.maxBytes)} limit).${where}]`;
7166
+ return {
7167
+ matches,
7168
+ status,
7169
+ errors: [stderr.trim(), ...errors].filter((text) => text.length > 0).join("\n")
7170
+ };
5480
7171
  };
5481
- /** Write full output to a host temp file (best-effort; undefined on failure). */
5482
- const spillToTempFile = (content) => Effect.gen(function* () {
5483
- const suffix = Encoding.encodeHex(yield* crypto.randomBytes(6));
5484
- const path = posix.join(tmpdir(), `codework-bash-${suffix}.log`);
5485
- yield* fileSystem.writeFileString(path, content);
5486
- return path;
5487
- }).pipe(Effect.orElseSucceed(() => void 0));
5488
- /** Variant A: truncate the buffered output once, spilling the full output if truncated. */
5489
- const presentBuffered = (combined) => Effect.gen(function* () {
5490
- const t = truncateTail(combined);
5491
- if (!t.truncated) return {
5492
- output: t.content,
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)`],
5493
7188
  truncated: false
5494
7189
  };
5495
- const fullOutputPath = yield* spillToTempFile(combined);
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
+ }
5496
7208
  return {
5497
- output: t.content + footer(t, fullOutputPath),
5498
- truncated: true,
5499
- ...fullOutputPath !== void 0 ? { fullOutputPath } : {}
7209
+ lines: block,
7210
+ truncated
5500
7211
  };
5501
7212
  });
5502
- /** Variant B: present an accumulator snapshot (the temp file is spilled incrementally). */
5503
- const presentSnapshot = (snap, lastLineBytes) => {
5504
- if (!snap.truncation.truncated) return {
5505
- output: snap.content,
5506
- truncated: false
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.]"
5507
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);
5508
7543
  return {
5509
- output: snap.content + footer(snap.truncation, snap.fullOutputPath, lastLineBytes),
5510
- truncated: true,
5511
- ...snap.fullOutputPath !== void 0 ? { fullOutputPath: snap.fullOutputPath } : {}
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}`
5512
7633
  };
5513
- };
5514
- /** Variant A — buffered: one `exec`, truncate after completion. No live progress. */
5515
- const runBuffered = (shell, params) => Effect.gen(function* () {
5516
- const result = yield* shell.exec(params.command, params.timeout !== void 0 ? { timeout: Duration.seconds(params.timeout) } : void 0).pipe(Effect.catchTags({
5517
- ToolShellTimeout: (timeout) => Effect.fail(new BashTimedOut({
5518
- timeoutSeconds: timeout.timeoutMillis / 1e3,
5519
- output: "",
5520
- truncated: false
5521
- })),
5522
- ToolShellError: (cause) => Effect.die(cause)
5523
- }));
5524
- const present = yield* presentBuffered(combineOutput(result.stdout, result.stderr));
5525
- if (result.exitCode !== 0) return yield* new BashFailed({
5526
- exitCode: result.exitCode,
5527
- ...present
5528
- });
5529
7634
  return {
5530
- exitCode: result.exitCode,
5531
- ...present
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
+ }
5532
7641
  };
5533
7642
  });
5534
- /** Variant B — streaming: accumulate output, report progress, keep partial output on timeout. */
5535
- const runStreaming = (stream, params) => Effect.scoped(Effect.gen(function* () {
5536
- const acc = new Accumulator({ tempFilePrefix: "codework-bash" });
5537
- const progress = yield* progress_exports.ToolProgress;
5538
- const exitCode = yield* Ref.make(null);
5539
- const consume = stream(params.command).pipe(Stream.runForEach((event) => event._tag === "Exit" ? Ref.set(exitCode, event.exitCode) : Effect.gen(function* () {
5540
- yield* acc.append(Buffer.from(event.bytes));
5541
- yield* progress.report({ content: [{
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 {
5542
7690
  type: "text",
5543
- text: acc.snapshot().content
5544
- }] });
5545
- })), Effect.catchTag("ToolShellError", (cause) => Effect.die(cause)));
5546
- let timedOutAfter;
5547
- if (params.timeout !== void 0) {
5548
- const finished = yield* consume.pipe(Effect.timeoutOption(Duration.seconds(params.timeout)));
5549
- if (Option.isNone(finished)) timedOutAfter = params.timeout;
5550
- } else yield* consume;
5551
- yield* acc.finish();
5552
- const present = presentSnapshot(acc.snapshot(), acc.getLastLineBytes());
5553
- if (timedOutAfter !== void 0) return yield* new BashTimedOut({
5554
- timeoutSeconds: timedOutAfter,
5555
- ...present
5556
- });
5557
- const code = yield* Ref.get(exitCode);
5558
- if (code === null || code !== 0) return yield* new BashFailed({
5559
- exitCode: code ?? -1,
5560
- ...present
5561
- });
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
+ }
5562
7702
  return {
5563
- exitCode: code,
5564
- ...present
7703
+ type: "text",
7704
+ text: shown.text
5565
7705
  };
5566
- }));
5567
- const bashHandler = (params) => Effect.gen(function* () {
5568
- const shell = yield* ToolShell;
5569
- return shell.stream !== void 0 ? yield* runStreaming(shell.stream, params) : yield* runBuffered(shell, params);
5570
7706
  });
5571
- /** The bash tool: definition + handler, wired the testable (def/exec split) way. */
5572
- const bashTool = tool_exports.implement(bashDef, bashHandler);
5573
- const bashPlugin = (0, plugin_exports$1.define)({
5574
- id: "codework.tool.bash",
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",
5575
7726
  kind: "tool",
5576
- setup: Effect.fn("BashPlugin.setup")(function* (ctx) {
5577
- const shell = yield* io_exports.SandboxIO.Shell;
5578
- const mounted = fromSandboxShell.pipe(Layer.provide(Layer.succeed(io_exports.SandboxIO.Shell, shell)));
5579
- ctx.plugin.tools.add(tool_exports.provide(bashTool, mounted));
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)));
5580
7730
  })
5581
7731
  });
5582
7732
  //#endregion
5583
- //#region src/plugin/builtin.ts
7733
+ //#region src/plugin/builtin/tool/write.ts
5584
7734
  /**
5585
- * Every plugin the harness ships, and the selection a caller who passes no `plugins` gets. Tool
5586
- * contributors come first so the Prompt plugin that indexes them runs after they registered.
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.
5587
7738
  */
5588
- const builtins = Object.freeze([bashPlugin, defaultPromptPlugin]);
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
+ });
5589
7786
  //#endregion
5590
- //#region src/settings/merge.ts
7787
+ //#region src/plugin/builtin.ts
5591
7788
  /**
5592
- * A plain string-keyed record: what a settings patch merges, and what a plugin's `options` block
5593
- * has to be. A Map, a typed array or a class instance is an object to `typeof` and not one of
5594
- * these — `Object.freeze` alone does not tell them apart, and it throws on some of them.
7789
+ * Every plugin the harness ships, and the selection a caller who passes no `plugins` gets. Tool
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 }`.
5595
7793
  */
5596
- const isRecord$1 = (value) => Predicate.isObject(value) && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
5597
- const copy = (value) => {
5598
- if (Array.isArray(value)) return value.map(copy);
5599
- return isRecord$1(value) ? merge({}, value) : value;
5600
- };
5601
- /** Null/undefined properties are absent, objects merge, and arrays replace. Never mutates inputs. */
5602
- function merge(base, ...patches) {
5603
- const result = /* @__PURE__ */ new Map();
5604
- for (const patch of [base, ...patches]) {
5605
- if (patch === void 0) continue;
5606
- for (const [key, value] of Object.entries(patch)) {
5607
- if (value === null || value === void 0) continue;
5608
- const previous = result.get(key);
5609
- result.set(key, isRecord$1(previous) && isRecord$1(value) ? merge(previous, value) : copy(value));
5610
- }
5611
- }
5612
- return Object.fromEntries(result);
5613
- }
5614
- /** Normalize nullable properties before schema validation, without changing array positions. */
5615
- const normalize = copy;
7794
+ const builtins = Object.freeze([
7795
+ bashPlugin,
7796
+ readPlugin,
7797
+ writePlugin,
7798
+ editPlugin,
7799
+ grepPlugin,
7800
+ findPlugin,
7801
+ lsPlugin,
7802
+ defaultPromptPlugin,
7803
+ instructionPlugin
7804
+ ]);
5616
7805
  //#endregion
5617
7806
  //#region src/util/module.ts
5618
7807
  /*!
@@ -6169,7 +8358,7 @@ const isPath = (reference) => reference.startsWith("file:") || reference.startsW
6169
8358
  * - a **CLI argument** anchors to the process's startup directory, because that is where the
6170
8359
  * person typed it.
6171
8360
  */
6172
- const parse$2 = (reference, from) => Effect.gen(function* () {
8361
+ const parse$1 = (reference, from) => Effect.gen(function* () {
6173
8362
  const fail = (reason, message) => new SourceError({
6174
8363
  reason,
6175
8364
  reference,
@@ -6386,7 +8575,7 @@ const slugOf = (target) => target.kind === "git" ? target.slug : canonical$1(tar
6386
8575
  */
6387
8576
  const digest = Effect.fn("PluginStore.digest")(function* (target, cache, from) {
6388
8577
  const source = target.kind === "registry" ? `${yield* registry(target, cache, from)}\0${target.spec}` : target.spec;
6389
- return Encoding.encodeHex(yield* crypto.digest("SHA-256", new TextEncoder().encode(source)));
8578
+ return Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(source)));
6390
8579
  });
6391
8580
  const entryDir = Effect.fn("PluginStore.entryDir")(function* (target, cache, from) {
6392
8581
  const key = yield* digest(target, cache, from);
@@ -6691,6 +8880,7 @@ const Definition = Schema.Struct({
6691
8880
  id: Id,
6692
8881
  kind: Schema.Literals(Object.keys(plugin_exports$1.domains)),
6693
8882
  events: Schema.optional(Schema.Array(Schema.declare((value) => Predicate.hasProperty(value, "type") && Predicate.isString(value.type)))),
8883
+ optIn: Schema.optional(Schema.Boolean),
6694
8884
  setup: Schema.declare((value) => Predicate.isFunction(value))
6695
8885
  });
6696
8886
  /** Every plugin failure says which file to go and edit, when there is one. */
@@ -6790,7 +8980,7 @@ const localUrl = Effect.fn("PluginLoader.localUrl")(function* (location, origin)
6790
8980
  */
6791
8981
  const install = Effect.fn("PluginLoader.install")(function* (target, cache, origin, options) {
6792
8982
  const added = yield* add(target, cache, {
6793
- from: anchor$1(origin.file, options.hostDir),
8983
+ from: anchor(origin.file, options.hostDir),
6794
8984
  validate: (fetched) => imported(url(fetched.entrypoint), origin, options.import)
6795
8985
  });
6796
8986
  return {
@@ -6806,13 +8996,13 @@ const install = Effect.fn("PluginLoader.install")(function* (target, cache, orig
6806
8996
  * package boundary discovered upward from the chain's start -- the root itself -- so anchoring at
6807
8997
  * the file's own directory would miss it. Absent a declaring file the host context stands in.
6808
8998
  */
6809
- const anchor$1 = (file, fallback) => {
8999
+ const anchor = (file, fallback) => {
6810
9000
  if (file === void 0) return fallback;
6811
9001
  const directory = hostPath.dirname(file);
6812
9002
  return hostPath.basename(directory) === ".codework" ? hostPath.dirname(directory) : directory;
6813
9003
  };
6814
9004
  /** The default installer, or the one the caller injected. */
6815
- const installer = (target, origin, options) => options.install === void 0 ? install(target, options.cache, origin, options) : options.install(target, options.cache, anchor$1(origin.file, options.hostDir));
9005
+ const installer = (target, origin, options) => options.install === void 0 ? install(target, options.cache, origin, options) : options.install(target, options.cache, anchor(origin.file, options.hostDir));
6816
9006
  /** Import a module and confirm it default-exports a plugin. */
6817
9007
  const imported = Effect.fn("PluginLoader.imported")(function* (url, origin, load) {
6818
9008
  const module = yield* Effect.tryPromise({
@@ -6844,15 +9034,15 @@ const inspect = Effect.fn("PluginLoader.inspect")(function* (reference, options)
6844
9034
  reference,
6845
9035
  file: options.file
6846
9036
  };
6847
- const target = yield* parse$2(reference, options.hostDir);
6848
- const loaded = yield* load$3(target, origin, options);
9037
+ const target = yield* parse$1(reference, options.hostDir);
9038
+ const loaded = yield* load$2(target, origin, options);
6849
9039
  return {
6850
9040
  id: loaded.plugin.id,
6851
9041
  ...loaded.version === void 0 ? {} : { version: loaded.version },
6852
9042
  ...loaded.name === void 0 ? {} : { name: loaded.name }
6853
9043
  };
6854
9044
  });
6855
- const load$3 = Effect.fn("PluginLoader.load")(function* (target, origin, options) {
9045
+ const load$2 = Effect.fn("PluginLoader.load")(function* (target, origin, options) {
6856
9046
  const installed = yield* target.kind === "local" ? localUrl(target.path, origin).pipe(Effect.mapError((cause) => sourceFailure(origin, cause))) : installer(target, origin, options).pipe(Effect.mapError((cause) => origin.file === void 0 ? cause : declaredIn(cause, origin.file)));
6857
9047
  const address = target.kind === "local" && options.reload !== void 0 && options.reload > 0 ? `${installed.url}?reload=${options.reload}` : installed.url;
6858
9048
  return {
@@ -6870,7 +9060,7 @@ const load$3 = Effect.fn("PluginLoader.load")(function* (target, origin, options
6870
9060
  * are one package, and `./plugins/x.ts` is the file it resolves to from the directory that
6871
9061
  * declared it. Pure -- nothing is installed, imported or read.
6872
9062
  */
6873
- const canonical = (reference, hostDir) => Effect.map(parse$2(reference, hostDir), canonical$1);
9063
+ const canonical = (reference, hostDir) => Effect.map(parse$1(reference, hostDir), canonical$1);
6874
9064
  //#endregion
6875
9065
  //#region src/plugin/catalog.ts
6876
9066
  /**
@@ -6897,7 +9087,7 @@ const isPatch = (reference) => Predicate.isObject(reference) && !Predicate.isFun
6897
9087
  *
6898
9088
  * Loading is keyed by what a reference resolves to, so naming one module twice imports it once.
6899
9089
  */
6900
- const load$2 = Effect.fn("PluginCatalog.load")(function* (references, options) {
9090
+ const load$1 = Effect.fn("PluginCatalog.load")(function* (references, options) {
6901
9091
  const plugins = /* @__PURE__ */ new Map();
6902
9092
  const aliases = /* @__PURE__ */ new Map();
6903
9093
  const versions = /* @__PURE__ */ new Map();
@@ -6932,14 +9122,14 @@ const load$2 = Effect.fn("PluginCatalog.load")(function* (references, options) {
6932
9122
  remember(plugin, []);
6933
9123
  continue;
6934
9124
  }
6935
- const target = yield* parse$2(reference, options.hostDir);
9125
+ const target = yield* parse$1(reference, options.hostDir);
6936
9126
  const key = target.kind === "local" ? target.path : target.spec;
6937
9127
  const already = seen.get(key);
6938
9128
  if (already !== void 0) {
6939
9129
  aliases.set(reference, already);
6940
9130
  continue;
6941
9131
  }
6942
- const definition = yield* load$3(target, origin, options);
9132
+ const definition = yield* load$2(target, origin, options);
6943
9133
  yield* note(definition.plugin, definition.version === void 0 ? key : `${key}@${definition.version}`);
6944
9134
  const names = target.kind === "local" ? [target.path, ...definition.name === void 0 ? [] : [definition.name]] : [canonical$1(target), target.spec];
6945
9135
  remember(definition.plugin, [reference, ...names], {
@@ -6976,11 +9166,14 @@ const load$2 = Effect.fn("PluginCatalog.load")(function* (references, options) {
6976
9166
  const select = Effect.fn("PluginCatalog.select")(function* (references, pool) {
6977
9167
  const operations = /* @__PURE__ */ new Map();
6978
9168
  const missing = [];
6979
- const choose = (id, index) => operations.set(id, {
6980
- enabled: true,
6981
- index,
6982
- options: operations.get(id)?.options ?? {}
6983
- });
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
+ };
6984
9177
  for (const [index, reference] of references.entries()) {
6985
9178
  if (isPatch(reference)) {
6986
9179
  const named = reference.plugin ?? reference.package;
@@ -7061,12 +9254,12 @@ const follow = Effect.fn("PluginCatalog.follow")(function* (references, pool, op
7061
9254
  for (const reference of references) {
7062
9255
  if (typeof reference !== "string") continue;
7063
9256
  const owner = options.declared?.get(reference);
7064
- const from = anchor$1(owner, options.hostDir);
9257
+ const from = anchor(owner, options.hostDir);
7065
9258
  const id = pool.aliases.get(reference);
7066
9259
  if (id !== void 0) {
7067
9260
  const origin = pool.origins.get(id);
7068
9261
  if (owner === void 0 || origin?.reference !== reference) continue;
7069
- if (anchor$1(origin.file, options.hostDir) === from) continue;
9262
+ if (anchor(origin.file, options.hostDir) === from) continue;
7070
9263
  }
7071
9264
  if (Option.isSome(yield* filed(reference, from))) {
7072
9265
  moved = true;
@@ -7075,7 +9268,7 @@ const follow = Effect.fn("PluginCatalog.follow")(function* (references, pool, op
7075
9268
  }
7076
9269
  if (!moved) for (const origin of pool.origins.values()) {
7077
9270
  if (origin.generation === void 0) continue;
7078
- const current = yield* filed(origin.reference, anchor$1(origin.file, options.hostDir));
9271
+ const current = yield* filed(origin.reference, anchor(origin.file, options.hostDir));
7079
9272
  if (Option.isSome(current) && current.value.generation !== void 0 && current.value.generation > origin.generation) {
7080
9273
  moved = true;
7081
9274
  break;
@@ -7084,7 +9277,7 @@ const follow = Effect.fn("PluginCatalog.follow")(function* (references, pool, op
7084
9277
  if (!moved) return Option.none();
7085
9278
  const retained = [];
7086
9279
  for (const origin of pool.origins.values()) {
7087
- if (references.includes(origin.reference) || Option.isSome(yield* filed(origin.reference, anchor$1(origin.file, options.hostDir)))) {
9280
+ if (references.includes(origin.reference) || Option.isSome(yield* filed(origin.reference, anchor(origin.file, options.hostDir)))) {
7088
9281
  retained.push(origin);
7089
9282
  continue;
7090
9283
  }
@@ -7094,13 +9287,13 @@ const follow = Effect.fn("PluginCatalog.follow")(function* (references, pool, op
7094
9287
  const known = /* @__PURE__ */ new Map();
7095
9288
  for (const origin of retained) if (origin.file !== void 0) known.set(origin.reference, origin.file);
7096
9289
  for (const [reference, file] of options.declared ?? /* @__PURE__ */ new Map()) known.set(reference, file);
7097
- return Option.some(yield* load$2(accumulated, {
9290
+ return Option.some(yield* load$1(accumulated, {
7098
9291
  ...options,
7099
9292
  declared: known
7100
9293
  }));
7101
9294
  });
7102
9295
  Effect.fn("PluginCatalog.prepare")(function* (references, options) {
7103
- const pool = yield* load$2(references, options);
9296
+ const pool = yield* load$1(references, options);
7104
9297
  return (yield* select(references, pool)).selection;
7105
9298
  });
7106
9299
  //#endregion
@@ -7178,7 +9371,7 @@ const make$7 = (options) => Effect.gen(function* () {
7178
9371
  });
7179
9372
  //#endregion
7180
9373
  //#region src/runner/execute.ts
7181
- const layer$7 = Layer.effect(Service$6, Effect.gen(function* () {
9374
+ const layer$6 = Layer.effect(Service$6, Effect.gen(function* () {
7182
9375
  const store = yield* Service$14;
7183
9376
  const sandbox = yield* Controller;
7184
9377
  const sql = yield* SqlClient.SqlClient;
@@ -7244,11 +9437,6 @@ const layer$7 = Layer.effect(Service$6, Effect.gen(function* () {
7244
9437
  });
7245
9438
  }));
7246
9439
  //#endregion
7247
- //#region src/settings/schema.ts
7248
- var schema_exports$3 = /* @__PURE__ */ __exportAll({});
7249
- import * as import__codeworksh_plugin_settings from "@codeworksh/plugin/settings";
7250
- __reExport(schema_exports$3, import__codeworksh_plugin_settings);
7251
- //#endregion
7252
9440
  //#region src/settings/resolve.ts
7253
9441
  const patternMatches = (pattern, id) => new RegExp(`^${pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`).test(id);
7254
9442
  function collect(settings, provider, id) {
@@ -7287,11 +9475,12 @@ function resolveRequest(block, model) {
7287
9475
  "toolExecution",
7288
9476
  "contextWindow",
7289
9477
  "protocol",
9478
+ "apiKey",
7290
9479
  "extras"
7291
9480
  ].includes(key)));
7292
9481
  return {
7293
9482
  ...resolveOptions(block),
7294
- ...Object.keys(bag).length === 0 ? {} : { providerOptions: { [Model$1.optionsKey(model)]: bag } }
9483
+ ...Object.keys(bag).length === 0 ? {} : { providerOptions: { [Model.optionsKey(model)]: bag } }
7295
9484
  };
7296
9485
  }
7297
9486
  /**
@@ -7299,7 +9488,8 @@ function resolveRequest(block, model) {
7299
9488
  *
7300
9489
  * Separate from {@link resolveRequest} because these steer the loop itself -- how hard to
7301
9490
  * think, how to schedule tool calls -- rather than the request body. Runtime bindings
7302
- * 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.
7303
9493
  */
7304
9494
  const resolveControls = (settings, block, runtime) => ({
7305
9495
  thinkingLevel: runtime.thinkingLevel ?? block.thinkingLevel ?? settings.model.thinkingLevel,
@@ -7320,6 +9510,25 @@ function compose(settings, options = {}, session = {}) {
7320
9510
  };
7321
9511
  }
7322
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
7323
9532
  //#region src/runner/event.ts
7324
9533
  const make$6 = Effect.fn("LLMEventPublisher.make")(function* (input) {
7325
9534
  const events = yield* Service$16;
@@ -7525,15 +9734,27 @@ const messageFailure = (message) => {
7525
9734
  };
7526
9735
  /** Resolve once before exchange setup; execution reuses this exact instance. */
7527
9736
  const resolve$1 = Effect.fn("LLM.resolve")(function* (input) {
7528
- const model = yield* Effect.tryPromise({
7529
- try: () => llm(input.provider, input.model, resolveOverrides(input.settings ?? {})),
9737
+ const overrides = resolveOverrides(input.settings ?? {});
9738
+ const declared = entry(input.models ?? [], input.provider, input.model);
9739
+ const model = declared !== void 0 ? {
9740
+ ...declared,
9741
+ ...overrides
9742
+ } : yield* Effect.tryPromise({
9743
+ try: () => llm(input.provider, input.model, overrides),
7530
9744
  catch: (cause) => loadError(cause) ?? providerErrorFromUnknown(input, cause)
7531
9745
  });
7532
9746
  if (model === void 0) return yield* new ModelNotFoundError({
7533
9747
  provider: input.provider,
7534
9748
  model: input.model
7535
9749
  });
7536
- return model;
9750
+ const apiKey = input.settings?.apiKey;
9751
+ return apiKey === void 0 ? model : {
9752
+ ...model,
9753
+ provider: {
9754
+ ...model.provider,
9755
+ key: (0, schema_exports$3.keyName)(apiKey)
9756
+ }
9757
+ };
7537
9758
  });
7538
9759
  /**
7539
9760
  * Fill in what an OAuth provider needs and the model catalog cannot carry: the
@@ -7542,7 +9763,7 @@ const resolve$1 = Effect.fn("LLM.resolve")(function* (input) {
7542
9763
  * with no stored login resolves to `{}` so aikit reports the missing key.
7543
9764
  */
7544
9765
  const oauthCredentials = async (protocol, options) => {
7545
- const storage = options.authFile === void 0 ? {} : { path: options.authFile };
9766
+ const storage = oauthStorage(options.authFile);
7546
9767
  if (protocol === "openai-codex") {
7547
9768
  const apiKey = await getOpenAICodexApiKey({ storage: new JsonOpenAICodexAuthStorage(storage) });
7548
9769
  return apiKey === void 0 ? {} : { apiKey };
@@ -7562,7 +9783,7 @@ const oauthCredentials = async (protocol, options) => {
7562
9783
  };
7563
9784
  /** Start a provider stream using the model pinned by State. */
7564
9785
  const openWith = (options = {}) => Effect.fn("LLM.open")(function* (input, signal) {
7565
- const model = input.resolvedModel;
9786
+ const model = withAttribution(input.resolvedModel);
7566
9787
  const configured = runtimeOptions(input, model, signal);
7567
9788
  const resolved = configured.apiKey === void 0 ? yield* Effect.tryPromise({
7568
9789
  try: () => oauthCredentials(model.protocol, options),
@@ -7695,8 +9916,9 @@ const encodeOutcome = (def, call, exit, latest) => Effect.gen(function* () {
7695
9916
  if (Exit.isSuccess(exit)) {
7696
9917
  const encoded = yield* Schema.encodeUnknownEffect(asCodec(def.success))(exit.value).pipe(Effect.orDie);
7697
9918
  const content = def.encodeContent ? def.encodeContent(exit.value) : [yield* jsonText(encoded)];
9919
+ const details = def.encodeDetails ? def.encodeDetails(exit.value) : encoded;
7698
9920
  const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
7699
- return completed(call, content, now, encoded);
9921
+ return completed(call, content, now, details);
7700
9922
  }
7701
9923
  const cause = exit.cause;
7702
9924
  if (Cause.hasInterrupts(cause)) {
@@ -7761,6 +9983,15 @@ const make$4 = (tools) => {
7761
9983
  impls.set(name, entry);
7762
9984
  }
7763
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
+ }));
7764
9995
  const handle = (call, options) => Effect.gen(function* () {
7765
9996
  const entry = impls.get(call.name);
7766
9997
  if (entry === void 0) {
@@ -7772,7 +10003,18 @@ const make$4 = (tools) => {
7772
10003
  }
7773
10004
  const { tool: impl, hooks } = entry;
7774
10005
  const def = impl.definition;
7775
- 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));
7776
10018
  if (Result.isFailure(decoded)) {
7777
10019
  const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
7778
10020
  return errored(call, [text(`Invalid arguments for ${call.name}: ${decoded.failure.message}`)], now, {
@@ -7915,20 +10157,109 @@ const make$3 = (tools) => {
7915
10157
  Context.Service()("@codeworksh/harness/tool/registry/ToolRegistry");
7916
10158
  const make$2 = () => {
7917
10159
  let open = true;
7918
- let value;
10160
+ let foundation;
10161
+ const sections = /* @__PURE__ */ new Map();
10162
+ const writable = () => {
10163
+ if (!open) throw new Error("prompt registry is closed");
10164
+ };
10165
+ const slot = (section) => {
10166
+ writable();
10167
+ const reason = Section.invalid(section.name);
10168
+ if (reason !== void 0) throw new Error(reason);
10169
+ if (!Section.isFormat(section.format)) throw new Error(`prompt section ${section.name} has an unknown format: ${JSON.stringify(section.format)}`);
10170
+ const builtin = Section.builtins.find((candidate) => candidate.name === section.name);
10171
+ if (builtin !== void 0 && builtin.format !== section.format) throw new Error(`prompt section ${section.name} is built in as "${builtin.format}"`);
10172
+ const existing = sections.get(section.name);
10173
+ if (existing === void 0) {
10174
+ const opened = {
10175
+ format: section.format,
10176
+ entries: []
10177
+ };
10178
+ sections.set(section.name, opened);
10179
+ return opened;
10180
+ }
10181
+ if (existing.format !== section.format) throw new Error(`prompt section ${section.name} is "${existing.format}"; a write as "${section.format}" cannot merge into it`);
10182
+ return existing;
10183
+ };
7919
10184
  return {
7920
10185
  registry: Object.freeze({
7921
- set: (prompt) => {
7922
- if (!open) throw new Error("prompt registry is closed");
7923
- value = prompt;
7924
- },
7925
- get: () => value
10186
+ foundation: Object.freeze({
10187
+ set: (text) => {
10188
+ writable();
10189
+ foundation = text;
10190
+ },
10191
+ get: () => foundation
10192
+ }),
10193
+ sections: Object.freeze({
10194
+ append: (section, entry) => {
10195
+ slot(section).entries.push(entry);
10196
+ },
10197
+ set: (section, entries) => {
10198
+ slot(section).entries = [...entries];
10199
+ },
10200
+ remove: (section) => {
10201
+ writable();
10202
+ sections.delete(section.name);
10203
+ },
10204
+ get: (section) => {
10205
+ const found = sections.get(section.name);
10206
+ return found === void 0 ? void 0 : Object.freeze([...found.entries]);
10207
+ }
10208
+ })
7926
10209
  }),
7927
10210
  close: () => {
7928
10211
  open = false;
7929
10212
  },
7930
- value: () => value
10213
+ snapshot: () => ({
10214
+ foundation,
10215
+ sections: new Map(Array.from(sections, ([name, { format, entries }]) => [name, {
10216
+ format,
10217
+ entries: [...entries]
10218
+ }]))
10219
+ })
10220
+ };
10221
+ };
10222
+ //#endregion
10223
+ //#region src/plugin/prompt/render.ts
10224
+ const tag = (name, body) => `<${name}>\n${body}\n</${name}>`;
10225
+ /** Two spellings of one list entry dedupe against each other. */
10226
+ const normalize = (value) => value.trim().replace(/\s+/g, " ");
10227
+ const body = ({ format, entries }) => {
10228
+ if (format === "text") {
10229
+ const kept = entries.filter((entry) => entry.trim().length > 0);
10230
+ return kept.length === 0 ? void 0 : kept.join("\n\n");
10231
+ }
10232
+ const seen = /* @__PURE__ */ new Set();
10233
+ for (const entry of entries) {
10234
+ const normalized = normalize(entry);
10235
+ if (normalized.length > 0) seen.add(normalized);
10236
+ }
10237
+ return seen.size === 0 ? void 0 : Array.from(seen, (line) => `- ${line}`).join("\n");
10238
+ };
10239
+ /** `(none)` rather than no section: a model told it has no tools behaves better than one left guessing. */
10240
+ const toolIndex = (tools) => {
10241
+ const listed = tools.filter((tool) => tool.promptSnippet !== void 0 && tool.promptSnippet.length > 0);
10242
+ if (listed.length === 0) return "(none)";
10243
+ return listed.map((tool) => `- ${tool.name}: ${tool.promptSnippet}`).join("\n");
10244
+ };
10245
+ const render = (input) => {
10246
+ const written = new Map(input.prompt.sections);
10247
+ const guidelines = input.tools.flatMap((tool) => tool.promptGuidelines ?? []);
10248
+ written.set(Section.Rules.name, {
10249
+ format: Section.Rules.format,
10250
+ entries: [...guidelines, ...written.get(Section.Rules.name)?.entries ?? []]
10251
+ });
10252
+ const parts = [input.prompt.foundation ?? "You are an expert coding assistant operating inside codework, a coding agent harness.", tag("tools", toolIndex(input.tools))];
10253
+ const push = (name) => {
10254
+ const section = written.get(name);
10255
+ const rendered = section === void 0 ? void 0 : body(section);
10256
+ if (rendered !== void 0) parts.push(tag(name, rendered));
10257
+ written.delete(name);
7931
10258
  };
10259
+ for (const section of Section.builtins) push(section.name);
10260
+ parts.push(tag("cwd", input.directory));
10261
+ for (const name of Array.from(written.keys())) push(name);
10262
+ return parts.join("\n\n");
7932
10263
  };
7933
10264
  //#endregion
7934
10265
  //#region src/plugin/tool/registry.ts
@@ -7954,6 +10285,8 @@ const assertTool = (tool) => {
7954
10285
  if (def.failure !== void 0 && !Schema.isSchema(def.failure)) throw new Error(`Tool ${name}: failure must be an Effect Schema`);
7955
10286
  if (def.encodeContent !== void 0 && !Predicate.isFunction(def.encodeContent)) throw new Error(`Tool ${name}: encodeContent must be a function`);
7956
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`);
7957
10290
  if (!Predicate.isFunction(tool.handler)) throw new Error(`Tool ${name}: handler must be a function`);
7958
10291
  };
7959
10292
  const assertHooks = (name, hooks) => {
@@ -8020,11 +10353,16 @@ const make = () => {
8020
10353
  prompt: prompt.registry
8021
10354
  }),
8022
10355
  close,
8023
- freeze: () => {
10356
+ freeze: (directory) => {
8024
10357
  close();
10358
+ const resolved = make$3(tools.entries()).resolve();
8025
10359
  return {
8026
- tools: make$3(tools.entries()).resolve(),
8027
- systemPrompt: prompt.value() ?? "You are an AI agent for CodeWork. CodeWork is the best agent harness for code & work."
10360
+ tools: resolved,
10361
+ systemPrompt: render({
10362
+ prompt: prompt.snapshot(),
10363
+ tools: resolved.defs,
10364
+ directory
10365
+ })
8028
10366
  };
8029
10367
  }
8030
10368
  };
@@ -8069,7 +10407,7 @@ const run = Effect.fn("PluginHost.run")(function* (selection, input) {
8069
10407
  }));
8070
10408
  }));
8071
10409
  return yield* Effect.try({
8072
- try: buckets.freeze,
10410
+ try: () => buckets.freeze(input.location.directory),
8073
10411
  catch: (cause) => new SetupError({
8074
10412
  message: `plugin snapshot freeze failed: ${cause instanceof Error ? cause.message : String(cause)}`,
8075
10413
  cause
@@ -8079,8 +10417,8 @@ const run = Effect.fn("PluginHost.run")(function* (selection, input) {
8079
10417
  });
8080
10418
  //#endregion
8081
10419
  //#region src/session/runtime.ts
8082
- var Service$3 = class extends Context.Service()("@codeworksh/harness/session/runtime/Service") {};
8083
- const layer$6 = Layer.effect(Service$3, Effect.gen(function* () {
10420
+ var Service$2 = class extends Context.Service()("@codeworksh/harness/session/runtime/Service") {};
10421
+ const layer$5 = Layer.effect(Service$2, Effect.gen(function* () {
8084
10422
  const bindings = yield* Ref.make(/* @__PURE__ */ new Map());
8085
10423
  const get = (sessionId) => Ref.get(bindings).pipe(Effect.map((entries) => Option.fromUndefinedOr(entries.get(sessionId))));
8086
10424
  const set = (sessionId, value) => Ref.update(bindings, (entries) => {
@@ -8101,7 +10439,7 @@ const layer$6 = Layer.effect(Service$3, Effect.gen(function* () {
8101
10439
  next.delete(sessionId);
8102
10440
  return next;
8103
10441
  });
8104
- return Service$3.of({
10442
+ return Service$2.of({
8105
10443
  get,
8106
10444
  set,
8107
10445
  update,
@@ -8109,202 +10447,6 @@ const layer$6 = Layer.effect(Service$3, Effect.gen(function* () {
8109
10447
  });
8110
10448
  }));
8111
10449
  //#endregion
8112
- //#region src/settings/settings.ts
8113
- var settings_exports = /* @__PURE__ */ __exportAll({
8114
- Service: () => Service$2,
8115
- Settings: () => settings_exports,
8116
- SettingsError: () => SettingsError,
8117
- defaults: () => schema_exports$3.defaults,
8118
- layer: () => layer$5,
8119
- load: () => load$1,
8120
- modules: () => modules,
8121
- parse: () => parse$1,
8122
- paths: () => paths,
8123
- projectRoot: () => projectRoot,
8124
- userConfigDir: () => userConfigDir
8125
- });
8126
- /**
8127
- * An app-level `--user-config-dir` as the absolute directory it names: `~` is the user's home, and
8128
- * a relative spelling is relative to `hostCwd` -- never to a session's `hostDir`, which is not
8129
- * where the flag was typed.
8130
- */
8131
- const userConfigDir = (raw, hostCwd) => hostPath.resolve(hostCwd, expandTilde(raw, hostPath));
8132
- /** The settings files a directory may hold, in the order it is searched. */
8133
- const settingsIn = (directory) => [hostPath.join(directory, "settings.jsonc"), hostPath.join(directory, "settings.json")];
8134
- /** A directory and every ancestor above it, nearest first. */
8135
- const ancestors = (from) => {
8136
- const chain = [];
8137
- for (let directory = from;; directory = hostPath.dirname(directory)) {
8138
- chain.push(directory);
8139
- if (hostPath.dirname(directory) === directory) return chain;
8140
- }
8141
- };
8142
- const isDirectory = (path) => fileSystem.stat(path).pipe(Effect.map((info) => info.type === "Directory"), Effect.orElseSucceed(() => false));
8143
- /**
8144
- * The project a directory is in: the nearest ancestor holding a `.codework` **directory**.
8145
- *
8146
- * This is git's rule. `~/workspace/app/.codework` makes `~/workspace/app` a project, and
8147
- * `~/workspace/app/packages/fubar` is inside it. Nearest wins outright -- the walk stops at the
8148
- * first marker, so an inner project is never merged with an outer one.
8149
- *
8150
- * The marker is the directory, not a file in it. An empty `.codework/` is still a project root,
8151
- * which is the point: creating the directory is how a person says "a project begins here", and it
8152
- * has to mean that before any settings exist. It also makes the question one `stat` per ancestor
8153
- * whose answer does not change as files come and go inside.
8154
- *
8155
- * `<home>` is skipped. `~/.codework` is the user layer, not a project; without this, running
8156
- * anywhere under `$HOME` would find it as one and read the user layer twice -- and since plugin
8157
- * entries accumulate across layers, every user plugin would load twice.
8158
- */
8159
- const projectRoot = Effect.fn("Settings.projectRoot")(function* (from, home) {
8160
- const userConfig = hostPath.resolve(home);
8161
- for (const directory of ancestors(hostPath.resolve(from))) {
8162
- const marker = hostPath.join(directory, appConfigDir);
8163
- if (hostPath.resolve(marker) === userConfig) continue;
8164
- if (yield* isDirectory(marker)) return directory;
8165
- }
8166
- });
8167
- /**
8168
- * Ordered layers, each a group of candidates where the first file that exists is selected -- a
8169
- * group never contributes more than one file.
8170
- *
8171
- * `.jsonc` is the canonical spelling: these files are written by hand and read as JSONC, so the
8172
- * extension says what the file is and an editor validates it as such. `.json` remains a fallback
8173
- * everywhere, tried second, because the parser reads either and a file already named that must
8174
- * keep working.
8175
- *
8176
- * `root` is an already-discovered project root ({@link projectRoot}), not a directory to search
8177
- * from: the walk is a `stat` per ancestor and belongs with the other I/O. `undefined` contributes
8178
- * no project layer at all, which is what a session with no host directory gets.
8179
- */
8180
- function paths(input) {
8181
- return [settingsIn(input.userConfigDir ?? input.home), input.root === void 0 ? [] : settingsIn(hostPath.join(input.root, appConfigDir))];
8182
- }
8183
- var SettingsError = class extends Schema.TaggedError()("SettingsError", {
8184
- path: Schema.String,
8185
- reason: Schema.Literals([
8186
- "read",
8187
- "parse",
8188
- "decode"
8189
- ]),
8190
- detail: Schema.String
8191
- }) {
8192
- get message() {
8193
- return `${this.path}: ${this.reason}: ${this.detail}`;
8194
- }
8195
- };
8196
- /** `line:column`, 1-based, for a parser offset into the source. */
8197
- const at = (source, offset) => {
8198
- const before = source.slice(0, offset).split("\n");
8199
- return `${before.length}:${(before.at(-1)?.length ?? 0) + 1}`;
8200
- };
8201
- const parse$1 = Effect.fn("Settings.parse")(function* (path, source) {
8202
- const errors = [];
8203
- const json = parse(source, errors, {
8204
- allowTrailingComma: true,
8205
- disallowComments: false
8206
- });
8207
- const first = errors[0];
8208
- if (first !== void 0) return yield* new SettingsError({
8209
- path,
8210
- reason: "parse",
8211
- detail: `${printParseErrorCode(first.error)} at ${at(source, first.offset)}`
8212
- });
8213
- const document = normalize(json);
8214
- if (Predicate.isObject(document) && Predicate.isObject(json) && "plugins" in json) Object.assign(document, { plugins: json.plugins });
8215
- return yield* Schema.decodeUnknownEffect(schema_exports$3.Patch)(document).pipe(Effect.mapError((error) => new SettingsError({
8216
- path,
8217
- reason: "decode",
8218
- detail: SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues.map((issue) => `${issue.path?.map((part) => typeof part === "object" ? String(part.key) : part).join(".") ?? "settings"}: invalid value`).join("; ")
8219
- })));
8220
- });
8221
- var Service$2 = class extends Context.Service()("@codeworksh/harness/settings/settings/Service") {};
8222
- /**
8223
- * Anchor relative plugin entries to the file that declared them.
8224
- *
8225
- * A reference is otherwise resolved against the host startup directory, which is right for
8226
- * a project file sitting in it and meaningless for `~/.codework/settings.json`, where
8227
- * `./plugins/x.ts` would name a different file in every project the process is started in.
8228
- * `file:` URLs, package specs and package names are left exactly as written, and a `plugin`
8229
- * key is an ID rather than a location. A configuration entry's `package` is anchored like a
8230
- * module entry, so a relative path names the same module in both spellings.
8231
- */
8232
- const anchored = (entry, file) => {
8233
- const directory = hostPath.dirname(file);
8234
- const resolve = (reference) => reference.startsWith("./") || reference.startsWith("../") ? hostPath.resolve(directory, reference) : reference;
8235
- if (typeof entry === "string") return resolve(entry);
8236
- return "package" in entry ? {
8237
- ...entry,
8238
- package: resolve(entry.package)
8239
- } : entry;
8240
- };
8241
- const anchor = (patch, file) => patch.plugins === void 0 ? patch : {
8242
- ...patch,
8243
- plugins: patch.plugins.map((entry) => anchored(entry, file))
8244
- };
8245
- const attempt = (path) => fileSystem.readFileString(path).pipe(Effect.mapError((error) => new SettingsError({
8246
- path,
8247
- reason: "read",
8248
- detail: error.reason._tag
8249
- })), Effect.flatMap((source) => parse$1(path, source)));
8250
- /**
8251
- * One read of every layer. Exported for the harness constructor, which needs the plugin
8252
- * selection before any layer is built; everything else goes through `Service`.
8253
- */
8254
- const load$1 = Effect.fn("Settings.load")(function* (options) {
8255
- const root = options.hostDir === void 0 ? void 0 : yield* projectRoot(options.hostDir, options.home);
8256
- const files = paths({
8257
- home: options.home,
8258
- root,
8259
- ...options.userConfigDir === void 0 ? {} : { userConfigDir: options.userConfigDir }
8260
- });
8261
- let settings = merge(schema_exports$3.defaults);
8262
- let declared = schema_exports$3.defaults.declared;
8263
- for (const group of files) for (const path of group) {
8264
- const result = yield* Effect.result(attempt(path));
8265
- if (Result.isSuccess(result)) {
8266
- const patch = anchor(result.success, path);
8267
- settings = merge(settings, patch);
8268
- if (result.success.plugins !== void 0) declared = [...declared, ...result.success.plugins.map((written) => ({
8269
- written,
8270
- entry: anchored(written, path),
8271
- file: path
8272
- }))];
8273
- break;
8274
- }
8275
- const error = result.failure;
8276
- if (error.reason === "read" && error.detail === "NotFound") continue;
8277
- return yield* error;
8278
- }
8279
- return {
8280
- ...settings,
8281
- plugins: declared.map((one) => one.entry),
8282
- declared
8283
- };
8284
- });
8285
- /**
8286
- * Each module reference once, owned by the **last** file to declare it (D6).
8287
- *
8288
- * The owner is not bookkeeping: its `.npmrc` chain keys the store artifact. The CLI that installs
8289
- * a reference and the runtime that loads it must pick the same owner, or `plugin update` refreshes
8290
- * an artifact the runtime never reads. Only a string entry loads a module, so only a string entry
8291
- * owns one -- a `{ package }` patch in another file configures it and must not move its registry.
8292
- * A reference keeps the position of its first declaration.
8293
- */
8294
- const modules = (declared) => {
8295
- const owners = /* @__PURE__ */ new Map();
8296
- for (const one of declared) if (typeof one.entry === "string") owners.set(one.entry, one);
8297
- return owners;
8298
- };
8299
- const layer$5 = (options = {}) => Layer.effect(Service$2, Effect.gen(function* () {
8300
- const global = yield* Service$17;
8301
- return Service$2.of({ load: (hostDir) => load$1({
8302
- ...options,
8303
- home: global.home,
8304
- hostDir
8305
- }) });
8306
- }));
8307
- //#endregion
8308
10450
  //#region src/state/state.ts
8309
10451
  var state_exports = /* @__PURE__ */ __exportAll({
8310
10452
  Service: () => Service$1,
@@ -8354,12 +10496,13 @@ const resolver = (input) => typeof input === "string" ? () => input : input;
8354
10496
  */
8355
10497
  const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effect.undefined) => {
8356
10498
  return Layer.effect(Service$1, Effect.gen(function* () {
8357
- const runtime = yield* Service$3;
10499
+ const runtime = yield* Service$2;
8358
10500
  const sessions = yield* Service$14;
8359
- const settings = yield* Service$2;
10501
+ const settings = yield* Service$3;
8360
10502
  const eventRegistry = yield* Service$4;
8361
10503
  const eventService = yield* Service$16;
8362
10504
  const events = makeEvents(eventService);
10505
+ const global = yield* Service$17;
8363
10506
  const loading = yield* Semaphore.make(1);
8364
10507
  const updated = (origin, id, status) => eventService.publish(PluginUpdated, {
8365
10508
  status,
@@ -8409,6 +10552,38 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
8409
10552
  ..."reference" in cause && typeof cause.reference === "string" ? { reference: cause.reference } : {},
8410
10553
  ...sessionId === void 0 ? {} : { sessionId }
8411
10554
  });
10555
+ /** Settings and composed configuration for a session's next exchange. */
10556
+ const configure = Effect.fnUntraced(function* (sessionId) {
10557
+ const session = Option.getOrUndefined(yield* sessions.get(sessionId));
10558
+ const hostDir = session === void 0 ? void 0 : Option.getOrUndefined(session.hostDir);
10559
+ const chosen = session === void 0 ? {} : Option.getOrElse(session.config, () => ({}));
10560
+ const sessionOptions = {
10561
+ ...chosen.model === void 0 ? {} : {
10562
+ provider: chosen.model.provider,
10563
+ model: chosen.model.id
10564
+ },
10565
+ ...chosen.thinkingLevel === void 0 ? {} : { thinkingLevel: chosen.thinkingLevel },
10566
+ ...Option.getOrElse(yield* runtime.get(sessionId), () => ({}))
10567
+ };
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);
10577
+ return {
10578
+ hostDir,
10579
+ loadedSettings,
10580
+ configured: {
10581
+ ...composed,
10582
+ resolvedModel,
10583
+ thinkingLevel
10584
+ }
10585
+ };
10586
+ });
8412
10587
  return Service$1.of({
8413
10588
  reload: Effect.gen(function* () {
8414
10589
  const failures = /* @__PURE__ */ new Map();
@@ -8427,22 +10602,21 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
8427
10602
  failure: first
8428
10603
  };
8429
10604
  }),
10605
+ configuration: Effect.fn("State.configuration")(function* (sessionId) {
10606
+ const { configured } = yield* configure(sessionId);
10607
+ return {
10608
+ provider: configured.provider,
10609
+ model: configured.model,
10610
+ thinkingLevel: configured.thinkingLevel
10611
+ };
10612
+ }),
8430
10613
  snapshot: Effect.fn("State.snapshot")(function* (sessionId) {
8431
- const sessionOptions = Option.getOrElse(yield* runtime.get(sessionId), () => ({}));
8432
- const session = yield* sessions.get(sessionId);
8433
- const hostDir = Option.isNone(session) ? void 0 : Option.getOrUndefined(session.value.hostDir);
8434
- const loadedSettings = yield* settings.load(hostDir);
8435
- const configured = compose(loadedSettings, options, sessionOptions);
10614
+ const { hostDir, loadedSettings, configured } = yield* configure(sessionId);
8436
10615
  const { promptCustom, promptSystemAppend, provider: _provider, model: _model, thinkingLevel: _thinkingLevel, toolExecution: _toolExecution, ...rest } = configured.runtime;
8437
- const { provider, model, thinkingLevel, toolExecution } = configured;
10616
+ const { provider, model, resolvedModel, thinkingLevel, toolExecution } = configured;
8438
10617
  const request = merge(resolveOptions(configured.block), rest);
8439
10618
  const sandbox = yield* io_exports.SandboxIO.Current;
8440
10619
  const location = yield* Service$8;
8441
- const resolvedModel = yield* resolve$1({
8442
- provider,
8443
- model,
8444
- settings: configured.block
8445
- });
8446
10620
  const refs = references(loadedSettings);
8447
10621
  const failed = (cause) => new SnapshotError({
8448
10622
  sessionId,
@@ -8460,7 +10634,15 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
8460
10634
  const chosen = yield* select(refs, loaded).pipe(Effect.mapError(failed));
8461
10635
  for (const reference of chosen.missing) {
8462
10636
  const where = loadedSettings.declared.find((one) => typeof one.entry === "string" ? one.entry === reference : "package" in one.entry && one.entry.package === reference);
8463
- yield* Effect.logWarning(`plugin ${reference} is configured but not loaded — run \`codework plugin install\`${where === void 0 ? "" : ` (declared in ${where.file})`}`);
10637
+ const error = `plugin ${reference} is configured but not loaded — run \`codework plugin install\`${where === void 0 ? "" : ` (declared in ${where.file})`}`;
10638
+ yield* Effect.logWarning(error);
10639
+ yield* eventService.publish(PluginUpdated, {
10640
+ status: "failed",
10641
+ reference,
10642
+ error,
10643
+ ...where === void 0 ? {} : { file: where.file },
10644
+ sessionId
10645
+ });
8464
10646
  }
8465
10647
  return {
8466
10648
  sessionId,
@@ -8469,6 +10651,7 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
8469
10651
  ...yield* run(chosen.selection, {
8470
10652
  sessionId,
8471
10653
  sandbox,
10654
+ paths: global,
8472
10655
  location,
8473
10656
  settings: loadedSettings,
8474
10657
  model: resolvedModel,
@@ -8488,13 +10671,34 @@ const layer$4 = (options, pool, references, follow, rebuild, viewOf = () => Effe
8488
10671
  thinkingLevel,
8489
10672
  request,
8490
10673
  settings: configured.block,
8491
- toolExecution
10674
+ toolExecution,
10675
+ retry: loadedSettings.retry
8492
10676
  };
8493
10677
  })
8494
10678
  });
8495
10679
  }));
8496
10680
  };
8497
10681
  //#endregion
10682
+ //#region src/runner/retry.ts
10683
+ /**
10684
+ * Milliseconds to wait before retry `attempt` (1-based), or `undefined` to give up.
10685
+ *
10686
+ * Only a provider failure the provider itself calls transient is retried. A quota error is
10687
+ * excluded even though it often arrives as a retryable 429: waiting does not refill a quota.
10688
+ * Context overflow is a non-retryable 400 and is left to compaction.
10689
+ *
10690
+ * A server-sent `retry-after` raises the delay, never lowers it. One longer than `maxDelayMs`
10691
+ * gives up rather than retrying early into the same rejection.
10692
+ */
10693
+ const delay = (policy, attempt, error) => {
10694
+ if (attempt > policy.maxRetries) return void 0;
10695
+ if (!Schema.is(ProviderError)(error) || !error.isRetryable) return void 0;
10696
+ if (error.reason._tag === "Runner.ProviderQuotaError") return void 0;
10697
+ const backoff = Math.min(policy.baseDelayMs * 2 ** (attempt - 1), policy.maxDelayMs);
10698
+ const requested = error.reason.retryAfter === void 0 ? 0 : Duration.toMillis(error.reason.retryAfter);
10699
+ return requested > policy.maxDelayMs ? void 0 : Math.max(backoff, Math.ceil(requested));
10700
+ };
10701
+ //#endregion
8498
10702
  //#region src/runner/loop.ts
8499
10703
  const terminalResult = (text) => ({
8500
10704
  content: [{
@@ -8614,7 +10818,9 @@ const layer$3 = (options = {}) => Layer.effect(Service$7, Effect.gen(function* (
8614
10818
  sessionId: snapshot.sessionId,
8615
10819
  messageId,
8616
10820
  callID: call.callID,
8617
- name: call.name
10821
+ name: call.name,
10822
+ label: snapshot.tools.defs.find((def) => def.name === call.name)?.label,
10823
+ arguments: call.arguments
8618
10824
  });
8619
10825
  const handled = yield* snapshot.tools.handle(call, {
8620
10826
  sessionId: snapshot.sessionId,
@@ -8661,11 +10867,14 @@ const layer$3 = (options = {}) => Layer.effect(Service$7, Effect.gen(function* (
8661
10867
  if (leaf !== void 0 && (leaf.stopReason === "stop" || leaf.stopReason === "length")) return { needsContinuation: false };
8662
10868
  let publisher;
8663
10869
  let committed = false;
8664
- return yield* Effect.gen(function* () {
8665
- yield* events.publish(TurnStarted, {
8666
- timestamp: yield* DateTime.now,
8667
- sessionId
8668
- });
10870
+ let retries = 0;
10871
+ /** Abort the latest attempt's draft, unless its own terminal event already did. */
10872
+ const settleDraft = (cause) => Effect.gen(function* () {
10873
+ if (publisher?.startedMessageId === void 0) return;
10874
+ const stored = yield* sessions.entry(publisher.startedMessageId);
10875
+ if (Option.isSome(stored) && stored.value.entry.state === "draft") yield* publishFailure(stored.value, cause);
10876
+ });
10877
+ const attempt = Effect.gen(function* () {
8669
10878
  publisher = yield* make$6({ sessionId }).pipe(Effect.provideService(Service$16, events));
8670
10879
  const terminal = yield* requestLLM({
8671
10880
  sessionId,
@@ -8689,6 +10898,43 @@ const layer$3 = (options = {}) => Layer.effect(Service$7, Effect.gen(function* (
8689
10898
  model: snapshot.model
8690
10899
  }, messageFailure(terminal.message));
8691
10900
  }
10901
+ return terminal;
10902
+ });
10903
+ const request = () => attempt.pipe(Effect.catch((error) => {
10904
+ const delayMs = delay(snapshot.retry, retries + 1, error);
10905
+ if (delayMs === void 0) return Effect.fail(error);
10906
+ return Effect.gen(function* () {
10907
+ retries += 1;
10908
+ yield* Effect.uninterruptible(settleDraft({
10909
+ _tag: "error",
10910
+ message: error.message
10911
+ }));
10912
+ yield* events.publish(RetryScheduled, {
10913
+ timestamp: yield* DateTime.now,
10914
+ sessionId,
10915
+ attempt: retries,
10916
+ maxRetries: snapshot.retry.maxRetries,
10917
+ delayMs,
10918
+ message: error.message
10919
+ });
10920
+ yield* Effect.sleep(Duration.millis(delayMs));
10921
+ return yield* request();
10922
+ });
10923
+ }));
10924
+ return yield* Effect.gen(function* () {
10925
+ yield* events.publish(TurnStarted, {
10926
+ timestamp: yield* DateTime.now,
10927
+ sessionId
10928
+ });
10929
+ const terminal = yield* request().pipe(Effect.onExit((exit) => retries === 0 ? Effect.void : Effect.gen(function* () {
10930
+ yield* events.publish(RetryFinished, {
10931
+ timestamp: yield* DateTime.now,
10932
+ sessionId,
10933
+ attempt: retries,
10934
+ success: Exit.isSuccess(exit),
10935
+ ...Exit.isSuccess(exit) ? {} : { message: Cause.hasInterrupts(exit.cause) ? "interrupted" : errorMessage(exit.cause) }
10936
+ });
10937
+ })));
8692
10938
  const interrupted = yield* settleTools(snapshot, terminal.message, terminal.reason, () => {
8693
10939
  committed = true;
8694
10940
  });
@@ -8697,24 +10943,20 @@ const layer$3 = (options = {}) => Layer.effect(Service$7, Effect.gen(function* (
8697
10943
  needsContinuation: terminal.reason === "toolUse",
8698
10944
  messageId: terminal.message.messageId
8699
10945
  };
8700
- }).pipe(Effect.catchCause((cause) => {
8701
- if (committed) return Effect.failCause(cause);
10946
+ }).pipe(Effect.onError((cause) => {
10947
+ if (committed) return Effect.void;
8702
10948
  const turnCause = Cause.hasInterrupts(cause) ? { _tag: "interrupted" } : {
8703
10949
  _tag: "error",
8704
10950
  message: errorMessage(cause)
8705
10951
  };
8706
- const record = Effect.gen(function* () {
8707
- if (publisher?.startedMessageId !== void 0) {
8708
- const stored = yield* sessions.entry(publisher.startedMessageId);
8709
- if (Option.isSome(stored) && stored.value.entry.state === "draft") yield* publishFailure(stored.value, turnCause);
8710
- }
10952
+ return Effect.gen(function* () {
10953
+ yield* settleDraft(turnCause);
8711
10954
  yield* events.publish(TurnAborted, {
8712
10955
  timestamp: yield* DateTime.now,
8713
10956
  sessionId,
8714
10957
  cause: turnCause
8715
10958
  });
8716
10959
  });
8717
- return Effect.uninterruptible(record).pipe(Effect.andThen(Effect.failCause(cause)));
8718
10960
  }));
8719
10961
  });
8720
10962
  const runTurn = Effect.fn("Loop.runTurn")(function* (promotion, snapshot) {
@@ -8868,10 +11110,7 @@ const layer$2 = Layer.effectDiscard(Effect.gen(function* () {
8868
11110
  messageId: event.data.messageId,
8869
11111
  role: "user",
8870
11112
  time: { created: DateTime.toEpochMillis(event.data.timestamp) },
8871
- parts: [{
8872
- type: "text",
8873
- text: event.data.prompt.text
8874
- }]
11113
+ parts: [...event.data.prompt.parts]
8875
11114
  })).pipe(Effect.orDie);
8876
11115
  yield* sessions.append({
8877
11116
  id: event.data.messageId,
@@ -8881,7 +11120,13 @@ const layer$2 = Layer.effectDiscard(Effect.gen(function* () {
8881
11120
  ...encoded,
8882
11121
  ...event.metadata === void 0 ? {} : { metadata: event.metadata }
8883
11122
  }).pipe(Effect.orDie);
11123
+ const title = titleFrom(Prompt.text(event.data.prompt));
11124
+ if (title !== void 0) yield* sessions.retitle(event.data.sessionId, title);
8884
11125
  }));
11126
+ yield* events.project(ConfigChanged, (event) => {
11127
+ const { sessionId, timestamp: _timestamp, ...config } = event.data;
11128
+ return sessions.configure(sessionId, config);
11129
+ });
8885
11130
  yield* events.project(LLMStarted, (event) => Effect.gen(function* () {
8886
11131
  if (event.durable === void 0) return yield* Effect.die("LLMStarted is missing its aggregate sequence");
8887
11132
  const encoded = yield* encodeAssistant({
@@ -8949,10 +11194,10 @@ var harness_exports = /* @__PURE__ */ __exportAll({
8949
11194
  });
8950
11195
  const layer = (options = {}) => Layer.unwrap(Effect.gen(function* () {
8951
11196
  const hostCwd = options.hostCwd ?? process.cwd();
8952
- const paths = yield* resolve$3(options.home === void 0 ? {} : { home: hostPath.resolve(hostCwd, expandTilde(options.home, hostPath)) });
8953
- const global = layerWith$1(paths);
11197
+ const paths = yield* resolve$4(options.home === void 0 ? {} : { home: hostPath.resolve(hostCwd, expandTilde(options.home, hostPath)) });
11198
+ const global = layerWith(paths);
8954
11199
  const settingsOptions = options.userConfigDir === void 0 ? {} : { userConfigDir: userConfigDir(options.userConfigDir, hostCwd) };
8955
- const config = yield* load$1({
11200
+ const config = yield* load$3({
8956
11201
  ...settingsOptions,
8957
11202
  home: paths.home
8958
11203
  });
@@ -8969,12 +11214,12 @@ const layer = (options = {}) => Layer.unwrap(Effect.gen(function* () {
8969
11214
  * `hostDir` led to; only the process view falls back to `hostCwd`.
8970
11215
  */
8971
11216
  const hostDirOf = (root) => root ?? hostCwd;
8972
- const filed = (reference, from) => parse$2(reference, from).pipe(Effect.flatMap((target) => target.kind === "local" ? fileSystem.exists(target.path).pipe(Effect.map((there) => there ? Option.some({}) : Option.none())) : resolve$2(target, paths.cache, from).pipe(Effect.map((entry) => Option.fromUndefinedOr(entry)))), Effect.orElseSucceed(() => Option.none()));
11217
+ const filed = (reference, from) => parse$1(reference, from).pipe(Effect.flatMap((target) => target.kind === "local" ? fileSystem.exists(target.path).pipe(Effect.map((there) => there ? Option.some({}) : Option.none())) : resolve$2(target, paths.cache, from).pipe(Effect.map((entry) => Option.fromUndefinedOr(entry)))), Effect.orElseSucceed(() => Option.none()));
8973
11218
  const resolveOnly = {
8974
11219
  ...catalogOptions,
8975
11220
  install: required
8976
11221
  };
8977
- const pool = yield* Ref.make(yield* load$2(references(config), {
11222
+ const pool = yield* Ref.make(yield* load$1(references(config), {
8978
11223
  ...resolveOnly,
8979
11224
  declared: declaredIn(config)
8980
11225
  }));
@@ -8988,7 +11233,7 @@ const layer = (options = {}) => Layer.unwrap(Effect.gen(function* () {
8988
11233
  let reloads = 0;
8989
11234
  const rebuild = (root, loaded) => Effect.gen(function* () {
8990
11235
  reloads += 1;
8991
- const current = yield* load$1({
11236
+ const current = yield* load$3({
8992
11237
  ...settingsOptions,
8993
11238
  home: paths.home,
8994
11239
  ...root === void 0 ? {} : { hostDir: root }
@@ -8996,7 +11241,7 @@ const layer = (options = {}) => Layer.unwrap(Effect.gen(function* () {
8996
11241
  const retained = [];
8997
11242
  const configured = references(current);
8998
11243
  for (const origin of loaded.origins.values()) {
8999
- if (configured.includes(origin.reference) || Option.isSome(yield* filed(origin.reference, anchor$1(origin.file, hostDirOf(root))))) {
11244
+ if (configured.includes(origin.reference) || Option.isSome(yield* filed(origin.reference, anchor(origin.file, hostDirOf(root))))) {
9000
11245
  retained.push(origin);
9001
11246
  continue;
9002
11247
  }
@@ -9006,7 +11251,7 @@ const layer = (options = {}) => Layer.unwrap(Effect.gen(function* () {
9006
11251
  const known = /* @__PURE__ */ new Map();
9007
11252
  for (const origin of retained) if (origin.file !== void 0) known.set(origin.reference, origin.file);
9008
11253
  for (const [reference, file] of declaredIn(current)) known.set(reference, file);
9009
- return yield* load$2(accumulated, {
11254
+ return yield* load$1(accumulated, {
9010
11255
  ...resolveOnly,
9011
11256
  hostDir: hostDirOf(root),
9012
11257
  declared: known,
@@ -9021,10 +11266,10 @@ const layer = (options = {}) => Layer.unwrap(Effect.gen(function* () {
9021
11266
  const authFile$1 = authFile(options.home === void 0 ? void 0 : paths.home);
9022
11267
  const open = options.llm ?? openWith(authFile$1 === void 0 ? {} : { authFile: authFile$1 });
9023
11268
  const loop = layer$3({ request: make$5(open) });
9024
- return layer$10.pipe(Layer.provideMerge(layer$7.pipe(Layer.provide(loop))), Layer.provideMerge(layer$4({}, pool, references, followStore, rebuild, viewOf)), Layer.provideMerge(layer$5(settingsOptions)), Layer.provideMerge(layer$6), Layer.provideMerge(sandboxes), Layer.provideMerge(layer$22), Layer.provideMerge(layer$1), Layer.provideMerge(layer$25), Layer.provideMerge(layer$9(definitions)), Layer.provideMerge(database), Layer.provideMerge(global), Layer.provideMerge(layer$8({
11269
+ return layer$10.pipe(Layer.provideMerge(layer$6.pipe(Layer.provide(loop))), Layer.provideMerge(layer$4({}, pool, references, followStore, rebuild, viewOf)), Layer.provideMerge(layer$8(settingsOptions)), Layer.provideMerge(layer$5), Layer.provideMerge(sandboxes), Layer.provideMerge(layer$22), Layer.provideMerge(layer$1), Layer.provideMerge(layer$25), Layer.provideMerge(layer$9(definitions)), Layer.provideMerge(database), Layer.provideMerge(global), Layer.provideMerge(layer$7({
9025
11270
  home: paths.home,
9026
11271
  watch: options.watchModels ?? false
9027
- })));
11272
+ })), Layer.provideMerge(Layer.succeed(Credentials, { authFile: authFile$1 })));
9028
11273
  }));
9029
11274
  //#endregion
9030
11275
  //#region src/effect/sandbox.ts
@@ -9151,30 +11396,54 @@ var session_exports = /* @__PURE__ */ __exportAll({
9151
11396
  SessionMessageSchema: () => schema_exports$4,
9152
11397
  SessionSchema: () => schema_exports$8,
9153
11398
  attach: () => attach,
11399
+ configuration: () => configuration,
9154
11400
  create: () => create$1,
9155
11401
  get: () => get,
9156
11402
  link: () => link,
9157
11403
  relink: () => relink
9158
11404
  });
11405
+ /** What stays in this process. The model and thinking level are the session's {@link chosen} config. */
9159
11406
  const runtimeBindings = (input) => ({
9160
11407
  ...input.model?.options,
9161
- ...input.model === void 0 ? {} : {
9162
- provider: input.model.provider,
9163
- model: input.model.id
9164
- },
9165
- ...input.thinkingLevel === void 0 ? {} : { thinkingLevel: input.thinkingLevel },
9166
11408
  ...input.tools?.execution === void 0 ? {} : { toolExecution: input.tools.execution },
9167
11409
  ...input.systemPrompt?.custom === void 0 ? {} : { promptCustom: input.systemPrompt.custom },
9168
11410
  ...input.systemPrompt?.append === void 0 ? {} : { promptSystemAppend: input.systemPrompt.append }
9169
11411
  });
9170
- const promptInput = (input) => {
11412
+ /** The keys of the session's durable config this input names. */
11413
+ const chosen = (input) => ({
11414
+ ...input.model === void 0 ? {} : { model: {
11415
+ provider: input.model.provider,
11416
+ id: input.model.id
11417
+ } },
11418
+ ...input.thinkingLevel === void 0 ? {} : { thinkingLevel: input.thinkingLevel }
11419
+ });
11420
+ /** Records a config choice; the projector merges it into the session row in the same commit. */
11421
+ const choose = Effect.fn("Session.choose")(function* (sessionId, input) {
11422
+ const config = chosen(input);
11423
+ if (Object.keys(config).length === 0) return;
11424
+ yield* (yield* Service$16).publish(ConfigChanged, {
11425
+ sessionId,
11426
+ timestamp: yield* DateTime.now,
11427
+ ...config
11428
+ });
11429
+ });
11430
+ const promptInput = Effect.fnUntraced(function* (input) {
9171
11431
  const value = typeof input === "string" ? { text: input } : input;
11432
+ const parts = "parts" in value ? value.parts : [{
11433
+ type: "text",
11434
+ text: value.text
11435
+ }];
11436
+ if (parts.length === 0) return yield* new InvalidPromptError({ reason: "A prompt needs at least one part" });
11437
+ for (const part of parts) {
11438
+ const problem = part.type === "image" ? imageProblem(part) : void 0;
11439
+ if (problem !== void 0) return yield* new InvalidPromptError({ reason: problem });
11440
+ }
9172
11441
  return {
9173
- prompt: Prompt.make({ text: value.text }),
11442
+ prompt: Prompt.make({ parts }),
9174
11443
  ...value.delivery === void 0 ? {} : { delivery: value.delivery },
9175
11444
  ...value.id === void 0 ? {} : { id: value.id }
9176
11445
  };
9177
- };
11446
+ });
9178
11447
  const makeHandle = Effect.fn("Session.makeHandle")(function* (id) {
9179
11448
  const sessions = yield* Service$14;
9180
11449
  const control = yield* Service$5;
@@ -9200,14 +11469,14 @@ const makeHandle = Effect.fn("Session.makeHandle")(function* (id) {
9200
11469
  id,
9201
11470
  active: control.active.pipe(Effect.map((active) => active.has(id))),
9202
11471
  info,
9203
- prompt: (input) => control.prompt({
11472
+ prompt: (input) => promptInput(input).pipe(Effect.flatMap((prompt) => control.prompt({
9204
11473
  sessionId: id,
9205
- ...promptInput(input)
9206
- }),
9207
- run: (input) => control.run({
11474
+ ...prompt
11475
+ }))),
11476
+ run: (input) => promptInput(input).pipe(Effect.flatMap((prompt) => control.run({
9208
11477
  sessionId: id,
9209
- ...promptInput(input)
9210
- }),
11478
+ ...prompt
11479
+ }))),
9211
11480
  wait: () => control.wait(id),
9212
11481
  resume: () => control.resume(id),
9213
11482
  interrupt: () => control.interrupt(id, { awaitSettlement: true }),
@@ -9217,7 +11486,7 @@ const makeHandle = Effect.fn("Session.makeHandle")(function* (id) {
9217
11486
  });
9218
11487
  const create$1 = Effect.fn("Session.create")(function* (input = {}) {
9219
11488
  const sessions = yield* Service$14;
9220
- const runtime = yield* Service$3;
11489
+ const runtime = yield* Service$2;
9221
11490
  const sandboxes = yield* Controller;
9222
11491
  const id = ID$1.create();
9223
11492
  const sandboxId = input.sandbox?.id ?? instance_exports.SandboxInstance.ID.local;
@@ -9226,11 +11495,11 @@ const create$1 = Effect.fn("Session.create")(function* (input = {}) {
9226
11495
  id,
9227
11496
  spaceId: location.space.id,
9228
11497
  directory: location.directory,
9229
- slug: id,
9230
11498
  title: input.title ?? "Session",
9231
11499
  ...input.hostDir === void 0 ? {} : { hostDir: declaredHostDir(input.hostDir) }
9232
11500
  });
9233
11501
  yield* runtime.set(id, runtimeBindings(input));
11502
+ yield* choose(id, input);
9234
11503
  return yield* makeHandle(id);
9235
11504
  });
9236
11505
  /**
@@ -9296,9 +11565,20 @@ const get = Effect.fn("Session.get")(function* (sessionId) {
9296
11565
  const attach = Effect.fn("Session.attach")(function* (input) {
9297
11566
  const found = yield* get(input.sessionId);
9298
11567
  if (Option.isNone(found)) return yield* new SessionNotFoundError({ sessionId: input.sessionId });
9299
- yield* (yield* Service$3).update(input.sessionId, runtimeBindings(input));
11568
+ yield* (yield* Service$2).update(input.sessionId, runtimeBindings(input));
11569
+ yield* choose(input.sessionId, input);
9300
11570
  return found.value;
9301
11571
  });
11572
+ /**
11573
+ * The model and thinking level the session's next exchange runs with. A choice made through
11574
+ * {@link create} or {@link attach} is saved with the session and outlives the process; without
11575
+ * one, the session follows its settings.
11576
+ */
11577
+ const configuration = Effect.fn("Session.configuration")(function* (sessionId) {
11578
+ const sessions = yield* Service$14;
11579
+ if (Option.isNone(yield* sessions.get(sessionId))) return yield* new SessionNotFoundError({ sessionId });
11580
+ return yield* Service$1.use((state) => state.configuration(sessionId));
11581
+ });
9302
11582
  //#endregion
9303
11583
  //#region src/plugin/prompt/schema.ts
9304
11584
  var schema_exports$1 = /* @__PURE__ */ __exportAll({});
@@ -9310,11 +11590,12 @@ var plugin_exports = /* @__PURE__ */ __exportAll({
9310
11590
  InstallError: () => InstallError,
9311
11591
  LoadError: () => LoadError,
9312
11592
  Prompt: () => schema_exports$1,
11593
+ Section: () => Section,
9313
11594
  SetupError: () => SetupError,
9314
11595
  SourceError: () => SourceError,
9315
11596
  StoreError: () => StoreError,
9316
11597
  Tool: () => schema_exports$2,
9317
- anchor: () => anchor$1,
11598
+ anchor: () => anchor,
9318
11599
  canonical: () => canonical,
9319
11600
  check: () => check,
9320
11601
  define: () => plugin_exports$1.define,
@@ -9322,7 +11603,7 @@ var plugin_exports = /* @__PURE__ */ __exportAll({
9322
11603
  identity: () => digest,
9323
11604
  inspect: () => inspect,
9324
11605
  localName: () => localName,
9325
- parse: () => parse$2,
11606
+ parse: () => parse$1,
9326
11607
  probe: () => probe,
9327
11608
  resolve: () => resolve$2,
9328
11609
  resolveCached: () => required,