@codeworksh/harness 0.0.1-dev.20260922184400 → 0.0.1-dev.20260923102526

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
@@ -19,6 +19,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
19
19
  import { Buffer as Buffer$1 } from "node:buffer";
20
20
  import { Bash } from "just-bash";
21
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";
22
+ import { generateModels } from "@codeworksh/aikit/modelgen";
22
23
  import { fileURLToPath, pathToFileURL } from "node:url";
23
24
  import path from "node:path";
24
25
  import { Script, constants } from "node:vm";
@@ -102,7 +103,7 @@ var global_exports = /* @__PURE__ */ __exportAll({
102
103
  authFile: () => authFile,
103
104
  defaultLayer: () => defaultLayer$4,
104
105
  homeConfig: () => homeConfig,
105
- layer: () => layer$26,
106
+ layer: () => layer$27,
106
107
  layerWith: () => layerWith$1,
107
108
  make: () => make$18,
108
109
  resolve: () => resolve$3
@@ -148,8 +149,8 @@ const build = (input) => Effect.gen(function* () {
148
149
  ]).pipe(Effect.orDie);
149
150
  return Service$17.of(paths);
150
151
  });
151
- const layer$26 = Layer.effect(Service$17, build({}));
152
- const defaultLayer$4 = layer$26;
152
+ const layer$27 = Layer.effect(Service$17, build({}));
153
+ const defaultLayer$4 = layer$27;
153
154
  const layerWith$1 = (input) => Layer.effect(Service$17, build(input));
154
155
  //#endregion
155
156
  //#region src/db/migrations.ts
@@ -337,7 +338,7 @@ var db_exports = /* @__PURE__ */ __exportAll({
337
338
  SqlClient: () => SqlClient$1,
338
339
  SqlSchema: () => SqlSchema$1,
339
340
  defaultLayer: () => defaultLayer$3,
340
- layer: () => layer$25,
341
+ layer: () => layer$26,
341
342
  locationConfig: () => locationConfig,
342
343
  path: () => path$1,
343
344
  resolveDatabaseLocation: () => resolveDatabaseLocation
@@ -351,7 +352,7 @@ const setup = Effect.gen(function* () {
351
352
  yield* sql`PRAGMA foreign_keys = ON`;
352
353
  yield* SqliteMigrator.run({ loader: Migrator.fromRecord(migrations) });
353
354
  });
354
- function layer$25(location) {
355
+ function layer$26(location) {
355
356
  return Layer.unwrap(Effect.gen(function* () {
356
357
  if (location !== ":memory:") yield* fileSystem.makeDirectory(posix.dirname(location), { recursive: true }).pipe(Effect.orDie);
357
358
  return SqliteClient.layer({
@@ -370,7 +371,7 @@ const path$1 = Effect.fn("Database.path")(function* (data) {
370
371
  const directory = data ?? (yield* resolve$3()).data;
371
372
  return resolveDatabaseLocation(yield* locationConfig, directory);
372
373
  });
373
- const defaultLayer$3 = Layer.unwrap(path$1().pipe(Effect.map(layer$25)));
374
+ const defaultLayer$3 = Layer.unwrap(path$1().pipe(Effect.map(layer$26)));
374
375
  //#endregion
375
376
  //#region src/event/schema.ts
376
377
  var schema_exports$7 = /* @__PURE__ */ __exportAll({});
@@ -549,7 +550,7 @@ var SessionEntryPartRow = class extends Model.Class("SessionEntryPartRow")({
549
550
  /**
550
551
  * The public description of a failed execution.
551
552
  *
552
- * Open by design: `type` is a namespaced category string, not a closed union.
553
+ * Open by design: `type` is a kebab-case category ending in `-error`, not a closed union.
553
554
  * A client older than the server degrades to the message instead of failing to
554
555
  * decode, and a new category is an additive change. The typed cause stays
555
556
  * server-side -- this is what crosses the wire.
@@ -562,40 +563,40 @@ const Error$1 = Schema.Struct({
562
563
  });
563
564
  const providerCategory = (tag) => {
564
565
  switch (tag) {
565
- case "Runner.ProviderAuthenticationError": return "provider.auth";
566
- case "Runner.ProviderAuthorizationError": return "provider.forbidden";
567
- case "Runner.ProviderConfigurationError": return "provider.config";
568
- case "Runner.ProviderModelUnavailableError": return "provider.model-unavailable";
569
- case "Runner.ProviderRateLimitError": return "provider.rate-limit";
570
- case "Runner.ProviderQuotaError": return "provider.quota";
571
- case "Runner.ProviderInvalidRequestError": return "provider.invalid-request";
572
- case "Runner.ProviderContentPolicyError": return "provider.content-filter";
573
- case "Runner.ProviderTimeoutError": return "provider.timeout";
574
- case "Runner.ProviderTransportError": return "provider.transport";
575
- case "Runner.ProviderUnavailableError": return "provider.unavailable";
576
- case "Runner.ProviderInvalidResponseError": return "provider.invalid-output";
577
- default: return "provider.unknown";
566
+ case "Runner.ProviderAuthenticationError": return "provider-authentication-error";
567
+ case "Runner.ProviderAuthorizationError": return "provider-authorization-error";
568
+ case "Runner.ProviderConfigurationError": return "provider-configuration-error";
569
+ case "Runner.ProviderModelUnavailableError": return "provider-model-unavailable-error";
570
+ case "Runner.ProviderRateLimitError": return "provider-rate-limit-error";
571
+ case "Runner.ProviderQuotaError": return "provider-quota-error";
572
+ case "Runner.ProviderInvalidRequestError": return "provider-invalid-request-error";
573
+ case "Runner.ProviderContentPolicyError": return "provider-content-filter-error";
574
+ case "Runner.ProviderTimeoutError": return "provider-timeout-error";
575
+ case "Runner.ProviderTransportError": return "provider-transport-error";
576
+ case "Runner.ProviderUnavailableError": return "provider-unavailable-error";
577
+ case "Runner.ProviderInvalidResponseError": return "provider-invalid-response-error";
578
+ default: return "provider-unknown-error";
578
579
  }
579
580
  };
580
581
  const category = (tag) => {
581
582
  switch (tag) {
582
- case "Runner.ModelCatalogError": return "model.catalog";
583
- case "Runner.ModelNotFoundError": return "model.not-found";
584
- case "Runner.LLMStreamError": return "llm.stream";
585
- case "ContextDecodeError": return "context.decode";
586
- case "ContextEncodeError": return "context.encode";
587
- case "SessionNotFoundError": return "session.not-found";
588
- case "SessionLinkedSpaceNotFoundError": return "session.space-not-found";
589
- case "SettingsError": return "settings";
590
- case "State.SnapshotError": return "state.snapshot";
591
- case "Runner.SandboxDirectoryNotFoundError": return "sandbox.directory-not-found";
592
- case "SandboxFileSystemError": return "sandbox.filesystem";
583
+ case "Runner.ModelCatalogError": return "model-catalog-error";
584
+ case "Runner.ModelNotFoundError": return "model-not-found-error";
585
+ case "Runner.LLMStreamError": return "llm-stream-error";
586
+ case "ContextDecodeError": return "context-decode-error";
587
+ case "ContextEncodeError": return "context-encode-error";
588
+ case "SessionNotFoundError": return "session-not-found-error";
589
+ case "SessionLinkedSpaceNotFoundError": return "session-space-not-found-error";
590
+ case "SettingsError": return "settings-error";
591
+ case "State.SnapshotError": return "state-snapshot-error";
592
+ case "Runner.SandboxDirectoryNotFoundError": return "sandbox-directory-not-found-error";
593
+ case "SandboxFileSystemError": return "sandbox-filesystem-error";
593
594
  case "SandboxNotFoundError":
594
595
  case "SandboxDriverNotRegisteredError":
595
596
  case "SandboxUnavailError":
596
597
  case "SandboxRemovedError":
597
- case "SandboxProviderError": return "sandbox.mount";
598
- default: return "unknown";
598
+ case "SandboxProviderError": return "sandbox-mount-error";
599
+ default: return "unknown-error";
599
600
  }
600
601
  };
601
602
  const message = (cause) => cause instanceof globalThis.Error ? cause.message : typeof cause === "string" ? cause : "Session execution failed";
@@ -603,7 +604,7 @@ const message = (cause) => cause instanceof globalThis.Error ? cause.message : t
603
604
  const fromCause = (cause) => {
604
605
  const tag = Predicate.hasProperty(cause, "_tag") && typeof cause._tag === "string" ? cause._tag : void 0;
605
606
  if (tag === void 0) return {
606
- type: "unknown",
607
+ type: "unknown-error",
607
608
  message: message(cause)
608
609
  };
609
610
  if (tag === "Runner.ProviderError") {
@@ -902,7 +903,7 @@ var event_exports = /* @__PURE__ */ __exportAll({
902
903
  Service: () => Service$16,
903
904
  SubscriptionOverflowError: () => SubscriptionOverflowError,
904
905
  isSynced: () => isSynced,
905
- layer: () => layer$24
906
+ layer: () => layer$25
906
907
  });
907
908
  var InvalidDurableEventError = class extends Schema.TaggedError()("InvalidDurableEventError", {
908
909
  type: Schema.String,
@@ -911,7 +912,7 @@ var InvalidDurableEventError = class extends Schema.TaggedError()("InvalidDurabl
911
912
  const isSynced = (item) => item.type === "log.synced";
912
913
  var SubscriptionOverflowError = class extends Schema.TaggedError()("SubscriptionOverflowError", { capacity: Schema.Int }) {};
913
914
  var Service$16 = class extends Context.Service()("@codeworksh/harness/event/event/Service") {};
914
- const layer$24 = Layer.effect(Service$16, Effect.gen(function* () {
915
+ const layer$25 = Layer.effect(Service$16, Effect.gen(function* () {
915
916
  const sql = yield* SqlClient.SqlClient;
916
917
  const pubsub = { durable: /* @__PURE__ */ new Map() };
917
918
  const subscriptions = /* @__PURE__ */ new Set();
@@ -1269,7 +1270,7 @@ const archiveEnv = Effect.fn("Space.archiveEnv")(function* (env) {
1269
1270
  `;
1270
1271
  })).pipe(Effect.orDie);
1271
1272
  });
1272
- const layer$23 = Layer.effect(Service$15, Effect.gen(function* () {
1273
+ const layer$24 = Layer.effect(Service$15, Effect.gen(function* () {
1273
1274
  const sql = yield* SqlClient.SqlClient;
1274
1275
  const fs = yield* io_exports.SandboxIO.FileSystem;
1275
1276
  const { id: env } = yield* io_exports.SandboxIO.Current;
@@ -1393,7 +1394,7 @@ var session_exports$1 = /* @__PURE__ */ __exportAll({
1393
1394
  defaultLayer: () => defaultLayer$2,
1394
1395
  entryStates: () => entryStates,
1395
1396
  entryTypes: () => entryTypes,
1396
- layer: () => layer$22,
1397
+ layer: () => layer$23,
1397
1398
  messageEntryTypes: () => messageEntryTypes,
1398
1399
  partTypes: () => partTypes,
1399
1400
  rebaseDirectory: () => rebaseDirectory,
@@ -1459,7 +1460,7 @@ const decodeMessageEnvelopeIdentity = Schema.decodeUnknownEffect(MessageEnvelope
1459
1460
  const decodeCompactionData$1 = Schema.decodeUnknownEffect(CompactionData);
1460
1461
  const decodeJsonObject$1 = Schema.decodeUnknownEffect(JsonObject);
1461
1462
  const encodeJsonObject$1 = Schema.encodeEffect(JsonObject);
1462
- const layer$22 = Layer.effect(Service$14, Effect.gen(function* () {
1463
+ const layer$23 = Layer.effect(Service$14, Effect.gen(function* () {
1463
1464
  const sql = yield* SqlClient.SqlClient;
1464
1465
  const events = yield* Service$16;
1465
1466
  const findSession = SqlSchema.findOneOption({
@@ -2121,7 +2122,7 @@ const layer$22 = Layer.effect(Service$14, Effect.gen(function* () {
2121
2122
  setLabel
2122
2123
  });
2123
2124
  }));
2124
- const defaultLayer$2 = layer$22.pipe(Layer.provide(defaultLayer$3));
2125
+ const defaultLayer$2 = layer$23.pipe(Layer.provide(defaultLayer$3));
2125
2126
  //#endregion
2126
2127
  //#region src/context/errors.ts
2127
2128
  var ContextDecodeError = class extends Schema.TaggedError()("ContextDecodeError", {
@@ -2422,7 +2423,7 @@ const assemblePath = Effect.fn("Context.assemblePath")(function* (sessionId, pat
2422
2423
  //#endregion
2423
2424
  //#region src/context/context.ts
2424
2425
  var Service$13 = class extends Context.Service()("@codeworksh/harness/context/context/Service") {};
2425
- const layer$21 = Layer.effect(Service$13, Effect.gen(function* () {
2426
+ const layer$22 = Layer.effect(Service$13, Effect.gen(function* () {
2426
2427
  const sessions = yield* Service$14;
2427
2428
  const assemble = Effect.fn("Context.assemble")(function* (sessionId) {
2428
2429
  const found = yield* sessions.get(sessionId);
@@ -2512,7 +2513,7 @@ const make$17 = (vfs) => {
2512
2513
  };
2513
2514
  };
2514
2515
  /** The `SandboxFileSystem.Service` backed by the local {@link Vfs}. */
2515
- const layer$20 = Layer.effect(filesystem_exports.SandboxFileSystem.Service, Effect.gen(function* () {
2516
+ const layer$21 = Layer.effect(filesystem_exports.SandboxFileSystem.Service, Effect.gen(function* () {
2516
2517
  return filesystem_exports.SandboxFileSystem.fromProvider(make$17(yield* Vfs));
2517
2518
  }));
2518
2519
  //#endregion
@@ -2539,7 +2540,7 @@ var HostProvider = class extends RealFSProvider {
2539
2540
  * reason to keep relative paths above the mount, not below it — nothing here
2540
2541
  * resolves them, and nothing here should.
2541
2542
  */
2542
- const layer$19 = () => {
2543
+ const layer$20 = () => {
2543
2544
  const vfs = create(new HostProvider("/"), {
2544
2545
  moduleHooks: false,
2545
2546
  virtualCwd: true
@@ -2632,11 +2633,11 @@ const make$16 = (drivers) => Effect.gen(function* () {
2632
2633
  encodeRuntimeConfig
2633
2634
  });
2634
2635
  });
2635
- const layer$18 = (...drivers) => Layer.effect(Registry, make$16(drivers));
2636
+ const layer$19 = (...drivers) => Layer.effect(Registry, make$16(drivers));
2636
2637
  //#endregion
2637
2638
  //#region src/sandbox/shell/host.ts
2638
2639
  const DEFAULT_SHELL = "sh";
2639
- const layer$17 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(function* () {
2640
+ const layer$18 = (options = {}) => Layer.effect(shell_exports.Shell, Effect.gen(function* () {
2640
2641
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2641
2642
  const run = (label, file, args, opts) => Effect.scoped(Effect.gen(function* () {
2642
2643
  const handle = yield* spawner.spawn(ChildProcess.make(file, [...args], {
@@ -2727,7 +2728,7 @@ const make$15 = Effect.gen(function* () {
2727
2728
  //#endregion
2728
2729
  //#region src/sandbox/control.ts
2729
2730
  var Controller = class extends Context.Service()("@codeworksh/harness/sandbox/control/Controller") {};
2730
- const hostTransport = Layer.provide(Layer.merge(Layer.fresh(layer$20), layer$17()), layer$19());
2731
+ const hostTransport = Layer.provide(Layer.merge(Layer.fresh(layer$21), layer$18()), layer$20());
2731
2732
  const asDate = DateTime.toDateUtc;
2732
2733
  const optionalDate = Option.map(DateTime.toDateUtc);
2733
2734
  const MetadataJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String));
@@ -3347,7 +3348,7 @@ const make$14 = Effect.fn("Sandbox.Controller.make")(function* (options) {
3347
3348
  destroy
3348
3349
  });
3349
3350
  });
3350
- const layer$16 = (options) => Layer.effect(Controller, make$14(options));
3351
+ const layer$17 = (options) => Layer.effect(Controller, make$14(options));
3351
3352
  //#endregion
3352
3353
  //#region src/sandbox/utils/seed.ts
3353
3354
  const copyDirectory = async (vfs, sourceDirectory, targetDirectory) => {
@@ -3603,7 +3604,7 @@ const transport$1 = (inner) => Layer.provideMerge(Layer.effect(shell_exports.She
3603
3604
  })), inner);
3604
3605
  //#endregion
3605
3606
  //#region src/sandbox/virtual.ts
3606
- const transportLayer = (primitives) => Layer.provideMerge(Layer.merge(Layer.fresh(layer$20), transport$1(primitives)), primitives);
3607
+ const transportLayer = (primitives) => Layer.provideMerge(Layer.merge(Layer.fresh(layer$21), transport$1(primitives)), primitives);
3607
3608
  /** A cwd-neutral FileSystem + just-bash transport over one retained VFS. */
3608
3609
  const transport = (vfs) => {
3609
3610
  const primitives = Layer.merge(Layer.succeed(Vfs, vfs), unsupported);
@@ -3710,7 +3711,7 @@ const make$10 = (location, options) => Effect.tryPromise({
3710
3711
  })
3711
3712
  });
3712
3713
  const vfsLayer = (location, options) => Layer.effect(Vfs, Effect.acquireRelease(make$10(location, options), ({ provider }) => Effect.sync(() => provider.close())).pipe(Effect.map(({ vfs }) => vfs)));
3713
- const layer$15 = (opts) => Layer.merge(vfsLayer(opts?.location, opts?.options), opts?.options?.hostProcess ? host : unsupported);
3714
+ const layer$16 = (opts) => Layer.merge(vfsLayer(opts?.location, opts?.options), opts?.options?.hostProcess ? host : unsupported);
3714
3715
  //#endregion
3715
3716
  //#region src/sandbox/drivers/sqldb.ts
3716
3717
  const CreateConfig = Schema.Struct({
@@ -3728,7 +3729,7 @@ const make$9 = () => {
3728
3729
  const memory = /* @__PURE__ */ new Map();
3729
3730
  const runtimeLocation = (input) => input.runtimeConfig.location ?? Option.getOrUndefined(input.providerResourceId);
3730
3731
  const persistentTransport = (location) => {
3731
- const backend = layer$15({ location });
3732
+ const backend = layer$16({ location });
3732
3733
  return transportLayer(Layer.effectContext(Layer.build(backend).pipe(Effect.mapError((cause) => (0, errors_exports.providerError)({
3733
3734
  driver: name,
3734
3735
  operation: "attach",
@@ -3863,9 +3864,9 @@ const make$9 = () => {
3863
3864
  * managers). For a sandbox whose shell should stay inside the VFS, use
3864
3865
  * `EnvBash.services` instead, which wires just-bash over the same tree.
3865
3866
  */
3866
- const services = (backend, identity) => Layer.provideMerge(io_exports.SandboxIO.mount(identity), Layer.provideMerge(Layer.merge(layer$20, layer$17()), backend));
3867
+ const services = (backend, identity) => Layer.provideMerge(io_exports.SandboxIO.mount(identity), Layer.provideMerge(Layer.merge(layer$21, layer$18()), backend));
3867
3868
  /** Default sandbox: the real OS filesystem and processes, mounted at `cwd`. */
3868
- const defaultLayer$1 = (cwd) => services(layer$19(), io_exports.SandboxIO.host(cwd));
3869
+ const defaultLayer$1 = (cwd) => services(layer$20(), io_exports.SandboxIO.host(cwd));
3869
3870
  //#endregion
3870
3871
  //#region src/git/git.ts
3871
3872
  var AppProcessError = class extends Schema.TaggedError()("AppProcessError", {
@@ -3875,7 +3876,7 @@ var AppProcessError = class extends Schema.TaggedError()("AppProcessError", {
3875
3876
  cause: Schema.optional(Schema.Defect())
3876
3877
  }) {};
3877
3878
  var Service$12 = class extends Context.Service()("@codeworksh/harness/git/git/Service") {};
3878
- const layer$14 = Layer.effect(Service$12, Effect.gen(function* () {
3879
+ const layer$15 = Layer.effect(Service$12, Effect.gen(function* () {
3879
3880
  const shell = yield* io_exports.SandboxIO.Shell;
3880
3881
  const execute = (cwd) => (args, options) => shell.execArgv(["git", ...args], {
3881
3882
  cwd,
@@ -4099,7 +4100,7 @@ function parts(host, name) {
4099
4100
  if (!host || !pathname) return void 0;
4100
4101
  return `${host}/${pathname}`.toLowerCase();
4101
4102
  }
4102
- const layer$13 = Layer.effect(Service$11, Effect.gen(function* () {
4103
+ const layer$14 = Layer.effect(Service$11, Effect.gen(function* () {
4103
4104
  const fs = yield* io_exports.SandboxIO.FileSystem;
4104
4105
  const git = yield* Service$12;
4105
4106
  const find = Effect.fn("Repo.find")(function* (input) {
@@ -4156,7 +4157,7 @@ var WorktreeError = class extends Schema.TaggedError()("Worktree.WorktreeError",
4156
4157
  cause: Schema.optional(Schema.Defect())
4157
4158
  }) {};
4158
4159
  var Service$10 = class extends Context.Service()("@codeworksh/harness/worktree/worktree/Service") {};
4159
- const layer$12 = Layer.effect(Service$10, Effect.gen(function* () {
4160
+ const layer$13 = Layer.effect(Service$10, Effect.gen(function* () {
4160
4161
  const fs = yield* io_exports.SandboxIO.FileSystem;
4161
4162
  const git = yield* Service$12;
4162
4163
  const worktree = Effect.fnUntraced(function* (operation, cwd, args, directory) {
@@ -4211,7 +4212,7 @@ const provisional = (env, location) => schema_exports$6.ProjectSchema.ID.make(fa
4211
4212
  location
4212
4213
  ])));
4213
4214
  var Service$9 = class extends Context.Service()("@codeworksh/harness/project/project/Service") {};
4214
- const layer$11 = Layer.effect(Service$9, Effect.gen(function* () {
4215
+ const layer$12 = Layer.effect(Service$9, Effect.gen(function* () {
4215
4216
  const sql = yield* SqlClient.SqlClient;
4216
4217
  const fs = yield* io_exports.SandboxIO.FileSystem;
4217
4218
  const repos = yield* Service$11;
@@ -4353,11 +4354,11 @@ var location_exports = /* @__PURE__ */ __exportAll({
4353
4354
  Ref: () => Ref$2,
4354
4355
  Service: () => Service$8,
4355
4356
  defaultLayer: () => defaultLayer,
4356
- layer: () => layer$10,
4357
+ layer: () => layer$11,
4357
4358
  layerMounted: () => layerMounted,
4358
4359
  layerWith: () => layerWith
4359
4360
  });
4360
- const layer$10 = (ref = {}) => Layer.effect(Service, Effect.gen(function* () {
4361
+ const layer$11 = (ref = {}) => Layer.effect(Service, Effect.gen(function* () {
4361
4362
  const instance = yield* io_exports.SandboxIO.Current;
4362
4363
  const directory = schema_exports$9.AbsolutePath.make(ref.directory ?? instance.cwd);
4363
4364
  const resolved = yield* (yield* Service$9).resolveOrCreate(directory);
@@ -4372,7 +4373,7 @@ const layer$10 = (ref = {}) => Layer.effect(Service, Effect.gen(function* () {
4372
4373
  * folded in. What remains is exactly the mount and the database — for callers
4373
4374
  * that already hold both, such as the runner and `Session.create`.
4374
4375
  */
4375
- const layerMounted = (ref = {}) => layer$10(ref).pipe(Layer.provide(layer$11.pipe(Layer.provide(Layer.mergeAll(layer$13, layer$12, layer$23)), Layer.provide(layer$14))));
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))));
4376
4377
  const layerWith = (ref, sandbox) => layerMounted(ref).pipe(Layer.provide(sandbox), Layer.provide(defaultLayer$3));
4377
4378
  const defaultLayer = (ref, path) => layerWith(ref, defaultLayer$1(path));
4378
4379
  //#endregion
@@ -4725,7 +4726,7 @@ var control_exports = /* @__PURE__ */ __exportAll({
4725
4726
  Control: () => control_exports,
4726
4727
  PromptConflictError: () => PromptConflictError,
4727
4728
  Service: () => Service$5,
4728
- layer: () => layer$9
4729
+ layer: () => layer$10
4729
4730
  });
4730
4731
  /**
4731
4732
  * The prompt could not be admitted under the id it was given: either that id
@@ -4737,7 +4738,7 @@ var PromptConflictError = class extends Schema.TaggedError()("PromptConflictErro
4737
4738
  messageId: Schema.String
4738
4739
  }) {};
4739
4740
  var Service$5 = class extends Context.Service()("@codeworksh/harness/control/Service") {};
4740
- const layer$9 = Layer.effect(Service$5, Effect.gen(function* () {
4741
+ const layer$10 = Layer.effect(Service$5, Effect.gen(function* () {
4741
4742
  const sessions = yield* Service$14;
4742
4743
  const inputs = yield* make$8;
4743
4744
  const execution = yield* Service$6;
@@ -4806,7 +4807,7 @@ var registry_exports = /* @__PURE__ */ __exportAll({
4806
4807
  EventRegistry: () => registry_exports,
4807
4808
  Service: () => Service$4,
4808
4809
  flatten: () => flatten,
4809
- layer: () => layer$8
4810
+ layer: () => layer$9
4810
4811
  });
4811
4812
  var Service$4 = class extends Context.Service()("@codeworksh/harness/event/registry/Service") {};
4812
4813
  var EventRegistrationError = class extends Schema.TaggedError()("EventRegistrationError", {
@@ -4852,7 +4853,7 @@ const flatten = Effect.fn("EventRegistry.flatten")(function* (plugins) {
4852
4853
  }
4853
4854
  return [...Definitions, ...definitions];
4854
4855
  });
4855
- const layer$8 = (definitions = Definitions) => Layer.sync(Service$4, () => {
4856
+ const layer$9 = (definitions = Definitions) => Layer.sync(Service$4, () => {
4856
4857
  let current = definitions;
4857
4858
  let latest = schema_exports$7.EventSchema.latest(current);
4858
4859
  const replace = Effect.fn("EventRegistry.replace")(function* (plugins) {
@@ -4869,2100 +4870,2223 @@ const layer$8 = (definitions = Definitions) => Layer.sync(Service$4, () => {
4869
4870
  });
4870
4871
  });
4871
4872
  //#endregion
4872
- //#region src/settings/merge.ts
4873
+ //#region src/model/catalog.ts
4874
+ var catalog_exports = /* @__PURE__ */ __exportAll({
4875
+ ModelCatalog: () => catalog_exports,
4876
+ RefreshError: () => RefreshError,
4877
+ filename: () => filename,
4878
+ layer: () => layer$8,
4879
+ loadError: () => loadError,
4880
+ models: () => models,
4881
+ providers: () => providers,
4882
+ refresh: () => refresh$1,
4883
+ sync: () => sync,
4884
+ ttlConfig: () => ttlConfig
4885
+ });
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", {
4892
+ path: Schema.String,
4893
+ detail: Schema.String
4894
+ }) {
4895
+ get message() {
4896
+ return `failed to refresh the model catalog at ${this.path}: ${this.detail}`;
4897
+ }
4898
+ };
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
+ };
4908
+ };
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 {
4927
+ path,
4928
+ refreshed: false
4929
+ };
4930
+ yield* download$1(path);
4931
+ return {
4932
+ path,
4933
+ refreshed: true
4934
+ };
4935
+ });
4873
4936
  /**
4874
- * A plain string-keyed record: what a settings patch merges, and what a plugin's `options` block
4875
- * has to be. A Map, a typed array or a class instance is an object to `typeof` and not one of
4876
- * these — `Object.freeze` alone does not tell them apart, and it throws on some of them.
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.
4877
4940
  */
4878
- const isRecord$1 = (value) => Predicate.isObject(value) && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
4879
- const copy = (value) => {
4880
- if (Array.isArray(value)) return value.map(copy);
4881
- return isRecord$1(value) ? merge({}, value) : value;
4941
+ const check$1 = Effect.fn("ModelCatalog.check")(function* ({ path, pinned }) {
4942
+ if (pinned || !(yield* stale(path))) return;
4943
+ yield* download$1(path);
4944
+ });
4945
+ const quietly = (effect) => effect.pipe(Effect.asVoid, Effect.catch((error) => Effect.logWarning(`model catalog: ${error.message}`)));
4946
+ /** Check the home's catalog once, for a command that runs and exits; returns the file aikit reads. */
4947
+ const sync = Effect.fn("ModelCatalog.sync")(function* (home) {
4948
+ const target = use(home);
4949
+ yield* quietly(check$1(target));
4950
+ return target.path;
4951
+ });
4952
+ /**
4953
+ * Check at boot, and with `watch`, keep checking for the life of the layer. A file whose mtime
4954
+ * moved -- refreshed here, by `codework models refresh`, or by hand -- is reloaded, so the
4955
+ * process never serves a catalog older than what is on disk.
4956
+ */
4957
+ const layer$8 = (options) => Layer.effectDiscard(Effect.gen(function* () {
4958
+ const target = use(options.home);
4959
+ yield* quietly(check$1(target));
4960
+ if (!options.watch) return;
4961
+ const current = modified(target.path).pipe(Effect.map(Option.getOrUndefined));
4962
+ const seen = yield* Ref.make(yield* current);
4963
+ const tick = Effect.gen(function* () {
4964
+ yield* quietly(check$1(target));
4965
+ const mtime = yield* current;
4966
+ if (mtime !== (yield* Ref.getAndSet(seen, mtime))) Model$1.reloadCatalog();
4967
+ });
4968
+ yield* Effect.forkScoped(tick.pipe(Effect.repeat(Schedule.spaced(checkEvery))));
4969
+ }));
4970
+ /** Translate aikit's catalog failure, or undefined for anything else. */
4971
+ const loadError = (cause) => {
4972
+ if (typeof cause !== "object" || cause === null || !("name" in cause) || cause.name !== "ModelCatalogLoadError") return;
4973
+ if (!("data" in cause) || typeof cause.data !== "object" || cause.data === null) return void 0;
4974
+ const data = cause.data;
4975
+ if (!("path" in data) || typeof data.path !== "string" || !("message" in data) || typeof data.message !== "string" || !("reason" in data) || data.reason !== "missing" && data.reason !== "unreadable" && data.reason !== "empty" && data.reason !== "invalid") return;
4976
+ return new ModelCatalogError({
4977
+ path: data.path,
4978
+ reason: data.reason,
4979
+ detail: data.message
4980
+ });
4882
4981
  };
4883
- /** Null/undefined properties are absent, objects merge, and arrays replace. Never mutates inputs. */
4884
- function merge(base, ...patches) {
4885
- const result = /* @__PURE__ */ new Map();
4886
- for (const patch of [base, ...patches]) {
4887
- if (patch === void 0) continue;
4888
- for (const [key, value] of Object.entries(patch)) {
4889
- if (value === null || value === void 0) continue;
4890
- const previous = result.get(key);
4891
- result.set(key, isRecord$1(previous) && isRecord$1(value) ? merge(previous, value) : copy(value));
4892
- }
4893
- }
4894
- return Object.fromEntries(result);
4895
- }
4896
- /** Normalize nullable properties before schema validation, without changing array positions. */
4897
- const normalize$1 = copy;
4982
+ const catalogError = (cause) => loadError(cause) ?? new ModelCatalogError({
4983
+ path: Model$1.catalogPath(),
4984
+ reason: "unreadable",
4985
+ detail: cause instanceof Error ? cause.message : "failed to load the model catalog"
4986
+ });
4987
+ const models = Effect.tryPromise({
4988
+ try: () => Model$1.getBuiltInModels(),
4989
+ catch: catalogError
4990
+ });
4991
+ const providers = Effect.tryPromise({
4992
+ try: () => Model$1.getProviders(),
4993
+ catch: catalogError
4994
+ });
4898
4995
  //#endregion
4899
- //#region src/util/module.ts
4900
- /*!
4901
- * Adapted from OpenCode packages/util/src/runtime/import.node.ts.
4902
- *
4903
- * MIT License
4904
- *
4905
- * Copyright (c) 2025 opencode
4906
- *
4907
- * Permission is hereby granted, free of charge, to any person obtaining a copy
4908
- * of this software and associated documentation files (the "Software"), to deal
4909
- * in the Software without restriction, including without limitation the rights
4910
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
4911
- * copies of the Software, and to permit persons to whom the Software is
4912
- * furnished to do so, subject to the following conditions:
4913
- *
4914
- * The above copyright notice and this permission notice shall be included in all
4915
- * copies or substantial portions of the Software.
4916
- *
4917
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
4918
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
4919
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
4920
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
4921
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
4922
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
4923
- * SOFTWARE.
4924
- */
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.`;
4925
5004
  /**
4926
- * Packages a plugin must share with the harness instead of using the copy installed beside it.
4927
- *
4928
- * A plugin is installed into the store as its own npm tree, so `effect` and the plugin SDK land
4929
- * there a second time. Two copies of the *same version* are still two module instances, and
4930
- * Effect Schema's checks do not survive the crossing: a schema built by one copy and evaluated by
4931
- * the other reports every check as failed, so a tool whose success type is `Schema.Finite` dies
4932
- * on "Expected a finite number" for `22800`. Nothing about that is visible at registration --
4933
- * `Schema.isSchema` passes, the JSON Schema is derived correctly, and the tool reaches the model
4934
- * -- so it surfaces on the first call rather than at boot.
5005
+ * Guidelines that hold regardless of which tools are registered.
4935
5006
  *
4936
- * Resolution is the right place to fix it. A symlink into the store cannot be: a generation is
4937
- * immutable and shared by every project on the machine, while the harness doing the loading is
4938
- * whichever one is running now, and two applications embedding the harness need not agree on
4939
- * where their `effect` lives.
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.
4940
5009
  */
4941
- const sharedPackages = /* @__PURE__ */ new Set(["effect", "@codeworksh/plugin"]);
4942
- /** The package a bare specifier names: `effect/Schema` -> `effect`, `@a/b/c` -> `@a/b`. */
4943
- function packageOf(specifier) {
4944
- if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.includes(":")) return "";
4945
- const segments = specifier.split("/");
4946
- return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0] ?? "";
4947
- }
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, " ");
4948
5017
  /**
4949
- * Resolve the shared packages as though this module had imported them, for as long as `run`
4950
- * takes. Anything else resolves exactly as it would have.
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.
4951
5023
  */
4952
- function withSharedModules(run) {
4953
- const hook = registerHooks({ resolve(specifier, context, nextResolve) {
4954
- return sharedPackages.has(packageOf(specifier)) ? nextResolve(specifier, {
4955
- ...context,
4956
- parentURL: import.meta.url
4957
- }) : nextResolve(specifier, context);
4958
- } });
4959
- return run().finally(() => hook.deregister());
4960
- }
4961
- /** Use Node's main loader even when the caller runs inside a bundler or test VM. */
4962
- async function importModule(specifier) {
4963
- const imported = await withSharedModules(() => new Script(`import(${JSON.stringify(specifier)})`, { importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER }).runInThisContext());
4964
- if (typeof imported !== "object" || imported === null) return imported;
4965
- const module = imported;
4966
- const exports = module["module.exports"];
4967
- if (exports !== module.default || typeof exports !== "object" && typeof exports !== "function" || exports === null) return imported;
4968
- return Object.assign({}, module, exports);
4969
- }
4970
- /** Node resolution scoped synchronously to the caller. */
4971
- function resolveModule(specifier, directory, conditions) {
4972
- const hook = registerHooks({ resolve(target, context, nextResolve) {
4973
- return nextResolve(target, {
4974
- ...context,
4975
- parentURL: pathToFileURL(path.join(directory, "package.json")).href,
4976
- ...conditions === void 0 ? {} : { conditions: [.../* @__PURE__ */ new Set([...context.conditions, ...conditions])] }
4977
- });
4978
- } });
4979
- try {
4980
- const resolve = (target) => {
4981
- const resolved = import.meta.resolve(path.isAbsolute(target) ? pathToFileURL(target).href : target);
4982
- if (resolved.startsWith("file:")) statSync(new URL(resolved));
4983
- return resolved;
4984
- };
4985
- try {
4986
- return resolve(specifier);
4987
- } catch (error) {
4988
- if (path.extname(specifier) || !missing(error)) throw error;
4989
- for (const extension of [
4990
- ".ts",
4991
- ".tsx",
4992
- ".js",
4993
- ".jsx",
4994
- ".mts",
4995
- ".mjs",
4996
- ".cts",
4997
- ".cjs"
4998
- ]) try {
4999
- return resolve(specifier + extension);
5000
- } catch (cause) {
5001
- if (!missing(cause)) throw cause;
5002
- }
5003
- throw error;
5004
- }
5005
- } finally {
5006
- hook.deregister();
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);
5007
5032
  }
5008
- }
5009
- function missing(error) {
5010
- return error instanceof Error && "code" in error && [
5011
- "ENOENT",
5012
- "ENOTDIR",
5013
- "ERR_MODULE_NOT_FOUND"
5014
- ].includes(String(error.code));
5015
- }
5016
- //#endregion
5017
- //#region src/plugin/error.ts
5033
+ return collected;
5034
+ };
5018
5035
  /**
5019
- * The reference as the person wrote it -- the string they will recognise in their settings file,
5020
- * never the resolved path or the canonical spec. Every plugin failure carries one.
5036
+ * The rendered tool index.
5037
+ *
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.
5021
5040
  */
5022
- const Reference = Schema.String;
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
+ };
5023
5056
  /**
5024
- * The settings file that declared the entry, when one did.
5025
- *
5026
- * Absent for an embedder's own list and for anything the CLI was handed directly -- in both cases
5027
- * the caller already knows where the string came from. It is the settings path that needs saying,
5028
- * because several layers accumulate entries and "which file says this" is the question.
5057
+ * A caller slot, awaited lazily. A throw or rejection fails the snapshot, which the
5058
+ * host attributes to this plugin's id.
5029
5059
  */
5030
- const File$1 = Schema.optional(Schema.String);
5060
+ const slot = (ctx, resolver) => Effect.tryPromise(() => Promise.resolve(resolver?.(ctx)));
5061
+ const defaultPromptPlugin = (0, plugin_exports$1.define)({
5062
+ id: "codework.prompt.default",
5063
+ kind: "prompt",
5064
+ setup: Effect.fn("DefaultPromptPlugin.setup")(function* (ctx) {
5065
+ 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
+ }));
5073
+ })
5074
+ });
5075
+ //#endregion
5076
+ //#region src/tool/truncate.ts
5031
5077
  /**
5032
- * A spec that cannot be installed at all.
5078
+ * Shared truncation utilities for tool outputs.
5033
5079
  *
5034
- * `plugin-not-found` is a local path with nothing at it; `plugin-not-installed` (the store's) is a
5035
- * package spec with no entry. The first is a mistake in a settings file, the second is fixed by
5036
- * `plugin install`, and keeping them apart is most of the value of having slugs.
5037
- */
5038
- var SourceError = class extends Schema.TaggedError()("PluginSourceError", {
5039
- reason: Schema.Literals([
5040
- "plugin-unsupported-source",
5041
- "plugin-not-found",
5042
- "plugin-escapes-root"
5043
- ]),
5044
- reference: Reference,
5045
- file: File$1,
5046
- message: Schema.String
5047
- }) {};
5048
- /** A spec that is installable, and whose install did not produce a usable package. */
5049
- var InstallError = class extends Schema.TaggedError()("PluginInstallError", {
5050
- reason: Schema.Literals([
5051
- "plugin-resolve-failed",
5052
- "plugin-fetch-failed",
5053
- "plugin-no-commit",
5054
- "plugin-no-entrypoint"
5055
- ]),
5056
- reference: Reference,
5057
- file: File$1,
5058
- message: Schema.String,
5059
- cause: Schema.optional(Schema.Defect())
5060
- }) {};
5061
- /**
5062
- * A module that reached the import and did not come back as a plugin.
5063
- *
5064
- * Distinct from {@link InstallError}: the bytes are here and are fine as *bytes*. What failed is
5065
- * evaluating them, or what they turned out to export -- which is the distinction a caller acts on,
5066
- * because one is fixed by installing and the other by editing code.
5067
- */
5068
- var LoadError = class extends Schema.TaggedError()("PluginLoadError", {
5069
- reason: Schema.Literals([
5070
- "plugin-import-failed",
5071
- "plugin-missing-dependency",
5072
- "plugin-not-compiled",
5073
- "plugin-invalid-definition"
5074
- ]),
5075
- reference: Reference,
5076
- file: File$1,
5077
- message: Schema.String,
5078
- /** The ID the module declared, when it got far enough to declare one. */
5079
- id: Schema.optional(Schema.String),
5080
- cause: Schema.optional(Schema.Defect())
5081
- }) {};
5082
- /** The store's own failures: what is filed, what is not, and what could not be claimed. */
5083
- var StoreError = class extends Schema.TaggedError()("PluginStoreError", {
5084
- reason: Schema.Literals([
5085
- "plugin-not-installed",
5086
- "plugin-lock-timeout",
5087
- "plugin-marker-invalid",
5088
- "plugin-index-invalid",
5089
- "plugin-collect-failed"
5090
- ]),
5091
- reference: Reference,
5092
- file: File$1,
5093
- message: Schema.String,
5094
- cause: Schema.optional(Schema.Defect())
5095
- }) {};
5096
- /**
5097
- * Note which settings file declared the entry a failure came from.
5098
- *
5099
- * The store and the installer raise their own failures without knowing that, because neither is
5100
- * told: they are handed a spec, not a settings layer. The loader knows, and this is where it says
5101
- * so -- leaving an existing answer alone, since a more specific one is already better.
5102
- */
5103
- const declaredIn = (error, file) => {
5104
- if (error.file !== void 0) return error;
5105
- const shared = {
5106
- reference: error.reference,
5107
- message: error.message,
5108
- file
5109
- };
5110
- if (Schema.is(SourceError)(error)) return new SourceError({
5111
- ...shared,
5112
- reason: error.reason
5113
- });
5114
- if (Schema.is(InstallError)(error)) return new InstallError({
5115
- ...shared,
5116
- reason: error.reason,
5117
- cause: error.cause
5118
- });
5119
- if (Schema.is(LoadError)(error)) return new LoadError({
5120
- ...shared,
5121
- reason: error.reason,
5122
- id: error.id,
5123
- cause: error.cause
5124
- });
5125
- if (Schema.is(StoreError)(error)) return new StoreError({
5126
- ...shared,
5127
- reason: error.reason,
5128
- cause: error.cause
5129
- });
5130
- return error;
5131
- };
5132
- //#endregion
5133
- //#region src/util/path.ts
5134
- /**
5135
- * A directory that crosses into the harness is absolute, and a relative one is a **defect** rather
5136
- * than an error.
5137
- *
5138
- * A relative path is a disguised `process.cwd()` call: and every consumer
5139
- * resolves it silently. `path.resolve("x")` anchors to the process directory, npm answers a
5140
- * relative `cwd` with a `localPrefix` of whatever repository the process is inside (measured, not
5141
- * assumed), and `Session.link` would write the *server's* directory into a field whose whole
5142
- * purpose is to be a host path the client declared.
5143
- *
5144
- * For a long-running server the process directory is nobody's project, so the result is a
5145
- * plausible answer to a question no one asked.
5146
- * An absolute path is the one spelling none of them can quietly reinterpret.
5147
- *
5148
- * A defect rather than a typed error because nothing a user types reaches here. Every reference
5149
- * goes through `parse`, and every directory comes from `Global.resolve`, a resolved `cwd` or a
5150
- * session's `hostDir`, all of them already absolute. A relative one means a new call site got it
5151
- * wrong, and a stack trace names that call site; a typed error would ask every caller to handle a
5152
- * case none of them can cause.
5153
- */
5154
- const rooted = (directory, role) => {
5155
- if (hostPath.isAbsolute(directory)) return directory;
5156
- throw new Error(`${role} must be an absolute path, got ${JSON.stringify(directory)}`);
5157
- };
5158
- //#endregion
5159
- //#region src/plugin/npm.ts
5160
- const Manifest$1 = Schema.Struct({ version: Schema.optional(Schema.String) });
5161
- const Lockfile = Schema.Struct({ packages: Schema.optional(Schema.Record(Schema.String, Schema.Struct({ resolved: Schema.optional(Schema.String) }))) });
5162
- /**
5163
- * npm's flattened config: registry, scopes, auth, and cache paths.
5164
- *
5165
- * Built in one place, because two call sites deriving it separately is how the cache location
5166
- * drifts between an install and the staleness check that is supposed to describe it.
5167
- *
5168
- * `dir` is where the `.npmrc` chain is read from, and it is the **host directory the caller
5169
- * belongs to** -- never the staging directory and never the cache. A company repo keeps its
5170
- * private registry and its scoped token in its own `.npmrc`, so a chain read anywhere else
5171
- * resolves the plugin against the public registry and fails 404, or worse, installs a public
5172
- * package of the same name. This is the registry half of auth; the git half is below.
5173
- *
5174
- * No `--prefix`. npm pins its local prefix to a `--prefix` on `argv` and stops walking, so
5175
- * passing one would look up exactly `<dir>/.npmrc` and nothing above it -- wrong for a person
5176
- * standing in `packages/x` whose token lives at the repository root. Without it npm walks up from
5177
- * `cwd` to the nearest `package.json` or `node_modules` exactly as `npm` itself would if it were
5178
- * run there, which is the only rule a user can predict.
5179
- *
5180
- * `--cache` is ours and is passed on `argv`, which outranks every `.npmrc`. npm's `flatten`
5181
- * derives `_cacache`, `_npx` and `_tuf` from that one value, so naming the parent is enough --
5182
- * and overriding `flat.cache` afterwards would redirect one of the three and leave `_tuf` pointing
5183
- * at the user's home. `--home` exists to make a run self-contained; anything reached by an ambient
5184
- * default is a test reading the developer's real machine.
5185
- *
5186
- * **Nothing here names an `env`.** npm hands git `opts.env` when one is present and falls back to
5187
- * `{ ...gitDefaults, ...process.env }` when it is not, so leaving it out is what lets a git source
5188
- * reach a private remote at all: `SSH_AUTH_SOCK` finds the agent, `HOME` finds the `~/.gitconfig`
5189
- * that names the credential helper, and `PATH` finds `git`. Setting `env` to anything -- even to
5190
- * a copy of `process.env` -- would be a standing invitation to prune it later and break private
5191
- * git silently, months from the change. §15 Q2.
5192
- */
5193
- const options = (dir, cache) => Effect.suspend(() => {
5194
- const root = rooted(dir, "the directory a plugin install reads its .npmrc chain from");
5195
- const store = rooted(cache, "the npm cache");
5196
- return npmOptions(root, store);
5197
- });
5198
- const npmOptions = (dir, cache) => Effect.gen(function* () {
5199
- const config = yield* Effect.tryPromise(async () => {
5200
- const { default: Config } = await import("@npmcli/config");
5201
- const { definitions, flatten, nerfDarts, shorthands } = (await import("@npmcli/config/lib/definitions/index.js")).default;
5202
- const created = new Config({
5203
- npmPath: fileURLToPath(new URL("..", import.meta.url)),
5204
- cwd: dir,
5205
- env: { ...process.env },
5206
- argv: [
5207
- process.execPath,
5208
- process.execPath,
5209
- "--cache",
5210
- hostPath.join(cache, "npm")
5211
- ],
5212
- execPath: process.execPath,
5213
- platform: process.platform,
5214
- definitions,
5215
- flatten,
5216
- nerfDarts,
5217
- shorthands,
5218
- warn: false
5219
- });
5220
- await created.load();
5221
- return created;
5222
- }).pipe(Effect.catch((cause) => Effect.logWarning(`cannot load npm configuration from ${dir}; npm defaults apply`, cause).pipe(Effect.as(void 0))));
5223
- if (config === void 0) return { allowGit: "root" };
5224
- for (const kind of [
5225
- "project",
5226
- "user",
5227
- "global"
5228
- ]) {
5229
- const loaded = config.data.get(kind);
5230
- if (loaded?.loadError !== void 0 && loaded.loadError.code !== "ENOENT") yield* Effect.logWarning(`cannot read npm configuration ${loaded.source ?? `for ${kind}`}; npm defaults apply`, loaded.loadError);
5231
- }
5232
- return {
5233
- ...config.flat,
5234
- allowGit: "root"
5235
- };
5236
- });
5237
- const PUBLIC_REGISTRY = "https://registry.npmjs.org/";
5238
- /** Stable, credential-free registry identity used to address registry artifacts in the store. */
5239
- const registry = Effect.fn("PluginNpm.registry")(function* (target, cache, from) {
5240
- const flat = yield* options(from, cache);
5241
- const scope = target.name.startsWith("@") ? target.name.slice(0, target.name.indexOf("/")) : void 0;
5242
- const configured = (scope === void 0 ? void 0 : flat[`${scope}:registry`]) ?? flat.registry ?? PUBLIC_REGISTRY;
5243
- if (typeof configured !== "string") return yield* new InstallError({
5244
- reason: "plugin-resolve-failed",
5245
- reference: target.spec,
5246
- message: `npm registry for ${target.name} is not a URL`
5247
- });
5248
- return yield* Effect.try({
5249
- try: () => {
5250
- const value = new URL(configured);
5251
- value.username = "";
5252
- value.password = "";
5253
- value.hash = "";
5254
- return value.href;
5255
- },
5256
- catch: (cause) => new InstallError({
5257
- reason: "plugin-resolve-failed",
5258
- reference: target.spec,
5259
- message: `npm registry for ${target.name} is not a valid URL: ${configured}`,
5260
- cause
5261
- })
5262
- });
5263
- });
5264
- /** A 40- or 64-hex commit out of the `resolved` URL arborist writes into the lockfile. */
5265
- const commitIn = (resolved) => resolved?.match(/#([a-f0-9]{40}|[a-f0-9]{64})(?=::|$)/i)?.[1];
5266
- /**
5267
- * The commit a git install actually landed on, read back from the lockfile arborist wrote.
5268
- *
5269
- * Both spellings are tried because which one exists depends on whether the tree was saved.
5270
- */
5271
- const commit = Effect.fn("PluginNpm.commit")(function* (root, name) {
5272
- for (const file of [hostPath.join(root, "package-lock.json"), hostPath.join(root, "node_modules", ".package-lock.json")]) {
5273
- const parsed = yield* fileSystem.readFileString(file).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Lockfile))), Effect.option);
5274
- if (Option.isNone(parsed)) continue;
5275
- const found = commitIn(parsed.value.packages?.[`node_modules/${name}`]?.resolved);
5276
- if (found !== void 0) return found;
5277
- }
5278
- });
5279
- /** Node error codes that mean "nothing here", as opposed to "this package is broken". */
5280
- const notFound = /* @__PURE__ */ new Set([
5281
- "ENOENT",
5282
- "ENOTDIR",
5283
- "MODULE_NOT_FOUND",
5284
- "ERR_MODULE_NOT_FOUND",
5285
- "ERR_PACKAGE_PATH_NOT_EXPORTED",
5286
- "ERR_UNSUPPORTED_DIR_IMPORT"
5287
- ]);
5288
- /**
5289
- * The module to import out of an installed package.
5290
- *
5291
- * Ordered subpaths through the package's own `exports`, so a package that publishes a dedicated
5292
- * `./plugin` entry is honoured and one that does not falls back to its main.
5293
- *
5294
- * Only "not found" is tolerated. Swallowing every error would turn a package that is broken --
5295
- * a syntax error in its manifest, a permission problem -- into "no entrypoint", which is the
5296
- * wrong diagnosis in the one place a person needs the right one.
5297
- */
5298
- const entrypoint = (directory, name) => Effect.gen(function* () {
5299
- for (const subpath of ["", "plugin"]) {
5300
- const specifier = name === void 0 ? hostPath.join(directory, subpath === "" ? "index" : subpath) : hostPath.join(name, subpath);
5301
- const resolved = yield* Effect.try(() => resolveModule(specifier, directory)).pipe(Effect.asSome, Effect.catchIf((error) => notFound.has(String(error.cause?.code)), () => Effect.succeed(Option.none())), Effect.mapError((error) => new InstallError({
5302
- reason: "plugin-no-entrypoint",
5303
- reference: name ?? directory,
5304
- message: `cannot resolve an entrypoint in ${directory}`,
5305
- cause: error
5306
- })));
5307
- if (Option.isSome(resolved)) return fileURLToPath(resolved.value);
5308
- }
5309
- return yield* new InstallError({
5310
- reason: "plugin-no-entrypoint",
5311
- reference: name ?? directory,
5312
- message: `no plugin entrypoint in ${directory}`
5313
- });
5314
- });
5315
- const reify = Effect.fn("PluginNpm.reify")(function* ({ target, into, cache, from }) {
5316
- const flat = yield* options(from, cache);
5317
- const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"));
5318
- const settings = {
5319
- ...flat,
5320
- ...target.mutable ? {
5321
- preferOnline: true,
5322
- noGitRevCache: true
5323
- } : {},
5324
- audit: false
5325
- };
5326
- const arborist = new Arborist({
5327
- ...settings,
5328
- path: into,
5329
- binLinks: true,
5330
- progress: false,
5331
- savePrefix: "",
5332
- ignoreScripts: true
5333
- });
5334
- return yield* Effect.tryPromise({
5335
- try: () => arborist.reify({
5336
- ...settings,
5337
- add: [target.spec],
5338
- update: target.mutable,
5339
- save: true,
5340
- saveType: "prod"
5341
- }),
5342
- catch: (cause) => new InstallError({
5343
- reason: "plugin-fetch-failed",
5344
- reference: target.spec,
5345
- message: `cannot install ${target.spec}`,
5346
- cause
5347
- })
5348
- });
5349
- });
5350
- /**
5351
- * Fill `into` with the package `target` names, and report what landed there.
5080
+ * Truncation is based on two independent limits — whichever is hit first
5081
+ * wins: a line limit and a byte limit. Lines are never split (except the bash
5082
+ * tail edge case where a single line exceeds the byte limit).
5352
5083
  *
5353
- * A local target downloads nothing: the files on disk are already the answer, and a local plugin
5354
- * is never copied into the store.
5084
+ * The bash tool uses {@link truncateTail} (keep the end — errors / final
5085
+ * results); file readers use {@link truncateHead} (keep the beginning).
5355
5086
  */
5356
- const download = Effect.fn("PluginNpm.download")(function* (input) {
5357
- const { target, into, cache, from, runner = reify } = input;
5358
- if (target.kind === "local") return {
5359
- directory: target.path,
5360
- name: hostPath.basename(target.path),
5361
- entrypoint: yield* entrypoint(target.path)
5362
- };
5363
- const root = yield* fileSystem.realPath(into).pipe(Effect.mapError((cause) => new InstallError({
5364
- reason: "plugin-fetch-failed",
5365
- reference: target.spec,
5366
- message: `cannot resolve the staging directory ${into}`,
5367
- cause
5368
- })));
5369
- const node = (yield* runner({
5370
- target,
5371
- into: root,
5372
- cache,
5373
- from
5374
- })).edgesOut.values().next().value?.to;
5375
- if (node === void 0) return yield* new InstallError({
5376
- reason: "plugin-fetch-failed",
5377
- reference: target.spec,
5378
- message: `${target.spec} installed nothing`
5379
- });
5380
- const manifest = yield* fileSystem.readFileString(hostPath.join(node.path, "package.json")).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest$1))), Effect.option);
5381
- const version = Option.isNone(manifest) ? void 0 : manifest.value.version;
5382
- const revision = target.kind === "registry" ? version : (yield* commit(root, node.name)) ?? (yield* noCommit(target.spec));
5383
- return {
5384
- directory: node.path,
5385
- name: node.name,
5386
- ...version === void 0 ? {} : { version },
5387
- ...revision === void 0 ? {} : { revision },
5388
- entrypoint: yield* entrypoint(node.path, node.name)
5389
- };
5390
- });
5391
- const noCommit = (spec) => new InstallError({
5392
- reason: "plugin-no-commit",
5393
- reference: spec,
5394
- message: `cannot resolve a commit for ${spec}`
5395
- });
5087
+ const DEFAULT_MAX_LINES = 2e3;
5088
+ const DEFAULT_MAX_BYTES = 51200;
5089
+ function splitLinesForCounting(content) {
5090
+ if (content.length === 0) return [];
5091
+ const lines = content.split("\n");
5092
+ if (content.endsWith("\n")) lines.pop();
5093
+ return lines;
5094
+ }
5095
+ /** Format bytes as a human-readable size. */
5096
+ function formatSize(bytes) {
5097
+ if (bytes < 1024) return `${bytes}B`;
5098
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`;
5099
+ return `${(bytes / 1048576).toFixed(1)}MB`;
5100
+ }
5396
5101
  /**
5397
- * What a mutable target points at right now, without installing it.
5398
- *
5399
- * `preferOnline` and `noGitRevCache` are the whole point: without them pacote answers from its own
5400
- * metadata cache, and a staleness check that reads a cache is not a staleness check. An immutable
5401
- * target skips the call entirely -- a version and a SHA cannot move.
5102
+ * Truncate content from the tail (keep the last N lines/bytes). Suitable for
5103
+ * bash output where the end matters (errors, final results). May return a
5104
+ * partial first line if the last line of the original content exceeds the byte
5105
+ * limit.
5402
5106
  */
5403
- const probe = Effect.fn("PluginNpm.probe")(function* (target, cache, from) {
5404
- if (target.kind === "local" || !target.mutable) return void 0;
5405
- const opts = {
5406
- ...yield* options(from, cache),
5407
- preferOnline: true,
5408
- noGitRevCache: true,
5409
- _isRoot: true
5107
+ function truncateTail(content, options = {}) {
5108
+ const maxLines = options.maxLines ?? 2e3;
5109
+ const maxBytes = options.maxBytes ?? 51200;
5110
+ const totalBytes = Buffer.byteLength(content, "utf-8");
5111
+ const lines = splitLinesForCounting(content);
5112
+ const totalLines = lines.length;
5113
+ if (totalLines <= maxLines && totalBytes <= maxBytes) return {
5114
+ content,
5115
+ truncated: false,
5116
+ truncatedBy: null,
5117
+ totalLines,
5118
+ totalBytes,
5119
+ outputLines: totalLines,
5120
+ outputBytes: totalBytes,
5121
+ lastLinePartial: false,
5122
+ firstLineExceedsLimit: false,
5123
+ maxLines,
5124
+ maxBytes
5410
5125
  };
5411
- const { default: pacote } = yield* Effect.promise(() => import("pacote"));
5412
- return yield* Effect.tryPromise({
5413
- try: async () => target.kind === "registry" ? (await pacote.manifest(target.spec, opts)).version : commitIn(await pacote.resolve(target.spec, opts)),
5414
- catch: (cause) => new InstallError({
5415
- reason: "plugin-resolve-failed",
5416
- reference: target.spec,
5417
- message: `cannot reach ${target.spec}`,
5418
- cause
5419
- })
5420
- });
5421
- });
5422
- /** A `file:` URL for an entrypoint, which is what the module loader takes. */
5423
- const url = (entrypoint) => pathToFileURL(entrypoint).href;
5424
- //#endregion
5425
- //#region src/plugin/plugin.ts
5426
- var plugin_exports$1 = /* @__PURE__ */ __exportAll({});
5427
- import * as import__codeworksh_plugin_plugin from "@codeworksh/plugin/plugin";
5428
- __reExport(plugin_exports$1, import__codeworksh_plugin_plugin);
5429
- //#endregion
5430
- //#region src/plugin/source.ts
5431
- /** A committish that names one artifact rather than a moving branch: an unabbreviated SHA. */
5432
- const isCommit = (committish) => typeof committish === "string" && /^[a-f0-9]{40}$|^[a-f0-9]{64}$/i.test(committish);
5433
- /**
5434
- * A short, readable name for a git source, used to make a store path recognisable to a human
5435
- * reading `ls`. It is never an identity: two remotes can slug alike, and the digest is what
5436
- * separates them.
5437
- */
5438
- const slug = (spec) => {
5439
- const withoutCommittish = spec.split("#")[0] ?? spec;
5440
- return (() => {
5441
- try {
5442
- return decodeURIComponent(withoutCommittish);
5443
- } catch {
5444
- return withoutCommittish;
5126
+ const outputLinesArr = [];
5127
+ let outputBytesCount = 0;
5128
+ let truncatedBy = "lines";
5129
+ let lastLinePartial = false;
5130
+ for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
5131
+ const line = lines[i] ?? "";
5132
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0);
5133
+ if (outputBytesCount + lineBytes > maxBytes) {
5134
+ truncatedBy = "bytes";
5135
+ if (outputLinesArr.length === 0) {
5136
+ const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
5137
+ outputLinesArr.unshift(truncatedLine);
5138
+ outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
5139
+ lastLinePartial = true;
5140
+ }
5141
+ break;
5445
5142
  }
5446
- })().replace(/\.git$/i, "").split(/[/:\\]/).at(-1)?.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "repository";
5447
- };
5448
- /** Whether a reference names a place on disk rather than something to fetch. */
5449
- const isPath = (reference) => reference.startsWith("file:") || reference.startsWith("./") || reference.startsWith("../") || hostPath.isAbsolute(reference);
5143
+ outputLinesArr.unshift(line);
5144
+ outputBytesCount += lineBytes;
5145
+ }
5146
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) truncatedBy = "lines";
5147
+ const outputContent = outputLinesArr.join("\n");
5148
+ const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
5149
+ return {
5150
+ content: outputContent,
5151
+ truncated: true,
5152
+ truncatedBy,
5153
+ totalLines,
5154
+ totalBytes,
5155
+ outputLines: outputLinesArr.length,
5156
+ outputBytes: finalOutputBytes,
5157
+ lastLinePartial,
5158
+ firstLineExceedsLimit: false,
5159
+ maxLines,
5160
+ maxBytes
5161
+ };
5162
+ }
5450
5163
  /**
5451
- * `from` is the directory a relative spec resolves against, and the caller picks it:
5452
- *
5453
- * - a **plugin entry** anchors to the directory holding the file that declared it --
5454
- * `<root>/.codework/` for a project entry, `<home>/` for a user one, the named directory for
5455
- * `--user-config-dir`. The rule is about the file, not the project.
5456
- * - a **CLI argument** anchors to the process's startup directory, because that is where the
5457
- * person typed it.
5164
+ * Truncate a string to fit within a byte limit (from the end). Handles
5165
+ * multi-byte UTF-8 characters correctly (never splits a codepoint).
5458
5166
  */
5459
- const parse$2 = (reference, from) => Effect.gen(function* () {
5460
- const fail = (reason, message) => new SourceError({
5461
- reason,
5462
- reference,
5463
- message
5464
- });
5465
- rooted(from, "the directory a plugin reference anchors to");
5466
- if (reference.startsWith("file:")) {
5467
- if (!reference.startsWith("file:///")) return yield* fail("plugin-unsupported-source", `not an absolute file URL: ${reference}`);
5468
- return {
5469
- kind: "local",
5470
- path: fileURLToPath(reference)
5471
- };
5167
+ function truncateStringToBytesFromEnd(str, maxBytes) {
5168
+ const buf = Buffer.from(str, "utf-8");
5169
+ if (buf.length <= maxBytes) return str;
5170
+ let start = buf.length - maxBytes;
5171
+ while (start < buf.length && (buf[start] & 192) === 128) start++;
5172
+ return buf.subarray(start).toString("utf-8");
5173
+ }
5174
+ //#endregion
5175
+ //#region src/tool/accumulator.ts
5176
+ function defaultTempFilePath(prefix, suffix) {
5177
+ return posix.join(tmpdir(), `${prefix}-${suffix}.log`);
5178
+ }
5179
+ function byteLength(text) {
5180
+ return Buffer.byteLength(text, "utf-8");
5181
+ }
5182
+ var Accumulator = class {
5183
+ maxLines;
5184
+ maxBytes;
5185
+ maxRollingBytes;
5186
+ tempFilePrefix;
5187
+ decoder = new TextDecoder();
5188
+ rawChunks = [];
5189
+ tailText = "";
5190
+ tailBytes = 0;
5191
+ tailStartsAtLineBoundary = true;
5192
+ totalRawBytes = 0;
5193
+ totalDecodedBytes = 0;
5194
+ completedLines = 0;
5195
+ totalLines = 0;
5196
+ currentLineBytes = 0;
5197
+ hasOpenLine = false;
5198
+ finished = false;
5199
+ tempFilePath;
5200
+ tempFile;
5201
+ constructor(options = {}) {
5202
+ this.maxLines = options.maxLines ?? 2e3;
5203
+ this.maxBytes = options.maxBytes ?? 51200;
5204
+ this.maxRollingBytes = Math.max(this.maxBytes * 2, 1);
5205
+ this.tempFilePrefix = options.tempFilePrefix ?? "codework-output";
5472
5206
  }
5473
- const expanded = expandTilde(reference, hostPath);
5474
- if (isPath(expanded)) return {
5475
- kind: "local",
5476
- path: hostPath.resolve(from, expanded)
5477
- };
5478
- const parsed = yield* Effect.try({
5479
- try: () => npa(reference),
5480
- catch: () => fail("plugin-unsupported-source", `cannot parse plugin source: ${reference}`)
5481
- });
5482
- if (parsed.type === "git") {
5483
- const { gitSubdir } = parsed;
5484
- return {
5485
- kind: "git",
5486
- slug: slug(reference),
5487
- spec: reference,
5488
- ...parsed.gitCommittish == null ? {} : { committish: parsed.gitCommittish },
5489
- ...gitSubdir == null ? {} : { subdir: gitSubdir },
5490
- mutable: !isCommit(parsed.gitCommittish)
5491
- };
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
+ });
5492
5219
  }
5493
- if (parsed.name !== null && [
5494
- "version",
5495
- "range",
5496
- "tag"
5497
- ].includes(parsed.type)) {
5498
- const omitted = parsed.raw === parsed.name || parsed.raw === `${parsed.name}@`;
5220
+ 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
+ });
5230
+ }
5231
+ snapshot() {
5232
+ const tailTruncation = truncateTail(this.getSnapshotText(), {
5233
+ maxLines: this.maxLines,
5234
+ maxBytes: this.maxBytes
5235
+ });
5236
+ const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes;
5237
+ const truncatedBy = truncated ? tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes ? "bytes" : "lines") : null;
5238
+ const truncation = {
5239
+ ...tailTruncation,
5240
+ truncated,
5241
+ truncatedBy,
5242
+ totalLines: this.totalLines,
5243
+ totalBytes: this.totalDecodedBytes,
5244
+ maxLines: this.maxLines,
5245
+ maxBytes: this.maxBytes
5246
+ };
5499
5247
  return {
5500
- kind: "registry",
5501
- name: parsed.name,
5502
- spec: `${parsed.name}@${omitted ? "latest" : parsed.rawSpec}`,
5503
- mutable: parsed.type !== "version"
5248
+ content: truncation.content,
5249
+ truncation,
5250
+ ...this.tempFilePath === void 0 ? {} : { fullOutputPath: this.tempFilePath }
5504
5251
  };
5505
5252
  }
5506
- return yield* fail("plugin-unsupported-source", `unsupported plugin source: ${reference}`);
5507
- });
5253
+ /** Bytes in the final (possibly unterminated) line — for the partial-line footer. */
5254
+ getLastLineBytes() {
5255
+ return this.currentLineBytes;
5256
+ }
5257
+ appendDecodedText(text) {
5258
+ if (text.length === 0) return;
5259
+ const bytes = byteLength(text);
5260
+ this.totalDecodedBytes += bytes;
5261
+ this.tailText += text;
5262
+ this.tailBytes += bytes;
5263
+ if (this.tailBytes > this.maxRollingBytes * 2) this.trimTail();
5264
+ let newlines = 0;
5265
+ let lastNewline = -1;
5266
+ for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) {
5267
+ newlines++;
5268
+ lastNewline = i;
5269
+ }
5270
+ if (newlines === 0) {
5271
+ this.currentLineBytes += bytes;
5272
+ this.hasOpenLine = true;
5273
+ } else {
5274
+ this.completedLines += newlines;
5275
+ const tail = text.slice(lastNewline + 1);
5276
+ this.currentLineBytes = byteLength(tail);
5277
+ this.hasOpenLine = tail.length > 0;
5278
+ }
5279
+ this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0);
5280
+ }
5281
+ trimTail() {
5282
+ const buffer = Buffer.from(this.tailText, "utf-8");
5283
+ if (buffer.length <= this.maxRollingBytes) {
5284
+ this.tailBytes = buffer.length;
5285
+ return;
5286
+ }
5287
+ let start = buffer.length - this.maxRollingBytes;
5288
+ while (start < buffer.length && (buffer[start] & 192) === 128) start++;
5289
+ this.tailStartsAtLineBoundary = start === 0 ? this.tailStartsAtLineBoundary : buffer[start - 1] === 10;
5290
+ this.tailText = buffer.subarray(start).toString("utf-8");
5291
+ this.tailBytes = byteLength(this.tailText);
5292
+ }
5293
+ getSnapshotText() {
5294
+ if (this.tailStartsAtLineBoundary) return this.tailText;
5295
+ 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);
5310
+ }
5311
+ };
5312
+ //#endregion
5313
+ //#region src/tool/progress.ts
5314
+ var progress_exports = /* @__PURE__ */ __exportAll({});
5315
+ import * as import__codeworksh_plugin_tool_progress from "@codeworksh/plugin/tool/progress";
5316
+ __reExport(progress_exports, import__codeworksh_plugin_tool_progress);
5317
+ //#endregion
5318
+ //#region src/tool/shell.ts
5319
+ /**
5320
+ * `ToolShell` — the tool-owned, cancellable command-execution capability the bash tool depends on.
5321
+ *
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`
5332
+ */
5333
+ /** Infra/spawn failure — not model-actionable; handlers should treat it as a defect. */
5334
+ var ToolShellError = class extends Schema.TaggedError()("ToolShellError", {
5335
+ command: Schema.String,
5336
+ cause: Schema.optional(Schema.Defect())
5337
+ }) {};
5338
+ /** The command exceeded its deadline. Distinct so handlers can map it to a domain timeout. */
5339
+ var ToolShellTimeout = class extends Schema.TaggedError()("ToolShellTimeout", {
5340
+ command: Schema.String,
5341
+ timeoutMillis: Schema.Finite
5342
+ }) {};
5343
+ const widenExecError = (effect) => effect.pipe(Effect.mapError((error) => error));
5344
+ var ToolShell = class extends Context.Service()("@codeworksh/harness/tool/shell/ToolShell") {};
5508
5345
  /**
5509
- * What two references must share to be the same plugin, without installing either.
5346
+ * Bridge the existing `sandbox/Shell` into a `ToolShell`. Used for just-bash
5347
+ * (in-process) and remote providers.
5510
5348
  *
5511
- * A local path is the file it resolves to; a package is its name, so `@acme/x@1` and `@acme/x@2`
5512
- * are one package at two versions. Pure -- nothing is installed, imported or read.
5349
+ * `cwd` and `env` both pass through to the backing `sandbox/Shell`, which
5350
+ * resolves a relative `cwd` against its mount. The buffered `exec`
5351
+ * timeout is enforced here with `Effect.timeoutOption` (interruption always
5352
+ * returns control; the underlying command is only truly killed mid-flight on
5353
+ * signal-aware backends, handled in the provider layer). When the backend exposes
5354
+ * `stream` (e.g. Vercel `Command.logs`), it is bridged to {@link IToolShell.stream}
5355
+ * so the bash tool streams over it too.
5513
5356
  */
5514
- const canonical$1 = (target) => {
5515
- switch (target.kind) {
5516
- case "local": return target.path;
5517
- case "registry": return target.name;
5518
- case "git": return target.spec;
5519
- }
5520
- };
5357
+ const fromSandboxShell = Layer.effect(ToolShell, Effect.gen(function* () {
5358
+ const shell = yield* shell_exports.Shell;
5359
+ const exec = (command, options) => {
5360
+ const run = shell.exec(command, {
5361
+ ...options?.env ? { env: options.env } : {},
5362
+ ...options?.cwd ? { cwd: options.cwd } : {}
5363
+ }).pipe(Effect.mapError((cause) => new ToolShellError({
5364
+ command,
5365
+ cause
5366
+ })));
5367
+ if (options?.timeout === void 0) return widenExecError(run);
5368
+ const timeout = options.timeout;
5369
+ return run.pipe(Effect.timeoutOption(timeout), Effect.flatMap(Option.match({
5370
+ onNone: () => Effect.fail(new ToolShellTimeout({
5371
+ command,
5372
+ timeoutMillis: Duration.toMillis(timeout)
5373
+ })),
5374
+ onSome: Effect.succeed
5375
+ })));
5376
+ };
5377
+ const sandboxStream = shell.stream;
5378
+ const stream = sandboxStream ? (command, options) => sandboxStream(command, {
5379
+ ...options?.env ? { env: options.env } : {},
5380
+ ...options?.cwd ? { cwd: options.cwd } : {}
5381
+ }).pipe(Stream.map((chunk) => chunk._tag === "exit" ? {
5382
+ _tag: "Exit",
5383
+ exitCode: chunk.exitCode
5384
+ } : {
5385
+ _tag: "Output",
5386
+ bytes: chunk.bytes
5387
+ }), Stream.mapError((cause) => new ToolShellError({
5388
+ command,
5389
+ cause
5390
+ }))) : void 0;
5391
+ return ToolShell.of(stream ? {
5392
+ exec,
5393
+ stream
5394
+ } : { exec });
5395
+ }));
5396
+ Duration.seconds(1);
5521
5397
  //#endregion
5522
- //#region src/plugin/index.store.ts
5523
- const Record_ = Schema.Struct({
5524
- spec: Schema.String,
5525
- source: Schema.Literals(["registry", "git"]),
5526
- name: Schema.String,
5527
- slug: Schema.String,
5528
- mutable: Schema.Boolean,
5529
- generation: Schema.Finite,
5530
- version: Schema.optional(Schema.String),
5531
- revision: Schema.optional(Schema.String),
5532
- entrypoint: Schema.String,
5533
- installedAt: Schema.Finite,
5534
- checkedAt: Schema.optional(Schema.Finite),
5535
- outdated: Schema.optional(Schema.Boolean),
5536
- /** The remote revision observed at `checkedAt`; distinct from the installed `revision`. */
5537
- available: Schema.optional(Schema.String)
5538
- });
5539
- const File = Schema.Struct({
5540
- version: Schema.Finite,
5541
- entries: Schema.Record(Schema.String, Record_)
5542
- });
5543
- const VERSION = 1;
5544
- const NAME = "index.json";
5398
+ //#region src/tool/tool.ts
5399
+ var tool_exports = /* @__PURE__ */ __exportAll({});
5400
+ import * as import__codeworksh_plugin_tool from "@codeworksh/plugin/tool";
5401
+ __reExport(tool_exports, import__codeworksh_plugin_tool);
5402
+ //#endregion
5403
+ //#region src/plugin/builtin/tool/bash.ts
5545
5404
  /**
5546
- * Read the whole file, or start empty.
5405
+ * 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.
5547
5409
  *
5548
- * An unreadable or unparseable index is not an error: it is a cold cache. Failing here would make
5549
- * a corrupt accelerator break installs that do not need it.
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.
5417
+ *
5418
+ * Success and failure carry the *same* structured shape
5419
+ * (`output`, `exitCode`, `truncated`, `fullOutputPath`)
5420
+ * — a non-zero exit is just `exitCode !== 0`, not a different kind of result.
5550
5421
  */
5551
- const read = Effect.fn("PluginIndex.read")(function* (root) {
5552
- const parsed = yield* fileSystem.readFileString(hostPath.join(root, NAME)).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(File))), Effect.option);
5553
- if (Option.isNone(parsed) || parsed.value.version !== VERSION) return /* @__PURE__ */ new Map();
5554
- return new Map(Object.entries(parsed.value.entries));
5422
+ const BashParams = Schema.Struct({
5423
+ command: Schema.String.annotate({ description: "The bash command to execute." }),
5424
+ timeout: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0)).annotate({ description: "Optional timeout in seconds (must be greater than 0)." }))
5555
5425
  });
5556
- /** Temp file plus rename, so a reader never sees a half-written index. */
5557
- const write = Effect.fn("PluginIndex.write")(function* (root, entries) {
5558
- const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(File))({
5559
- version: VERSION,
5560
- entries: Object.fromEntries(entries)
5426
+ const outputFields = {
5427
+ /** Combined stdout + stderr, truncated for display (full output is on disk when `truncated`). */
5428
+ output: Schema.String,
5429
+ truncated: Schema.Boolean,
5430
+ fullOutputPath: Schema.optional(Schema.String)
5431
+ };
5432
+ const BashSuccess = Schema.Struct({
5433
+ ...outputFields,
5434
+ exitCode: Schema.Finite
5435
+ });
5436
+ /** Non-zero exit — expected, model-visible. Carries the same shape as success. */
5437
+ var BashFailed = class extends Schema.TaggedError()("BashFailed", {
5438
+ ...outputFields,
5439
+ exitCode: Schema.Finite
5440
+ }) {};
5441
+ /** Deadline exceeded — expected, model-visible. Carries the partial output produced so far. */
5442
+ var BashTimedOut = class extends Schema.TaggedError()("BashTimedOut", {
5443
+ ...outputFields,
5444
+ timeoutSeconds: Schema.Finite
5445
+ }) {};
5446
+ const BashFailure = Schema.Union([BashFailed, BashTimedOut]);
5447
+ const bashDef = tool_exports.define({
5448
+ name: "bash",
5449
+ label: "bash",
5450
+ 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.`,
5452
+ parameters: BashParams,
5453
+ success: BashSuccess,
5454
+ failure: BashFailure,
5455
+ encodeContent: (success) => [{
5456
+ type: "text",
5457
+ text: success.output
5458
+ }],
5459
+ encodeFailureContent: (failure) => [{
5460
+ type: "text",
5461
+ text: failure.output
5462
+ }]
5463
+ });
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}`;
5469
+ };
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}]`;
5477
+ }
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}]`;
5480
+ };
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,
5493
+ truncated: false
5494
+ };
5495
+ const fullOutputPath = yield* spillToTempFile(combined);
5496
+ return {
5497
+ output: t.content + footer(t, fullOutputPath),
5498
+ truncated: true,
5499
+ ...fullOutputPath !== void 0 ? { fullOutputPath } : {}
5500
+ };
5501
+ });
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
5507
+ };
5508
+ return {
5509
+ output: snap.content + footer(snap.truncation, snap.fullOutputPath, lastLineBytes),
5510
+ truncated: true,
5511
+ ...snap.fullOutputPath !== void 0 ? { fullOutputPath: snap.fullOutputPath } : {}
5512
+ };
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
5561
5528
  });
5562
- yield* fileSystem.makeDirectory(root, { recursive: true });
5563
- const temporary = yield* fileSystem.makeTempFile({
5564
- directory: root,
5565
- prefix: `.${NAME}.`,
5566
- suffix: ".tmp"
5529
+ return {
5530
+ exitCode: result.exitCode,
5531
+ ...present
5532
+ };
5533
+ });
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: [{
5542
+ 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
5567
5556
  });
5568
- yield* fileSystem.writeFileString(temporary, encoded).pipe(Effect.andThen(fileSystem.rename(temporary, hostPath.join(root, NAME))), Effect.ensuring(fileSystem.remove(temporary, { force: true }).pipe(Effect.ignore)));
5569
- });
5570
- /**
5571
- * Record one entry, keeping whatever else the file holds.
5572
- *
5573
- * Ignores its own failures. A store that cannot write its accelerator still works; refusing the
5574
- * install would be the accelerator deciding whether the install counted.
5575
- */
5576
- const put = Effect.fn("PluginIndex.put")(function* (root, digest, record) {
5577
- const entries = yield* read(root);
5578
- entries.set(digest, record);
5579
- yield* write(root, entries).pipe(Effect.ignore);
5580
- });
5581
- /**
5582
- * Merge fields into one record, if it is there.
5583
- *
5584
- * Unlocked, like every write here but the one that publishes a generation. Two processes checking
5585
- * at once both read the whole file and both rewrite it, so one update is lost and a record added
5586
- * in between can vanish -- and neither matters, because a lost `checkedAt` costs one network call
5587
- * and a vanished record is a miss a scan repairs.
5588
- */
5589
- const patch = Effect.fn("PluginIndex.patch")(function* (root, digest, fields) {
5590
- const entries = yield* read(root);
5591
- const existing = entries.get(digest);
5592
- if (existing === void 0) return;
5593
- entries.set(digest, {
5594
- ...existing,
5595
- ...fields
5557
+ const code = yield* Ref.get(exitCode);
5558
+ if (code === null || code !== 0) return yield* new BashFailed({
5559
+ exitCode: code ?? -1,
5560
+ ...present
5596
5561
  });
5597
- yield* write(root, entries).pipe(Effect.ignore);
5562
+ return {
5563
+ exitCode: code,
5564
+ ...present
5565
+ };
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);
5598
5570
  });
5599
- /** Forget one entry, for a store entry that has been removed. */
5600
- const drop = Effect.fn("PluginIndex.drop")(function* (root, digest) {
5601
- const entries = yield* read(root);
5602
- if (!entries.delete(digest)) return;
5603
- yield* write(root, entries).pipe(Effect.ignore);
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",
5575
+ 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));
5580
+ })
5604
5581
  });
5605
5582
  //#endregion
5606
- //#region src/plugin/lock.ts
5607
- /** How long to wait for another holder before giving up. */
5608
- const TIMEOUT = Duration.minutes(2);
5609
- /** The holder refreshes the lock's mtime this often; one not refreshed for the timeout is stale. */
5610
- const HEARTBEAT = Duration.seconds(15);
5611
- const abandoned = (directory) => Effect.gen(function* () {
5612
- const info = yield* fileSystem.stat(directory);
5613
- const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
5614
- return Option.exists(info.mtime, (mtime) => now - mtime.getTime() > Duration.toMillis(TIMEOUT));
5615
- }).pipe(Effect.orElseSucceed(() => false));
5616
- const heartbeat = (directory) => Effect.clockWith((clock) => clock.currentTimeMillis).pipe(Effect.flatMap((now) => fileSystem.utimes(directory, now, now)), Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT)));
5583
+ //#region src/plugin/builtin.ts
5617
5584
  /**
5618
- * Claims `directory` by creating it, polling while another process holds it, and releasing it when
5619
- * the surrounding scope closes.
5620
- *
5621
- * One finalizer is registered for the whole wait rather than one per 50ms poll, and a single
5622
- * attempt is `uninterruptible` so `mkdir` and recording ownership cannot be split -- an interrupt
5623
- * between them would leave behind a lock the finalizer does not know to remove.
5624
- *
5625
- * `onTimeout` names the failure, because what a caller is waiting for is the caller's to describe.
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.
5626
5587
  */
5627
- const lock = Effect.fn("PluginLock.acquire")(function* (directory, onTimeout) {
5628
- const held = yield* Ref.make(false);
5629
- yield* Effect.acquireRelease(Effect.void, () => Ref.get(held).pipe(Effect.flatMap((owned) => owned ? fileSystem.remove(directory, {
5630
- recursive: true,
5631
- force: true
5632
- }) : Effect.void), Effect.orDie));
5633
- if (!(yield* fileSystem.makeDirectory(directory).pipe(Effect.andThen(Ref.set(held, true)), Effect.as(true), Effect.catchIf((error) => error.reason._tag === "AlreadyExists", () => abandoned(directory).pipe(Effect.flatMap((stale) => stale ? fileSystem.remove(directory, {
5634
- recursive: true,
5635
- force: true
5636
- }).pipe(Effect.as(false)) : Effect.succeed(false)))), Effect.uninterruptible).pipe(Effect.repeat({
5637
- schedule: Schedule.spaced("50 millis"),
5638
- until: (owned) => owned
5639
- }), Effect.timeoutOrElse({
5640
- duration: TIMEOUT,
5641
- orElse: () => Effect.succeed(false)
5642
- })))) return yield* Effect.fail(onTimeout());
5643
- yield* Effect.forkScoped(heartbeat(directory));
5644
- });
5588
+ const builtins = Object.freeze([bashPlugin, defaultPromptPlugin]);
5645
5589
  //#endregion
5646
- //#region src/plugin/store.ts
5647
- /** Facts about the *package*, and nothing about the plugin inside it. */
5648
- const Marker = Schema.Struct({
5649
- spec: Schema.String,
5650
- name: Schema.String,
5651
- version: Schema.optional(Schema.String),
5652
- revision: Schema.optional(Schema.String),
5653
- /** Relative to the generation directory, so the marker survives the publishing rename. */
5654
- entrypoint: Schema.String,
5655
- createdAt: Schema.Finite
5656
- });
5657
- const MARKER = ".complete.json";
5658
- const STAGING = ".staging-";
5659
- const root = (cache) => hostPath.join(cache, "plugins", "v2");
5590
+ //#region src/settings/merge.ts
5660
5591
  /**
5661
- * A readable label, never an identity: two specs that slug alike share a parent directory and
5662
- * nothing more. Worth having because a greppable path is worth real money when debugging, and a
5663
- * bare hash throws away every clue.
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.
5664
5595
  */
5665
- const slugOf = (target) => target.kind === "git" ? target.slug : canonical$1(target).replace(/^@/, "").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "package";
5666
- /**
5667
- * The **full** SHA-256 of the source identity: canonical spec plus effective registry for a
5668
- * registry package, and the canonical spec alone for git.
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;
5616
+ //#endregion
5617
+ //#region src/util/module.ts
5618
+ /*!
5619
+ * Adapted from OpenCode packages/util/src/runtime/import.node.ts.
5669
5620
  *
5670
- * Not truncated. The fast path proves a hit by checking that a marker *exists* rather than by
5671
- * decoding it, and the index is keyed by digest alone, so a truncated digest would let a collision
5672
- * return the wrong package silently.
5621
+ * MIT License
5622
+ *
5623
+ * Copyright (c) 2025 opencode
5624
+ *
5625
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
5626
+ * of this software and associated documentation files (the "Software"), to deal
5627
+ * in the Software without restriction, including without limitation the rights
5628
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
5629
+ * copies of the Software, and to permit persons to whom the Software is
5630
+ * furnished to do so, subject to the following conditions:
5631
+ *
5632
+ * The above copyright notice and this permission notice shall be included in all
5633
+ * copies or substantial portions of the Software.
5634
+ *
5635
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
5636
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
5637
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
5638
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
5639
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
5640
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
5641
+ * SOFTWARE.
5673
5642
  */
5674
- const digest = Effect.fn("PluginStore.digest")(function* (target, cache, from) {
5675
- const source = target.kind === "registry" ? `${yield* registry(target, cache, from)}\0${target.spec}` : target.spec;
5676
- return Encoding.encodeHex(yield* crypto.digest("SHA-256", new TextEncoder().encode(source)));
5677
- });
5678
- const entryDir = Effect.fn("PluginStore.entryDir")(function* (target, cache, from) {
5679
- const key = yield* digest(target, cache, from);
5680
- return {
5681
- key,
5682
- directory: hostPath.join(root(cache), slugOf(target), key)
5683
- };
5684
- });
5685
- const failure$1 = (target, reason, message, cause) => new StoreError({
5686
- reason,
5687
- reference: target.spec,
5688
- message,
5689
- ...cause === void 0 ? {} : { cause }
5690
- });
5691
- /** Generation directories, oldest first. A name that is not a number is not a generation. */
5692
- const generations = Effect.fn("PluginStore.generations")(function* (directory) {
5693
- return (yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []))).filter((name) => /^\d+$/.test(name)).map((name) => Number(name)).sort((left, right) => left - right);
5694
- });
5695
- const readMarker = (directory) => fileSystem.readFileString(hostPath.join(directory, MARKER)).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Marker))), Effect.option);
5696
- const entryOf = (marker, input) => ({
5697
- ...marker,
5698
- digest: input.digest,
5699
- generation: input.generation,
5700
- directory: input.directory,
5701
- url: url(hostPath.resolve(input.directory, marker.entrypoint))
5702
- });
5703
- /** The newest generation that carries a marker. Unmarked ones are invisible: they never happened. */
5704
- const active = Effect.fn("PluginStore.active")(function* (directory, key) {
5705
- for (const generation of (yield* generations(directory)).reverse()) {
5706
- const where = hostPath.join(directory, String(generation));
5707
- const marker = yield* readMarker(where);
5708
- if (Option.isSome(marker)) return entryOf(marker.value, {
5709
- digest: key,
5710
- generation,
5711
- directory: where
5712
- });
5713
- }
5714
- });
5715
- /** What the index files about an entry, all of it derivable from the spec and the marker. */
5716
- const record = (target, entry, installedAt) => ({
5717
- spec: target.spec,
5718
- source: target.kind,
5719
- name: entry.name,
5720
- slug: slugOf(target),
5721
- mutable: target.mutable,
5722
- generation: entry.generation,
5723
- ...entry.version === void 0 ? {} : { version: entry.version },
5724
- ...entry.revision === void 0 ? {} : { revision: entry.revision },
5725
- entrypoint: entry.entrypoint,
5726
- installedAt
5727
- });
5728
5643
  /**
5729
- * Disk-only lookup. No network, no install -- this is what boot uses, and the reason booting
5730
- * offline works.
5644
+ * Packages a plugin must share with the harness instead of using the copy installed beside it.
5731
5645
  *
5732
- * The index is consulted first and is only ever a hint: a hit is proved by the marker still
5733
- * existing, and anything else falls through to a scan that repairs the index on the way past.
5646
+ * A plugin is installed into the store as its own npm tree, so `effect` and the plugin SDK land
5647
+ * there a second time. Two copies of the *same version* are still two module instances, and
5648
+ * Effect Schema's checks do not survive the crossing: a schema built by one copy and evaluated by
5649
+ * the other reports every check as failed, so a tool whose success type is `Schema.Finite` dies
5650
+ * on "Expected a finite number" for `22800`. Nothing about that is visible at registration --
5651
+ * `Schema.isSchema` passes, the JSON Schema is derived correctly, and the tool reaches the model
5652
+ * -- so it surfaces on the first call rather than at boot.
5653
+ *
5654
+ * Resolution is the right place to fix it. A symlink into the store cannot be: a generation is
5655
+ * immutable and shared by every project on the machine, while the harness doing the loading is
5656
+ * whichever one is running now, and two applications embedding the harness need not agree on
5657
+ * where their `effect` lives.
5734
5658
  */
5735
- const resolve$2 = Effect.fn("PluginStore.resolve")(function* (target, cache, from) {
5736
- const { key, directory } = yield* entryDir(target, cache, from);
5737
- const base = root(cache);
5738
- const hinted = (yield* read(base)).get(key);
5739
- if (hinted !== void 0) {
5740
- const where = hostPath.join(directory, String(hinted.generation));
5741
- const marker = yield* readMarker(where);
5742
- if (Option.isSome(marker)) return entryOf(marker.value, {
5743
- digest: key,
5744
- generation: hinted.generation,
5745
- directory: where
5659
+ const sharedPackages = /* @__PURE__ */ new Set(["effect", "@codeworksh/plugin"]);
5660
+ /** The package a bare specifier names: `effect/Schema` -> `effect`, `@a/b/c` -> `@a/b`. */
5661
+ function packageOf(specifier) {
5662
+ if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.includes(":")) return "";
5663
+ const segments = specifier.split("/");
5664
+ return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0] ?? "";
5665
+ }
5666
+ /**
5667
+ * Resolve the shared packages as though this module had imported them, for as long as `run`
5668
+ * takes. Anything else resolves exactly as it would have.
5669
+ */
5670
+ function withSharedModules(run) {
5671
+ const hook = registerHooks({ resolve(specifier, context, nextResolve) {
5672
+ return sharedPackages.has(packageOf(specifier)) ? nextResolve(specifier, {
5673
+ ...context,
5674
+ parentURL: import.meta.url
5675
+ }) : nextResolve(specifier, context);
5676
+ } });
5677
+ return run().finally(() => hook.deregister());
5678
+ }
5679
+ /** Use Node's main loader even when the caller runs inside a bundler or test VM. */
5680
+ async function importModule(specifier) {
5681
+ const imported = await withSharedModules(() => new Script(`import(${JSON.stringify(specifier)})`, { importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER }).runInThisContext());
5682
+ if (typeof imported !== "object" || imported === null) return imported;
5683
+ const module = imported;
5684
+ const exports = module["module.exports"];
5685
+ if (exports !== module.default || typeof exports !== "object" && typeof exports !== "function" || exports === null) return imported;
5686
+ return Object.assign({}, module, exports);
5687
+ }
5688
+ /** Node resolution scoped synchronously to the caller. */
5689
+ function resolveModule(specifier, directory, conditions) {
5690
+ const hook = registerHooks({ resolve(target, context, nextResolve) {
5691
+ return nextResolve(target, {
5692
+ ...context,
5693
+ parentURL: pathToFileURL(path.join(directory, "package.json")).href,
5694
+ ...conditions === void 0 ? {} : { conditions: [.../* @__PURE__ */ new Set([...context.conditions, ...conditions])] }
5746
5695
  });
5696
+ } });
5697
+ try {
5698
+ const resolve = (target) => {
5699
+ const resolved = import.meta.resolve(path.isAbsolute(target) ? pathToFileURL(target).href : target);
5700
+ if (resolved.startsWith("file:")) statSync(new URL(resolved));
5701
+ return resolved;
5702
+ };
5703
+ try {
5704
+ return resolve(specifier);
5705
+ } catch (error) {
5706
+ if (path.extname(specifier) || !missing(error)) throw error;
5707
+ for (const extension of [
5708
+ ".ts",
5709
+ ".tsx",
5710
+ ".js",
5711
+ ".jsx",
5712
+ ".mts",
5713
+ ".mjs",
5714
+ ".cts",
5715
+ ".cjs"
5716
+ ]) try {
5717
+ return resolve(specifier + extension);
5718
+ } catch (cause) {
5719
+ if (!missing(cause)) throw cause;
5720
+ }
5721
+ throw error;
5722
+ }
5723
+ } finally {
5724
+ hook.deregister();
5747
5725
  }
5748
- const scanned = yield* active(directory, key);
5749
- if (scanned === void 0) return void 0;
5750
- yield* put(base, key, record(target, scanned, scanned.createdAt));
5751
- return scanned;
5752
- }, Effect.mapError((cause) => Schema.is(StoreError)(cause) || Schema.is(InstallError)(cause) ? cause : new StoreError({
5753
- reason: "plugin-marker-invalid",
5754
- reference: "",
5755
- message: "cannot read the plugin store",
5756
- cause
5757
- })));
5726
+ }
5727
+ function missing(error) {
5728
+ return error instanceof Error && "code" in error && [
5729
+ "ENOENT",
5730
+ "ENOTDIR",
5731
+ "ERR_MODULE_NOT_FOUND"
5732
+ ].includes(String(error.code));
5733
+ }
5734
+ //#endregion
5735
+ //#region src/plugin/error.ts
5758
5736
  /**
5759
- * `resolve`, for a caller that needs the entry rather than an answer about it.
5760
- *
5761
- * `plugin remove` uses this as its installer: it has to identify what an entry names without
5762
- * fetching anything, and a spec with nothing filed is a real answer there -- the command reports
5763
- * it and moves on.
5737
+ * The reference as the person wrote it -- the string they will recognise in their settings file,
5738
+ * never the resolved path or the canonical spec. Every plugin failure carries one.
5764
5739
  */
5765
- const required = Effect.fn("PluginStore.required")(function* (target, cache, from) {
5766
- const found = yield* resolve$2(target, cache, from);
5767
- if (found === void 0) return yield* failure$1(target, "plugin-not-installed", `${target.spec} is not installed`);
5768
- return found;
5769
- });
5740
+ const Reference = Schema.String;
5770
5741
  /**
5771
- * Install if absent; hand back what is filed if present.
5742
+ * The settings file that declared the entry, when one did.
5772
5743
  *
5773
- * `refresh` forces past both the fast path and the double-check, which is what `update` needs and
5774
- * nothing else should use.
5744
+ * Absent for an embedder's own list and for anything the CLI was handed directly -- in both cases
5745
+ * the caller already knows where the string came from. It is the settings path that needs saying,
5746
+ * because several layers accumulate entries and "which file says this" is the question.
5775
5747
  */
5776
- const add = Effect.fn("PluginStore.add")(function* (target, cache, options) {
5777
- return yield* Effect.gen(function* () {
5778
- const { key, directory } = yield* entryDir(target, cache, options.from);
5779
- if (options.refresh !== true) {
5780
- const found = yield* resolve$2(target, cache, options.from);
5781
- if (found !== void 0) return { entry: found };
5782
- }
5783
- yield* fileSystem.makeDirectory(directory, { recursive: true });
5784
- yield* lock(hostPath.join(directory, ".lock"), () => failure$1(target, "plugin-lock-timeout", `timed out waiting for another installation of ${target.spec}`));
5785
- const current = yield* active(directory, key);
5786
- if (current !== void 0 && options.refresh !== true) return { entry: current };
5787
- const real = yield* fileSystem.realPath(directory).pipe(Effect.orElseSucceed(() => directory));
5788
- const staging = yield* Effect.acquireRelease(fileSystem.makeTempDirectory({
5789
- directory: real,
5790
- prefix: STAGING
5791
- }), (staged) => fileSystem.remove(staged, {
5792
- recursive: true,
5793
- force: true
5794
- }).pipe(Effect.orDie));
5795
- yield* fileSystem.writeFileString(hostPath.join(staging, "package.json"), "{\"private\":true,\"type\":\"module\"}");
5796
- const fetched = yield* download({
5797
- target,
5798
- into: staging,
5799
- cache,
5800
- from: options.from,
5801
- ...options.runner === void 0 ? {} : { runner: options.runner }
5802
- });
5803
- if (current !== void 0 && current.revision === fetched.revision) return { entry: current };
5804
- const validated = yield* options.validate(fetched);
5805
- const entrypoint = hostPath.relative(yield* fileSystem.realPath(staging), fetched.entrypoint);
5806
- if (entrypoint.startsWith("..") || hostPath.isAbsolute(entrypoint)) return yield* new InstallError({
5807
- reason: "plugin-no-entrypoint",
5808
- reference: target.spec,
5809
- message: `entrypoint escapes its installation: ${entrypoint}`
5810
- });
5811
- const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
5812
- const marker = {
5813
- spec: target.spec,
5814
- name: fetched.name,
5815
- ...fetched.version === void 0 ? {} : { version: fetched.version },
5816
- ...fetched.revision === void 0 ? {} : { revision: fetched.revision },
5817
- entrypoint,
5818
- createdAt: now
5819
- };
5820
- yield* fileSystem.writeFileString(hostPath.join(staging, MARKER), yield* Schema.encodeEffect(Schema.fromJsonString(Marker))(marker));
5821
- const previous = (yield* generations(directory)).at(-1) ?? 0;
5822
- const generation = Math.max(now, previous + 1);
5823
- const published = hostPath.join(directory, String(generation));
5824
- yield* fileSystem.rename(staging, published);
5825
- const entry = entryOf(marker, {
5826
- digest: key,
5827
- generation,
5828
- directory: published
5829
- });
5830
- yield* put(root(cache), key, record(target, entry, now));
5831
- yield* collect$1(directory);
5832
- return {
5833
- entry,
5834
- validated
5835
- };
5836
- }).pipe(Effect.scoped, Effect.mapError((cause) => Schema.is(InstallError)(cause) || Schema.is(StoreError)(cause) || Schema.is(LoadError)(cause) ? cause : failure$1(target, "plugin-marker-invalid", `cannot write the store entry for ${target.spec}`, cause)));
5837
- });
5748
+ const File$1 = Schema.optional(Schema.String);
5838
5749
  /**
5839
- * Expire what is superseded.
5750
+ * A spec that cannot be installed at all.
5840
5751
  *
5841
- * The two newest generations are kept regardless of age: the newest is live, and the one before it
5842
- * may still be imported by an exchange in flight. Runs at the end of an `add` that published
5843
- * something -- no timer and no sweeper, because installs are the only thing that creates garbage.
5752
+ * `plugin-not-found` is a local path with nothing at it; `plugin-not-installed` (the store's) is a
5753
+ * package spec with no entry. The first is a mistake in a settings file, the second is fixed by
5754
+ * `plugin install`, and keeping them apart is most of the value of having slugs.
5844
5755
  */
5845
- const collect$1 = Effect.fn("PluginStore.collect")(function* (directory) {
5846
- const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
5847
- const keep = new Set((yield* generations(directory)).slice(-2).map(String));
5848
- const names = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
5849
- for (const name of names) {
5850
- if (keep.has(name) || name === ".lock") continue;
5851
- if (name.startsWith(STAGING)) {
5852
- const started = yield* fileSystem.stat(hostPath.join(directory, name)).pipe(Effect.option);
5853
- if ((Option.isNone(started) ? 0 : Option.match(started.value.mtime, {
5854
- onNone: () => 0,
5855
- onSome: (at) => now - at.getTime()
5856
- })) > HOUR) yield* fileSystem.remove(hostPath.join(directory, name), {
5857
- recursive: true,
5858
- force: true
5859
- }).pipe(Effect.ignore);
5860
- continue;
5861
- }
5862
- if (!/^\d+$/.test(name)) continue;
5863
- if (now - Number(name) > WEEK) yield* fileSystem.remove(hostPath.join(directory, name), {
5864
- recursive: true,
5865
- force: true
5866
- }).pipe(Effect.ignore);
5867
- }
5868
- });
5869
- const HOUR = 36e5;
5870
- const WEEK = 168 * HOUR;
5756
+ var SourceError = class extends Schema.TaggedError()("PluginSourceError", {
5757
+ reason: Schema.Literals([
5758
+ "plugin-unsupported-source",
5759
+ "plugin-not-found",
5760
+ "plugin-escapes-root"
5761
+ ]),
5762
+ reference: Reference,
5763
+ file: File$1,
5764
+ message: Schema.String
5765
+ }) {};
5766
+ /** A spec that is installable, and whose install did not produce a usable package. */
5767
+ var InstallError = class extends Schema.TaggedError()("PluginInstallError", {
5768
+ reason: Schema.Literals([
5769
+ "plugin-resolve-failed",
5770
+ "plugin-fetch-failed",
5771
+ "plugin-no-commit",
5772
+ "plugin-no-entrypoint"
5773
+ ]),
5774
+ reference: Reference,
5775
+ file: File$1,
5776
+ message: Schema.String,
5777
+ cause: Schema.optional(Schema.Defect())
5778
+ }) {};
5871
5779
  /**
5872
- * How long a network answer is reused before `check` asks again.
5780
+ * A module that reached the import and did not come back as a plugin.
5873
5781
  *
5874
- * Persisted in the index rather than held in memory, because the thing that benefits is a command
5875
- * that exits: an in-memory staleness cache is worthless to `codework plugin check`.
5782
+ * Distinct from {@link InstallError}: the bytes are here and are fine as *bytes*. What failed is
5783
+ * evaluating them, or what they turned out to export -- which is the distinction a caller acts on,
5784
+ * because one is fixed by installing and the other by editing code.
5876
5785
  */
5877
- const CHECK_TTL = 36e5;
5786
+ var LoadError = class extends Schema.TaggedError()("PluginLoadError", {
5787
+ reason: Schema.Literals([
5788
+ "plugin-import-failed",
5789
+ "plugin-missing-dependency",
5790
+ "plugin-not-compiled",
5791
+ "plugin-invalid-definition"
5792
+ ]),
5793
+ reference: Reference,
5794
+ file: File$1,
5795
+ message: Schema.String,
5796
+ /** The ID the module declared, when it got far enough to declare one. */
5797
+ id: Schema.optional(Schema.String),
5798
+ cause: Schema.optional(Schema.Defect())
5799
+ }) {};
5800
+ /** The store's own failures: what is filed, what is not, and what could not be claimed. */
5801
+ var StoreError = class extends Schema.TaggedError()("PluginStoreError", {
5802
+ reason: Schema.Literals([
5803
+ "plugin-not-installed",
5804
+ "plugin-lock-timeout",
5805
+ "plugin-marker-invalid",
5806
+ "plugin-index-invalid",
5807
+ "plugin-collect-failed"
5808
+ ]),
5809
+ reference: Reference,
5810
+ file: File$1,
5811
+ message: Schema.String,
5812
+ cause: Schema.optional(Schema.Defect())
5813
+ }) {};
5878
5814
  /**
5879
- * Is a newer revision available?
5815
+ * Note which settings file declared the entry a failure came from.
5880
5816
  *
5881
- * The only network call in the store's vocabulary, and it is the caller's to make. A `probe` that
5882
- * fails -- offline, registry down, a remote that refuses -- propagates rather than answering
5883
- * `current`: an outage must not read as "you are up to date".
5817
+ * The store and the installer raise their own failures without knowing that, because neither is
5818
+ * told: they are handed a spec, not a settings layer. The loader knows, and this is where it says
5819
+ * so -- leaving an existing answer alone, since a more specific one is already better.
5884
5820
  */
5885
- const check = Effect.fn("PluginStore.check")(function* (target, cache, options) {
5886
- if (!target.mutable) return { _tag: "immutable" };
5887
- const entry = yield* resolve$2(target, cache, options.from);
5888
- if (entry === void 0) return { _tag: "not-installed" };
5889
- const key = yield* digest(target, cache, options.from).pipe(Effect.mapError((cause) => failure$1(target, "plugin-marker-invalid", `cannot address ${target.spec}`, cause)));
5890
- const base = root(cache);
5891
- const record = (yield* read(base)).get(key);
5892
- const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
5893
- if (options.refresh !== true && record?.checkedAt !== void 0 && now - record.checkedAt < CHECK_TTL && (record.outdated !== true || record.available !== void 0)) return record.outdated === true ? {
5894
- _tag: "outdated",
5895
- available: record.available,
5896
- ...entry.revision === void 0 ? {} : { filed: entry.revision }
5897
- } : {
5898
- _tag: "current",
5899
- ...entry.revision === void 0 ? {} : { revision: entry.revision }
5900
- };
5901
- const available = yield* options.probe(target);
5902
- if (available === void 0) return {
5903
- _tag: "current",
5904
- ...entry.revision === void 0 ? {} : { revision: entry.revision }
5821
+ const declaredIn = (error, file) => {
5822
+ if (error.file !== void 0) return error;
5823
+ const shared = {
5824
+ reference: error.reference,
5825
+ message: error.message,
5826
+ file
5905
5827
  };
5906
- const outdated = available !== entry.revision;
5907
- yield* patch(base, key, {
5908
- checkedAt: now,
5909
- outdated,
5910
- available
5828
+ if (Schema.is(SourceError)(error)) return new SourceError({
5829
+ ...shared,
5830
+ reason: error.reason
5911
5831
  });
5912
- return outdated ? {
5913
- _tag: "outdated",
5914
- available,
5915
- ...entry.revision === void 0 ? {} : { filed: entry.revision }
5916
- } : {
5917
- _tag: "current",
5918
- ...entry.revision === void 0 ? {} : { revision: entry.revision }
5919
- };
5920
- });
5832
+ if (Schema.is(InstallError)(error)) return new InstallError({
5833
+ ...shared,
5834
+ reason: error.reason,
5835
+ cause: error.cause
5836
+ });
5837
+ if (Schema.is(LoadError)(error)) return new LoadError({
5838
+ ...shared,
5839
+ reason: error.reason,
5840
+ id: error.id,
5841
+ cause: error.cause
5842
+ });
5843
+ if (Schema.is(StoreError)(error)) return new StoreError({
5844
+ ...shared,
5845
+ reason: error.reason,
5846
+ cause: error.cause
5847
+ });
5848
+ return error;
5849
+ };
5850
+ //#endregion
5851
+ //#region src/util/path.ts
5921
5852
  /**
5922
- * Refresh a mutable target, if the network says there is anything to refresh.
5853
+ * A directory that crosses into the harness is absolute, and a relative one is a **defect** rather
5854
+ * than an error.
5923
5855
  *
5924
- * `add` with `refresh` is what does the work; this is the guard in front of it, so an `update`
5925
- * that finds nothing new neither stages nor publishes.
5856
+ * A relative path is a disguised `process.cwd()` call: and every consumer
5857
+ * resolves it silently. `path.resolve("x")` anchors to the process directory, npm answers a
5858
+ * relative `cwd` with a `localPrefix` of whatever repository the process is inside (measured, not
5859
+ * assumed), and `Session.link` would write the *server's* directory into a field whose whole
5860
+ * purpose is to be a host path the client declared.
5861
+ *
5862
+ * For a long-running server the process directory is nobody's project, so the result is a
5863
+ * plausible answer to a question no one asked.
5864
+ * An absolute path is the one spelling none of them can quietly reinterpret.
5865
+ *
5866
+ * A defect rather than a typed error because nothing a user types reaches here. Every reference
5867
+ * goes through `parse`, and every directory comes from `Global.resolve`, a resolved `cwd` or a
5868
+ * session's `hostDir`, all of them already absolute. A relative one means a new call site got it
5869
+ * wrong, and a stack trace names that call site; a typed error would ask every caller to handle a
5870
+ * case none of them can cause.
5926
5871
  */
5927
- const update = Effect.fn("PluginStore.update")(function* (target, cache, options) {
5928
- const current = yield* resolve$2(target, cache, options.from);
5929
- if (current === void 0) return { _tag: "not-installed" };
5930
- if ((yield* check(target, cache, {
5931
- refresh: true,
5932
- probe: options.probe,
5933
- from: options.from
5934
- }))._tag !== "outdated") return {
5935
- _tag: "unchanged",
5936
- entry: current
5937
- };
5938
- const added = yield* add(target, cache, {
5939
- refresh: true,
5940
- validate: options.validate,
5941
- from: options.from,
5942
- ...options.runner === void 0 ? {} : { runner: options.runner }
5943
- });
5944
- return added.entry.generation === current.generation ? {
5945
- _tag: "unchanged",
5946
- entry: current
5947
- } : {
5948
- _tag: "updated",
5949
- entry: added.entry,
5950
- ...added.validated === void 0 ? {} : { validated: added.validated }
5951
- };
5952
- });
5953
- Effect.fn("PluginStore.remove")(function* (target, cache, from) {
5954
- const { key, directory } = yield* entryDir(target, cache, from);
5955
- const existed = yield* fileSystem.exists(directory).pipe(Effect.orElseSucceed(() => false));
5956
- yield* fileSystem.remove(directory, {
5957
- recursive: true,
5958
- force: true
5959
- }).pipe(Effect.ignore);
5960
- yield* drop(root(cache), key);
5961
- return existed;
5872
+ const rooted = (directory, role) => {
5873
+ if (hostPath.isAbsolute(directory)) return directory;
5874
+ throw new Error(`${role} must be an absolute path, got ${JSON.stringify(directory)}`);
5875
+ };
5876
+ //#endregion
5877
+ //#region src/plugin/npm.ts
5878
+ const Manifest$1 = Schema.Struct({ version: Schema.optional(Schema.String) });
5879
+ const Lockfile = Schema.Struct({ packages: Schema.optional(Schema.Record(Schema.String, Schema.Struct({ resolved: Schema.optional(Schema.String) }))) });
5880
+ /**
5881
+ * npm's flattened config: registry, scopes, auth, and cache paths.
5882
+ *
5883
+ * Built in one place, because two call sites deriving it separately is how the cache location
5884
+ * drifts between an install and the staleness check that is supposed to describe it.
5885
+ *
5886
+ * `dir` is where the `.npmrc` chain is read from, and it is the **host directory the caller
5887
+ * belongs to** -- never the staging directory and never the cache. A company repo keeps its
5888
+ * private registry and its scoped token in its own `.npmrc`, so a chain read anywhere else
5889
+ * resolves the plugin against the public registry and fails 404, or worse, installs a public
5890
+ * package of the same name. This is the registry half of auth; the git half is below.
5891
+ *
5892
+ * No `--prefix`. npm pins its local prefix to a `--prefix` on `argv` and stops walking, so
5893
+ * passing one would look up exactly `<dir>/.npmrc` and nothing above it -- wrong for a person
5894
+ * standing in `packages/x` whose token lives at the repository root. Without it npm walks up from
5895
+ * `cwd` to the nearest `package.json` or `node_modules` exactly as `npm` itself would if it were
5896
+ * run there, which is the only rule a user can predict.
5897
+ *
5898
+ * `--cache` is ours and is passed on `argv`, which outranks every `.npmrc`. npm's `flatten`
5899
+ * derives `_cacache`, `_npx` and `_tuf` from that one value, so naming the parent is enough --
5900
+ * and overriding `flat.cache` afterwards would redirect one of the three and leave `_tuf` pointing
5901
+ * at the user's home. `--home` exists to make a run self-contained; anything reached by an ambient
5902
+ * default is a test reading the developer's real machine.
5903
+ *
5904
+ * **Nothing here names an `env`.** npm hands git `opts.env` when one is present and falls back to
5905
+ * `{ ...gitDefaults, ...process.env }` when it is not, so leaving it out is what lets a git source
5906
+ * reach a private remote at all: `SSH_AUTH_SOCK` finds the agent, `HOME` finds the `~/.gitconfig`
5907
+ * that names the credential helper, and `PATH` finds `git`. Setting `env` to anything -- even to
5908
+ * a copy of `process.env` -- would be a standing invitation to prune it later and break private
5909
+ * git silently, months from the change. §15 Q2.
5910
+ */
5911
+ const options = (dir, cache) => Effect.suspend(() => {
5912
+ const root = rooted(dir, "the directory a plugin install reads its .npmrc chain from");
5913
+ const store = rooted(cache, "the npm cache");
5914
+ return npmOptions(root, store);
5962
5915
  });
5963
- Effect.fn("PluginStore.all")(function* (cache) {
5964
- const base = root(cache);
5965
- const slugs = yield* fileSystem.readDirectory(base).pipe(Effect.orElseSucceed(() => []));
5966
- const found = [];
5967
- for (const slug of slugs) {
5968
- const digests = yield* fileSystem.readDirectory(hostPath.join(base, slug)).pipe(Effect.orElseSucceed(() => []));
5969
- for (const key of digests) {
5970
- const entry = yield* active(hostPath.join(base, slug, key), key);
5971
- if (entry !== void 0) found.push(entry);
5972
- }
5916
+ const npmOptions = (dir, cache) => Effect.gen(function* () {
5917
+ const config = yield* Effect.tryPromise(async () => {
5918
+ const { default: Config } = await import("@npmcli/config");
5919
+ const { definitions, flatten, nerfDarts, shorthands } = (await import("@npmcli/config/lib/definitions/index.js")).default;
5920
+ const created = new Config({
5921
+ npmPath: fileURLToPath(new URL("..", import.meta.url)),
5922
+ cwd: dir,
5923
+ env: { ...process.env },
5924
+ argv: [
5925
+ process.execPath,
5926
+ process.execPath,
5927
+ "--cache",
5928
+ hostPath.join(cache, "npm")
5929
+ ],
5930
+ execPath: process.execPath,
5931
+ platform: process.platform,
5932
+ definitions,
5933
+ flatten,
5934
+ nerfDarts,
5935
+ shorthands,
5936
+ warn: false
5937
+ });
5938
+ await created.load();
5939
+ return created;
5940
+ }).pipe(Effect.catch((cause) => Effect.logWarning(`cannot load npm configuration from ${dir}; npm defaults apply`, cause).pipe(Effect.as(void 0))));
5941
+ if (config === void 0) return { allowGit: "root" };
5942
+ for (const kind of [
5943
+ "project",
5944
+ "user",
5945
+ "global"
5946
+ ]) {
5947
+ const loaded = config.data.get(kind);
5948
+ if (loaded?.loadError !== void 0 && loaded.loadError.code !== "ENOENT") yield* Effect.logWarning(`cannot read npm configuration ${loaded.source ?? `for ${kind}`}; npm defaults apply`, loaded.loadError);
5973
5949
  }
5974
- return found;
5975
- });
5976
- const Id = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*){2}$/));
5977
- const Definition = Schema.Struct({
5978
- id: Id,
5979
- kind: Schema.Literals(Object.keys(plugin_exports$1.domains)),
5980
- events: Schema.optional(Schema.Array(Schema.declare((value) => Predicate.hasProperty(value, "type") && Predicate.isString(value.type)))),
5981
- setup: Schema.declare((value) => Predicate.isFunction(value))
5950
+ return {
5951
+ ...config.flat,
5952
+ allowGit: "root"
5953
+ };
5982
5954
  });
5983
- /** Every plugin failure says which file to go and edit, when there is one. */
5984
- const where = (origin) => origin.file === void 0 ? {} : { file: origin.file };
5985
- const detail = (cause) => cause instanceof Error ? cause.message : typeof cause === "string" ? cause : String(cause);
5986
- /** A reference that does not name anything loadable. */
5987
- const sourceFailure = (origin, cause) => Schema.is(SourceError)(cause) ? cause : new SourceError({
5988
- reason: "plugin-not-found",
5989
- reference: origin.reference,
5990
- ...where(origin),
5991
- message: detail(cause)
5955
+ const PUBLIC_REGISTRY = "https://registry.npmjs.org/";
5956
+ /** Stable, credential-free registry identity used to address registry artifacts in the store. */
5957
+ const registry = Effect.fn("PluginNpm.registry")(function* (target, cache, from) {
5958
+ const flat = yield* options(from, cache);
5959
+ const scope = target.name.startsWith("@") ? target.name.slice(0, target.name.indexOf("/")) : void 0;
5960
+ const configured = (scope === void 0 ? void 0 : flat[`${scope}:registry`]) ?? flat.registry ?? PUBLIC_REGISTRY;
5961
+ if (typeof configured !== "string") return yield* new InstallError({
5962
+ reason: "plugin-resolve-failed",
5963
+ reference: target.spec,
5964
+ message: `npm registry for ${target.name} is not a URL`
5965
+ });
5966
+ return yield* Effect.try({
5967
+ try: () => {
5968
+ const value = new URL(configured);
5969
+ value.username = "";
5970
+ value.password = "";
5971
+ value.hash = "";
5972
+ return value.href;
5973
+ },
5974
+ catch: (cause) => new InstallError({
5975
+ reason: "plugin-resolve-failed",
5976
+ reference: target.spec,
5977
+ message: `npm registry for ${target.name} is not a valid URL: ${configured}`,
5978
+ cause
5979
+ })
5980
+ });
5992
5981
  });
5993
- /** Node's own code for a failure, when it set one. */
5994
- const codeOf = (cause) => Predicate.hasProperty(cause, "code") && Predicate.isString(cause.code) ? cause.code : void 0;
5982
+ /** A 40- or 64-hex commit out of the `resolved` URL arborist writes into the lockfile. */
5983
+ const commitIn = (resolved) => resolved?.match(/#([a-f0-9]{40}|[a-f0-9]{64})(?=::|$)/i)?.[1];
5995
5984
  /**
5996
- * A module that would not import.
5997
- *
5998
- * Two of these get their own reason, because the remedy differs and that is the whole value of a
5999
- * slug. A missing dependency means the plugin is fine and its `node_modules` is not, which a
6000
- * person fixes in the package rather than in their settings. An uncompiled one means the plugin
6001
- * shipped its sources: Node refuses to strip types under `node_modules`, and installing a plugin
6002
- * runs no lifecycle scripts, so a `prepare` build never happens either -- the author has to ship
6003
- * the build output, and nothing the user can do to their settings will help.
5985
+ * The commit a git install actually landed on, read back from the lockfile arborist wrote.
6004
5986
  *
6005
- * `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` is matched by code rather than by message: it is
6006
- * Node's documented identifier and exact, where the wording is free to change under us. Only an
6007
- * installed plugin can reach it -- a local path lives outside `node_modules`, where Node strips
6008
- * types happily, which is what keeps the single-file dev workflow working.
5987
+ * Both spellings are tried because which one exists depends on whether the tree was saved.
6009
5988
  */
6010
- const importFailure = (origin, cause) => new LoadError({
6011
- reason: codeOf(cause) === "ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING" ? "plugin-not-compiled" : /cannot find (package|module)/i.test(detail(cause)) ? "plugin-missing-dependency" : "plugin-import-failed",
6012
- reference: origin.reference,
6013
- ...where(origin),
6014
- message: detail(cause),
6015
- cause
6016
- });
6017
- /** A module that imported and is not a plugin. */
6018
- const definitionFailure = (origin, cause, id) => new LoadError({
6019
- reason: "plugin-invalid-definition",
6020
- reference: origin.reference,
6021
- ...where(origin),
6022
- message: detail(cause),
6023
- ...id === void 0 ? {} : { id },
6024
- cause
6025
- });
6026
- const validate = Effect.fn("PluginLoader.validate")(function* (input, origin, builtin = false) {
6027
- yield* Schema.decodeUnknownEffect(Definition)(input).pipe(Effect.mapError((cause) => definitionFailure(origin, cause)));
6028
- const plugin = input;
6029
- if (!builtin && plugin.id.startsWith("codework.")) return yield* definitionFailure(origin, /* @__PURE__ */ new Error("codework namespace is reserved for builtins"), plugin.id);
6030
- return plugin;
6031
- });
6032
- const Manifest = Schema.Struct({
6033
- name: Schema.optional(Schema.String),
6034
- exports: Schema.optional(Schema.Unknown)
6035
- });
6036
- const localUrl = Effect.fn("PluginLoader.localUrl")(function* (location, origin) {
6037
- const escapes = (message) => new SourceError({
6038
- reason: "plugin-escapes-root",
6039
- reference: origin.reference,
6040
- ...where(origin),
6041
- message
6042
- });
6043
- if ((yield* fileSystem.stat(location)).type !== "Directory") return { url: yield* Effect.try(() => resolveModule(location, hostPath.dirname(location))) };
6044
- const manifestPath = hostPath.join(location, "package.json");
6045
- if (!(yield* fileSystem.exists(manifestPath))) return { url: yield* Effect.try(() => resolveModule("./index", location)) };
6046
- const manifest = yield* fileSystem.readFileString(manifestPath).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))));
6047
- if (manifest.exports !== void 0) {
6048
- const name = manifest.name;
6049
- if (!name) return yield* escapes("a local package with exports must declare its name");
6050
- const url = yield* Effect.try(() => resolveModule(name, location));
6051
- const resolved = yield* fileSystem.realPath(fileURLToPath(url));
6052
- if (hostPath.relative(yield* fileSystem.realPath(location), resolved).startsWith("..")) return yield* escapes("package export escapes its root");
6053
- return {
6054
- url: pathToFileURL(resolved).href,
6055
- name
6056
- };
5989
+ const commit = Effect.fn("PluginNpm.commit")(function* (root, name) {
5990
+ for (const file of [hostPath.join(root, "package-lock.json"), hostPath.join(root, "node_modules", ".package-lock.json")]) {
5991
+ const parsed = yield* fileSystem.readFileString(file).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Lockfile))), Effect.option);
5992
+ if (Option.isNone(parsed)) continue;
5993
+ const found = commitIn(parsed.value.packages?.[`node_modules/${name}`]?.resolved);
5994
+ if (found !== void 0) return found;
6057
5995
  }
6058
- const url = yield* Effect.try(() => resolveModule(createRequire(pathToFileURL(manifestPath)).resolve(location), location));
6059
- return manifest.name === void 0 ? { url } : {
6060
- url,
6061
- name: manifest.name
6062
- };
6063
5996
  });
5997
+ /** Node error codes that mean "nothing here", as opposed to "this package is broken". */
5998
+ const notFound = /* @__PURE__ */ new Set([
5999
+ "ENOENT",
6000
+ "ENOTDIR",
6001
+ "MODULE_NOT_FOUND",
6002
+ "ERR_MODULE_NOT_FOUND",
6003
+ "ERR_PACKAGE_PATH_NOT_EXPORTED",
6004
+ "ERR_UNSUPPORTED_DIR_IMPORT"
6005
+ ]);
6064
6006
  /**
6065
- * Install a package into the store, importing and validating it **while it is still staged**.
6007
+ * The module to import out of an installed package.
6066
6008
  *
6067
- * The validation is passed down rather than done afterwards for one reason: the store publishes by
6068
- * rename, so a package that turns out not to be a plugin must be rejected before it is published.
6069
- * Publishing first would make the broken generation the newest marked one, which `resolve` returns
6070
- * forever after -- and re-running `add` would hit the fast path and return it again.
6009
+ * Ordered subpaths through the package's own `exports`, so a package that publishes a dedicated
6010
+ * `./plugin` entry is honoured and one that does not falls back to its main.
6011
+ *
6012
+ * Only "not found" is tolerated. Swallowing every error would turn a package that is broken --
6013
+ * a syntax error in its manifest, a permission problem -- into "no entrypoint", which is the
6014
+ * wrong diagnosis in the one place a person needs the right one.
6071
6015
  */
6072
- const install = Effect.fn("PluginLoader.install")(function* (target, cache, origin, options) {
6073
- const added = yield* add(target, cache, {
6074
- from: anchor$1(origin.file, options.hostDir),
6075
- validate: (fetched) => imported(url(fetched.entrypoint), origin, options.import)
6016
+ const entrypoint = (directory, name) => Effect.gen(function* () {
6017
+ for (const subpath of ["", "plugin"]) {
6018
+ const specifier = name === void 0 ? hostPath.join(directory, subpath === "" ? "index" : subpath) : hostPath.join(name, subpath);
6019
+ const resolved = yield* Effect.try(() => resolveModule(specifier, directory)).pipe(Effect.asSome, Effect.catchIf((error) => notFound.has(String(error.cause?.code)), () => Effect.succeed(Option.none())), Effect.mapError((error) => new InstallError({
6020
+ reason: "plugin-no-entrypoint",
6021
+ reference: name ?? directory,
6022
+ message: `cannot resolve an entrypoint in ${directory}`,
6023
+ cause: error
6024
+ })));
6025
+ if (Option.isSome(resolved)) return fileURLToPath(resolved.value);
6026
+ }
6027
+ return yield* new InstallError({
6028
+ reason: "plugin-no-entrypoint",
6029
+ reference: name ?? directory,
6030
+ message: `no plugin entrypoint in ${directory}`
6076
6031
  });
6077
- return {
6078
- url: added.entry.url,
6079
- ...added.entry.version === void 0 ? {} : { version: added.entry.version },
6080
- generation: added.entry.generation,
6081
- ...added.validated === void 0 ? {} : { plugin: added.validated }
6082
- };
6083
6032
  });
6084
- /**
6085
- * The directory whose `.npmrc` chain names the artifact a reference resolves to: the project that
6086
- * declared it. A settings file sits in `<root>/.codework/`, but npm reads a project `.npmrc` at the
6087
- * package boundary discovered upward from the chain's start -- the root itself -- so anchoring at
6088
- * the file's own directory would miss it. Absent a declaring file the host context stands in.
6089
- */
6090
- const anchor$1 = (file, fallback) => {
6091
- if (file === void 0) return fallback;
6092
- const directory = hostPath.dirname(file);
6093
- return hostPath.basename(directory) === ".codework" ? hostPath.dirname(directory) : directory;
6094
- };
6095
- /** The default installer, or the one the caller injected. */
6096
- 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));
6097
- /** Import a module and confirm it default-exports a plugin. */
6098
- const imported = Effect.fn("PluginLoader.imported")(function* (url, origin, load) {
6099
- const module = yield* Effect.tryPromise({
6100
- try: () => (load ?? importModule)(url),
6101
- catch: (cause) => importFailure(origin, cause)
6033
+ const reify = Effect.fn("PluginNpm.reify")(function* ({ target, into, cache, from }) {
6034
+ const flat = yield* options(from, cache);
6035
+ const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"));
6036
+ const settings = {
6037
+ ...flat,
6038
+ ...target.mutable ? {
6039
+ preferOnline: true,
6040
+ noGitRevCache: true
6041
+ } : {},
6042
+ audit: false
6043
+ };
6044
+ const arborist = new Arborist({
6045
+ ...settings,
6046
+ path: into,
6047
+ binLinks: true,
6048
+ progress: false,
6049
+ savePrefix: "",
6050
+ ignoreScripts: true
6051
+ });
6052
+ return yield* Effect.tryPromise({
6053
+ try: () => arborist.reify({
6054
+ ...settings,
6055
+ add: [target.spec],
6056
+ update: target.mutable,
6057
+ save: true,
6058
+ saveType: "prod"
6059
+ }),
6060
+ catch: (cause) => new InstallError({
6061
+ reason: "plugin-fetch-failed",
6062
+ reference: target.spec,
6063
+ message: `cannot install ${target.spec}`,
6064
+ cause
6065
+ })
6102
6066
  });
6103
- return yield* validate(Predicate.isObject(module) && "default" in module ? module.default : void 0, origin);
6104
6067
  });
6105
6068
  /**
6106
- * Import one entrypoint and confirm it is a plugin, without touching the store.
6069
+ * Fill `into` with the package `target` names, and report what landed there.
6107
6070
  *
6108
- * This is what a caller hands the store as its `validate`: the artifact is checked while it is
6109
- * still staged, so a package that turns out not to be a plugin is never published.
6110
- */
6111
- const definition$1 = (entrypoint, reference) => imported(url(entrypoint), {
6112
- index: 0,
6113
- reference
6114
- });
6115
- /**
6116
- * Install and import one reference, and report what it turned out to be, without selecting it.
6117
- * `codework plugin add` uses this to prove a package really is a plugin before it writes the
6118
- * reference into a settings file: the failure a user gets is the one they can act on — a bad
6119
- * spec, an install that did not resolve, a module that exports no plugin — rather than a broken
6120
- * configuration that only fails at the next run.
6071
+ * A local target downloads nothing: the files on disk are already the answer, and a local plugin
6072
+ * is never copied into the store.
6121
6073
  */
6122
- const inspect = Effect.fn("PluginLoader.inspect")(function* (reference, options) {
6123
- const origin = {
6124
- index: 0,
6125
- reference,
6126
- file: options.file
6074
+ const download = Effect.fn("PluginNpm.download")(function* (input) {
6075
+ const { target, into, cache, from, runner = reify } = input;
6076
+ if (target.kind === "local") return {
6077
+ directory: target.path,
6078
+ name: hostPath.basename(target.path),
6079
+ entrypoint: yield* entrypoint(target.path)
6127
6080
  };
6128
- const target = yield* parse$2(reference, options.hostDir);
6129
- const loaded = yield* load$3(target, origin, options);
6081
+ const root = yield* fileSystem.realPath(into).pipe(Effect.mapError((cause) => new InstallError({
6082
+ reason: "plugin-fetch-failed",
6083
+ reference: target.spec,
6084
+ message: `cannot resolve the staging directory ${into}`,
6085
+ cause
6086
+ })));
6087
+ const node = (yield* runner({
6088
+ target,
6089
+ into: root,
6090
+ cache,
6091
+ from
6092
+ })).edgesOut.values().next().value?.to;
6093
+ if (node === void 0) return yield* new InstallError({
6094
+ reason: "plugin-fetch-failed",
6095
+ reference: target.spec,
6096
+ message: `${target.spec} installed nothing`
6097
+ });
6098
+ const manifest = yield* fileSystem.readFileString(hostPath.join(node.path, "package.json")).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest$1))), Effect.option);
6099
+ const version = Option.isNone(manifest) ? void 0 : manifest.value.version;
6100
+ const revision = target.kind === "registry" ? version : (yield* commit(root, node.name)) ?? (yield* noCommit(target.spec));
6130
6101
  return {
6131
- id: loaded.plugin.id,
6132
- ...loaded.version === void 0 ? {} : { version: loaded.version },
6133
- ...loaded.name === void 0 ? {} : { name: loaded.name }
6102
+ directory: node.path,
6103
+ name: node.name,
6104
+ ...version === void 0 ? {} : { version },
6105
+ ...revision === void 0 ? {} : { revision },
6106
+ entrypoint: yield* entrypoint(node.path, node.name)
6134
6107
  };
6135
6108
  });
6136
- const load$3 = Effect.fn("PluginLoader.load")(function* (target, origin, options) {
6137
- 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)));
6138
- const address = target.kind === "local" && options.reload !== void 0 && options.reload > 0 ? `${installed.url}?reload=${options.reload}` : installed.url;
6139
- return {
6140
- plugin: installed.plugin ?? (yield* imported(address, origin, options.import)),
6141
- source: origin.reference,
6142
- ..."version" in installed && installed.version !== void 0 ? { version: installed.version } : {},
6143
- ..."generation" in installed && installed.generation !== void 0 ? { generation: installed.generation } : {},
6144
- ...installed.name === void 0 ? {} : { name: installed.name }
6109
+ const noCommit = (spec) => new InstallError({
6110
+ reason: "plugin-no-commit",
6111
+ reference: spec,
6112
+ message: `cannot resolve a commit for ${spec}`
6113
+ });
6114
+ /**
6115
+ * What a mutable target points at right now, without installing it.
6116
+ *
6117
+ * `preferOnline` and `noGitRevCache` are the whole point: without them pacote answers from its own
6118
+ * metadata cache, and a staleness check that reads a cache is not a staleness check. An immutable
6119
+ * target skips the call entirely -- a version and a SHA cannot move.
6120
+ */
6121
+ const probe = Effect.fn("PluginNpm.probe")(function* (target, cache, from) {
6122
+ if (target.kind === "local" || !target.mutable) return void 0;
6123
+ const opts = {
6124
+ ...yield* options(from, cache),
6125
+ preferOnline: true,
6126
+ noGitRevCache: true,
6127
+ _isRoot: true
6145
6128
  };
6129
+ const { default: pacote } = yield* Effect.promise(() => import("pacote"));
6130
+ return yield* Effect.tryPromise({
6131
+ try: async () => target.kind === "registry" ? (await pacote.manifest(target.spec, opts)).version : commitIn(await pacote.resolve(target.spec, opts)),
6132
+ catch: (cause) => new InstallError({
6133
+ reason: "plugin-resolve-failed",
6134
+ reference: target.spec,
6135
+ message: `cannot reach ${target.spec}`,
6136
+ cause
6137
+ })
6138
+ });
6146
6139
  });
6147
- /**
6148
- * The version-free, location-anchored spelling of a reference.
6149
- *
6150
- * Two references naming the same module compare equal through it: `@acme/x@1.2.0` and `@acme/x`
6151
- * are one package, and `./plugins/x.ts` is the file it resolves to from the directory that
6152
- * declared it. Pure -- nothing is installed, imported or read.
6153
- */
6154
- const canonical = (reference, hostDir) => Effect.map(parse$2(reference, hostDir), canonical$1);
6140
+ /** A `file:` URL for an entrypoint, which is what the module loader takes. */
6141
+ const url = (entrypoint) => pathToFileURL(entrypoint).href;
6155
6142
  //#endregion
6156
- //#region src/plugin/catalog.ts
6143
+ //#region src/plugin/source.ts
6144
+ /** A committish that names one artifact rather than a moving branch: an unabbreviated SHA. */
6145
+ const isCommit = (committish) => typeof committish === "string" && /^[a-f0-9]{40}$|^[a-f0-9]{64}$/i.test(committish);
6157
6146
  /**
6158
- * A definition carries `setup`; a patch carries `plugin` or `package` and no `setup`. Checking
6159
- * `setup` first matters because `Plugin` permits extra properties, so a definition holding its
6160
- * own `plugin` property must not be read as configuration.
6161
- *
6162
- * e.g
6163
- * "acme/tool@1.2.0" → module (load me)
6164
- * define({id, kind, setup, ...}) → module (I AM the definition — embedder code only)
6165
- * { plugin: "x", enabled: false } → config (don't load — adjust something already loaded)
6166
- *
6167
- * setup present → definition, always (even with a stray plugin: key)
6168
- * setup absent + plugin:/package: present → config patch
6169
- * setup absent + neither key → malformed
6147
+ * A short, readable name for a git source, used to make a store path recognisable to a human
6148
+ * reading `ls`. It is never an identity: two remotes can slug alike, and the digest is what
6149
+ * separates them.
6170
6150
  */
6171
- const isPatch = (reference) => Predicate.isObject(reference) && !Predicate.isFunction(reference) && !("setup" in reference) && ("plugin" in reference || "package" in reference);
6151
+ const slug = (spec) => {
6152
+ const withoutCommittish = spec.split("#")[0] ?? spec;
6153
+ return (() => {
6154
+ try {
6155
+ return decodeURIComponent(withoutCommittish);
6156
+ } catch {
6157
+ return withoutCommittish;
6158
+ }
6159
+ })().replace(/\.git$/i, "").split(/[/:\\]/).at(-1)?.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "repository";
6160
+ };
6161
+ /** Whether a reference names a place on disk rather than something to fetch. */
6162
+ const isPath = (reference) => reference.startsWith("file:") || reference.startsWith("./") || reference.startsWith("../") || hostPath.isAbsolute(reference);
6172
6163
  /**
6173
- * The **load pass**: store lookup and ESM import, never the network.
6174
- *
6175
- * Runs at boot, at `reload`, and at any exchange that finds a reference it cannot satisfy. It
6176
- * deliberately knows nothing about `enabled`, options or order -- those are data over modules that
6177
- * are already loaded, they cost a walk of a short array, and they belong to every exchange.
6164
+ * `from` is the directory a relative spec resolves against, and the caller picks it:
6178
6165
  *
6179
- * Loading is keyed by what a reference resolves to, so naming one module twice imports it once.
6166
+ * - a **plugin entry** anchors to the directory holding the file that declared it --
6167
+ * `<root>/.codework/` for a project entry, `<home>/` for a user one, the named directory for
6168
+ * `--user-config-dir`. The rule is about the file, not the project.
6169
+ * - a **CLI argument** anchors to the process's startup directory, because that is where the
6170
+ * person typed it.
6180
6171
  */
6181
- const load$2 = Effect.fn("PluginCatalog.load")(function* (references, options) {
6182
- const plugins = /* @__PURE__ */ new Map();
6183
- const aliases = /* @__PURE__ */ new Map();
6184
- const versions = /* @__PURE__ */ new Map();
6185
- const origins = /* @__PURE__ */ new Map();
6186
- const seen = /* @__PURE__ */ new Map();
6187
- const remember = (plugin, names, from) => {
6188
- plugins.set(plugin.id, plugin);
6189
- aliases.set(plugin.id, plugin.id);
6190
- for (const name of names) aliases.set(name, plugin.id);
6191
- if (from?.version !== void 0) versions.set(plugin.id, from.version);
6192
- if (from !== void 0) origins.set(plugin.id, from.origin);
6193
- };
6194
- /** Source metadata exists for diagnostics; a silent ID replacement is where it earns that. */
6195
- const note = (plugin, source) => plugins.has(plugin.id) ? Effect.logDebug(`plugin ${plugin.id} redefined by ${source}; the earlier definition is discarded`) : Effect.void;
6196
- for (const builtin of options.builtins) remember(yield* validate(builtin, {
6197
- index: -1,
6198
- reference: builtin.id
6199
- }, true), []);
6200
- for (const [index, reference] of references.entries()) {
6201
- if (isPatch(reference)) continue;
6202
- const declared = typeof reference === "string" ? reference : Predicate.hasProperty(reference, "id") ? reference.id : void 0;
6203
- const named = typeof declared === "string" ? declared : `<plugin object #${index}>`;
6204
- const origin = {
6205
- index,
6206
- reference: named,
6207
- file: options.declared?.get(named)
6172
+ const parse$2 = (reference, from) => Effect.gen(function* () {
6173
+ const fail = (reason, message) => new SourceError({
6174
+ reason,
6175
+ reference,
6176
+ message
6177
+ });
6178
+ rooted(from, "the directory a plugin reference anchors to");
6179
+ if (reference.startsWith("file:")) {
6180
+ if (!reference.startsWith("file:///")) return yield* fail("plugin-unsupported-source", `not an absolute file URL: ${reference}`);
6181
+ return {
6182
+ kind: "local",
6183
+ path: fileURLToPath(reference)
6208
6184
  };
6209
- if (typeof reference !== "string") {
6210
- if (typeof declared === "string" && plugins.get(declared) === reference) continue;
6211
- const plugin = yield* validate(reference, origin);
6212
- yield* note(plugin, "a supplied object");
6213
- remember(plugin, []);
6214
- continue;
6215
- }
6216
- const target = yield* parse$2(reference, options.hostDir);
6217
- const key = target.kind === "local" ? target.path : target.spec;
6218
- const already = seen.get(key);
6219
- if (already !== void 0) {
6220
- aliases.set(reference, already);
6221
- continue;
6222
- }
6223
- const definition = yield* load$3(target, origin, options);
6224
- yield* note(definition.plugin, definition.version === void 0 ? key : `${key}@${definition.version}`);
6225
- const names = target.kind === "local" ? [target.path, ...definition.name === void 0 ? [] : [definition.name]] : [canonical$1(target), target.spec];
6226
- remember(definition.plugin, [reference, ...names], {
6227
- ...definition.version === void 0 ? {} : { version: definition.version },
6228
- origin: {
6229
- reference,
6230
- ...definition.generation === void 0 ? {} : { generation: definition.generation },
6231
- ...origin.file === void 0 ? {} : { file: origin.file }
6232
- }
6233
- });
6234
- seen.set(key, definition.plugin.id);
6235
6185
  }
6236
- return {
6237
- plugins,
6238
- aliases,
6239
- versions,
6240
- origins
6186
+ const expanded = expandTilde(reference, hostPath);
6187
+ if (isPath(expanded)) return {
6188
+ kind: "local",
6189
+ path: hostPath.resolve(from, expanded)
6241
6190
  };
6191
+ const parsed = yield* Effect.try({
6192
+ try: () => npa(reference),
6193
+ catch: () => fail("plugin-unsupported-source", `cannot parse plugin source: ${reference}`)
6194
+ });
6195
+ if (parsed.type === "git") {
6196
+ const { gitSubdir } = parsed;
6197
+ return {
6198
+ kind: "git",
6199
+ slug: slug(reference),
6200
+ spec: reference,
6201
+ ...parsed.gitCommittish == null ? {} : { committish: parsed.gitCommittish },
6202
+ ...gitSubdir == null ? {} : { subdir: gitSubdir },
6203
+ mutable: !isCommit(parsed.gitCommittish)
6204
+ };
6205
+ }
6206
+ if (parsed.name !== null && [
6207
+ "version",
6208
+ "range",
6209
+ "tag"
6210
+ ].includes(parsed.type)) {
6211
+ const omitted = parsed.raw === parsed.name || parsed.raw === `${parsed.name}@`;
6212
+ return {
6213
+ kind: "registry",
6214
+ name: parsed.name,
6215
+ spec: `${parsed.name}@${omitted ? "latest" : parsed.rawSpec}`,
6216
+ mutable: parsed.type !== "version"
6217
+ };
6218
+ }
6219
+ return yield* fail("plugin-unsupported-source", `unsupported plugin source: ${reference}`);
6242
6220
  });
6243
6221
  /**
6244
- * The **config pass**: pure data over modules that are already loaded, run at every exchange.
6245
- *
6246
- * An array entry is a **module** — a definition object, a path, a `file:` URL, or a package
6247
- * spec. It takes the position it is written at, so naming a module again moves it.
6248
- *
6249
- * An object entry is **configuration** for a plugin an earlier entry (or the built-in list)
6250
- * already selected, addressed by its ID (`plugin`) or the module string (`package`) that selected
6251
- * it. It never loads, installs or reorders anything, and a name matching nothing in the selection
6252
- * is ignored rather than failing the boot — a typo costs a debug line, never a fetch.
6222
+ * What two references must share to be the same plugin, without installing either.
6253
6223
  *
6254
- * Nothing here touches the disk, which is the whole point of the split: an `options` edit, an
6255
- * `enabled` flip or a reordering applies at the next exchange without importing anything.
6224
+ * A local path is the file it resolves to; a package is its name, so `@acme/x@1` and `@acme/x@2`
6225
+ * are one package at two versions. Pure -- nothing is installed, imported or read.
6256
6226
  */
6257
- const select = Effect.fn("PluginCatalog.select")(function* (references, pool) {
6258
- const operations = /* @__PURE__ */ new Map();
6259
- const missing = [];
6260
- const choose = (id, index) => operations.set(id, {
6261
- enabled: true,
6262
- index,
6263
- options: operations.get(id)?.options ?? {}
6264
- });
6265
- for (const [index, reference] of references.entries()) {
6266
- if (isPatch(reference)) {
6267
- const named = reference.plugin ?? reference.package;
6268
- const origin = {
6269
- index,
6270
- reference: typeof named === "string" ? named : `<plugin entry #${index}>`
6271
- };
6272
- const invalid = reference.plugin !== void 0 && reference.package !== void 0 ? "names both `plugin` and `package`" : typeof named !== "string" || named.length === 0 ? "names neither `plugin` nor `package`" : reference.enabled !== void 0 && typeof reference.enabled !== "boolean" ? "`enabled` is not a boolean" : reference.options !== void 0 && !isRecord$1(reference.options) ? "`options` is not a plain object" : void 0;
6273
- if (invalid !== void 0) return yield* definitionFailure(origin, /* @__PURE__ */ new Error(`plugin entry ${invalid}`));
6274
- const id = reference.plugin === void 0 ? pool.aliases.get(reference.package) : reference.plugin;
6275
- const operation = id === void 0 ? void 0 : operations.get(id);
6276
- if (id === void 0 || operation === void 0) {
6277
- yield* Effect.logDebug(`plugin configuration ignored: nothing selected under ${named}`);
6278
- continue;
6279
- }
6280
- operations.set(id, {
6281
- enabled: reference.enabled ?? operation.enabled,
6282
- index: operation.index,
6283
- options: reference.options ?? operation.options
6284
- });
6285
- continue;
6286
- }
6287
- if (typeof reference !== "string") {
6288
- const declared = Predicate.hasProperty(reference, "id") ? reference.id : void 0;
6289
- if (typeof declared === "string" && pool.plugins.has(declared)) choose(declared, index);
6290
- continue;
6291
- }
6292
- const id = pool.aliases.get(reference);
6293
- if (id === void 0) missing.push(reference);
6294
- else choose(id, index);
6295
- }
6296
- const selection = [];
6297
- for (const [id, operation] of operations) {
6298
- if (!operation.enabled) continue;
6299
- const plugin = pool.plugins.get(id);
6300
- if (plugin !== void 0) selection.push({
6301
- plugin,
6302
- options: Object.freeze(operation.options),
6303
- index: operation.index
6304
- });
6227
+ const canonical$1 = (target) => {
6228
+ switch (target.kind) {
6229
+ case "local": return target.path;
6230
+ case "registry": return target.name;
6231
+ case "git": return target.spec;
6305
6232
  }
6306
- selection.sort((a, b) => (0, plugin_exports$1.rank)(a.plugin.kind) - (0, plugin_exports$1.rank)(b.plugin.kind) || a.index - b.index);
6307
- return {
6308
- selection: Object.freeze(selection.map(({ plugin, options }) => ({
6309
- plugin,
6310
- options
6311
- }))),
6312
- missing
6313
- };
6233
+ };
6234
+ //#endregion
6235
+ //#region src/plugin/index.store.ts
6236
+ const Record_ = Schema.Struct({
6237
+ spec: Schema.String,
6238
+ source: Schema.Literals(["registry", "git"]),
6239
+ name: Schema.String,
6240
+ slug: Schema.String,
6241
+ mutable: Schema.Boolean,
6242
+ generation: Schema.Finite,
6243
+ version: Schema.optional(Schema.String),
6244
+ revision: Schema.optional(Schema.String),
6245
+ entrypoint: Schema.String,
6246
+ installedAt: Schema.Finite,
6247
+ checkedAt: Schema.optional(Schema.Finite),
6248
+ outdated: Schema.optional(Schema.Boolean),
6249
+ /** The remote revision observed at `checkedAt`; distinct from the installed `revision`. */
6250
+ available: Schema.optional(Schema.String)
6251
+ });
6252
+ const File = Schema.Struct({
6253
+ version: Schema.Finite,
6254
+ entries: Schema.Record(Schema.String, Record_)
6314
6255
  });
6256
+ const VERSION = 1;
6257
+ const NAME = "index.json";
6315
6258
  /**
6316
- * The exchange-boundary check: has the store moved under this pool?
6259
+ * Read the whole file, or start empty.
6317
6260
  *
6318
- * Every exchange already re-reads every settings layer from disk, and the config pass already
6319
- * walks those entries against the loaded set. Two things can be settled right there, and they are
6320
- * the same question asked twice:
6261
+ * An unreadable or unparseable index is not an error: it is a cold cache. Failing here would make
6262
+ * a corrupt accelerator break installs that do not need it.
6263
+ */
6264
+ const read = Effect.fn("PluginIndex.read")(function* (root) {
6265
+ const parsed = yield* fileSystem.readFileString(hostPath.join(root, NAME)).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(File))), Effect.option);
6266
+ if (Option.isNone(parsed) || parsed.value.version !== VERSION) return /* @__PURE__ */ new Map();
6267
+ return new Map(Object.entries(parsed.value.entries));
6268
+ });
6269
+ /** Temp file plus rename, so a reader never sees a half-written index. */
6270
+ const write = Effect.fn("PluginIndex.write")(function* (root, entries) {
6271
+ const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(File))({
6272
+ version: VERSION,
6273
+ entries: Object.fromEntries(entries)
6274
+ });
6275
+ yield* fileSystem.makeDirectory(root, { recursive: true });
6276
+ const temporary = yield* fileSystem.makeTempFile({
6277
+ directory: root,
6278
+ prefix: `.${NAME}.`,
6279
+ suffix: ".tmp"
6280
+ });
6281
+ yield* fileSystem.writeFileString(temporary, encoded).pipe(Effect.andThen(fileSystem.rename(temporary, hostPath.join(root, NAME))), Effect.ensuring(fileSystem.remove(temporary, { force: true }).pipe(Effect.ignore)));
6282
+ });
6283
+ /**
6284
+ * Record one entry, keeping whatever else the file holds.
6321
6285
  *
6322
- * 1. a settings entry names a module that is not loaded, and the store holds it;
6323
- * 2. a loaded module is not the newest generation the store has.
6286
+ * Ignores its own failures. A store that cannot write its accelerator still works; refusing the
6287
+ * install would be the accelerator deciding whether the install counted.
6288
+ */
6289
+ const put = Effect.fn("PluginIndex.put")(function* (root, digest, record) {
6290
+ const entries = yield* read(root);
6291
+ entries.set(digest, record);
6292
+ yield* write(root, entries).pipe(Effect.ignore);
6293
+ });
6294
+ /**
6295
+ * Merge fields into one record, if it is there.
6324
6296
  *
6325
- * The second is what makes `plugin update` finish. Without it the question is "is this entry
6326
- * loaded?", the answer after an update is still yes, and the bytes just fetched sit on disk until
6327
- * something else forces an import.
6297
+ * Unlocked, like every write here but the one that publishes a generation. Two processes checking
6298
+ * at once both read the whole file and both rewrite it, so one update is lost and a record added
6299
+ * in between can vanish -- and neither matters, because a lost `checkedAt` costs one network call
6300
+ * and a vanished record is a miss a scan repairs.
6301
+ */
6302
+ const patch = Effect.fn("PluginIndex.patch")(function* (root, digest, fields) {
6303
+ const entries = yield* read(root);
6304
+ const existing = entries.get(digest);
6305
+ if (existing === void 0) return;
6306
+ entries.set(digest, {
6307
+ ...existing,
6308
+ ...fields
6309
+ });
6310
+ yield* write(root, entries).pipe(Effect.ignore);
6311
+ });
6312
+ /** Forget one entry, for a store entry that has been removed. */
6313
+ const drop = Effect.fn("PluginIndex.drop")(function* (root, digest) {
6314
+ const entries = yield* read(root);
6315
+ if (!entries.delete(digest)) return;
6316
+ yield* write(root, entries).pipe(Effect.ignore);
6317
+ });
6318
+ //#endregion
6319
+ //#region src/plugin/lock.ts
6320
+ /** How long to wait for another holder before giving up. */
6321
+ const TIMEOUT = Duration.minutes(2);
6322
+ /** The holder refreshes the lock's mtime this often; one not refreshed for the timeout is stale. */
6323
+ const HEARTBEAT = Duration.seconds(15);
6324
+ const abandoned = (directory) => Effect.gen(function* () {
6325
+ const info = yield* fileSystem.stat(directory);
6326
+ const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
6327
+ return Option.exists(info.mtime, (mtime) => now - mtime.getTime() > Duration.toMillis(TIMEOUT));
6328
+ }).pipe(Effect.orElseSucceed(() => false));
6329
+ const heartbeat = (directory) => Effect.clockWith((clock) => clock.currentTimeMillis).pipe(Effect.flatMap((now) => fileSystem.utimes(directory, now, now)), Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT)));
6330
+ /**
6331
+ * Claims `directory` by creating it, polling while another process holds it, and releasing it when
6332
+ * the surrounding scope closes.
6328
6333
  *
6329
- * Returns the new pool, or nothing when nothing moved -- which is the common case, and costs one
6330
- * store lookup per filed module.
6334
+ * One finalizer is registered for the whole wait rather than one per 50ms poll, and a single
6335
+ * attempt is `uninterruptible` so `mkdir` and recording ownership cannot be split -- an interrupt
6336
+ * between them would leave behind a lock the finalizer does not know to remove.
6331
6337
  *
6332
- * **This is not a watcher.** Nothing polls and no fiber exists; it is the observation that we were
6333
- * already paying for the I/O that would tell us, and discarding the answer. Three bounds keep it
6334
- * safe: the store only and never the network, so an exchange cannot block on a registry;
6335
- * resolve-only, so it cannot install; and once, at the top of a snapshot, so an exchange already
6336
- * running is unaffected.
6338
+ * `onTimeout` names the failure, because what a caller is waiting for is the caller's to describe.
6337
6339
  */
6338
- const follow = Effect.fn("PluginCatalog.follow")(function* (references, pool, options, filed) {
6339
- let moved = false;
6340
- for (const reference of references) {
6341
- if (typeof reference !== "string" || pool.aliases.has(reference)) continue;
6342
- if (Option.isSome(yield* filed(reference, anchor$1(options.declared?.get(reference), options.hostDir)))) {
6343
- moved = true;
6344
- break;
6345
- }
6346
- }
6347
- if (!moved) for (const origin of pool.origins.values()) {
6348
- if (origin.generation === void 0) continue;
6349
- const current = yield* filed(origin.reference, anchor$1(origin.file, options.hostDir));
6350
- if (Option.isSome(current) && current.value.generation !== void 0 && current.value.generation > origin.generation) {
6351
- moved = true;
6352
- break;
6353
- }
6354
- }
6355
- if (!moved) return Option.none();
6356
- const retained = [];
6357
- for (const origin of pool.origins.values()) {
6358
- if (references.includes(origin.reference) || Option.isSome(yield* filed(origin.reference, anchor$1(origin.file, options.hostDir)))) {
6359
- retained.push(origin);
6360
- continue;
6361
- }
6362
- yield* Effect.logWarning(`plugin ${origin.reference} no longer resolves; it is dropped from the loaded set`);
6363
- }
6364
- const accumulated = [...Array.from(retained, (origin) => origin.reference), ...references];
6365
- const known = /* @__PURE__ */ new Map();
6366
- for (const origin of retained) if (origin.file !== void 0) known.set(origin.reference, origin.file);
6367
- for (const [reference, file] of options.declared ?? /* @__PURE__ */ new Map()) known.set(reference, file);
6368
- return Option.some(yield* load$2(accumulated, {
6369
- ...options,
6370
- declared: known
6371
- }));
6372
- });
6373
- Effect.fn("PluginCatalog.prepare")(function* (references, options) {
6374
- const pool = yield* load$2(references, options);
6375
- return (yield* select(references, pool)).selection;
6340
+ const lock = Effect.fn("PluginLock.acquire")(function* (directory, onTimeout) {
6341
+ const held = yield* Ref.make(false);
6342
+ yield* Effect.acquireRelease(Effect.void, () => Ref.get(held).pipe(Effect.flatMap((owned) => owned ? fileSystem.remove(directory, {
6343
+ recursive: true,
6344
+ force: true
6345
+ }) : Effect.void), Effect.orDie));
6346
+ if (!(yield* fileSystem.makeDirectory(directory).pipe(Effect.andThen(Ref.set(held, true)), Effect.as(true), Effect.catchIf((error) => error.reason._tag === "AlreadyExists", () => abandoned(directory).pipe(Effect.flatMap((stale) => stale ? fileSystem.remove(directory, {
6347
+ recursive: true,
6348
+ force: true
6349
+ }).pipe(Effect.as(false)) : Effect.succeed(false)))), Effect.uninterruptible).pipe(Effect.repeat({
6350
+ schedule: Schedule.spaced("50 millis"),
6351
+ until: (owned) => owned
6352
+ }), Effect.timeoutOrElse({
6353
+ duration: TIMEOUT,
6354
+ orElse: () => Effect.succeed(false)
6355
+ })))) return yield* Effect.fail(onTimeout());
6356
+ yield* Effect.forkScoped(heartbeat(directory));
6376
6357
  });
6377
6358
  //#endregion
6378
- //#region src/plugin/builtin/prompt/default.ts
6379
- /** The default coding-agent foundation, used unless `promptCustom` replaces it. */
6380
- const foundation = `You are an expert coding assistant operating inside codework, a coding agent harness.`;
6359
+ //#region src/plugin/store.ts
6360
+ /** Facts about the *package*, and nothing about the plugin inside it. */
6361
+ const Marker = Schema.Struct({
6362
+ spec: Schema.String,
6363
+ name: Schema.String,
6364
+ version: Schema.optional(Schema.String),
6365
+ revision: Schema.optional(Schema.String),
6366
+ /** Relative to the generation directory, so the marker survives the publishing rename. */
6367
+ entrypoint: Schema.String,
6368
+ createdAt: Schema.Finite
6369
+ });
6370
+ const MARKER = ".complete.json";
6371
+ const STAGING = ".staging-";
6372
+ const root = (cache) => hostPath.join(cache, "plugins", "v2");
6381
6373
  /**
6382
- * Guidelines that hold regardless of which tools are registered.
6374
+ * A readable label, never an identity: two specs that slug alike share a parent directory and
6375
+ * nothing more. Worth having because a greppable path is worth real money when debugging, and a
6376
+ * bare hash throws away every clue.
6377
+ */
6378
+ const slugOf = (target) => target.kind === "git" ? target.slug : canonical$1(target).replace(/^@/, "").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "package";
6379
+ /**
6380
+ * The **full** SHA-256 of the source identity: canonical spec plus effective registry for a
6381
+ * registry package, and the canonical spec alone for git.
6383
6382
  *
6384
- * Kept short on purpose. Every line here is spent on every request, so a line
6385
- * earns its place only if a model measurably behaves worse without it.
6383
+ * Not truncated. The fast path proves a hit by checking that a marker *exists* rather than by
6384
+ * decoding it, and the index is keyed by digest alone, so a truncated digest would let a collision
6385
+ * return the wrong package silently.
6386
6386
  */
6387
- const standingGuidelines = [
6388
- "Be concise. Report what you did and what you found, not what you are about to do.",
6389
- "Quote exact paths and command output rather than paraphrasing them.",
6390
- "If a command fails, read the error before retrying."
6391
- ];
6392
- /** Collapse whitespace so two spellings of one guideline dedupe against each other. */
6393
- const normalize = (value) => value.trim().replace(/\s+/g, " ");
6387
+ const digest = Effect.fn("PluginStore.digest")(function* (target, cache, from) {
6388
+ 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)));
6390
+ });
6391
+ const entryDir = Effect.fn("PluginStore.entryDir")(function* (target, cache, from) {
6392
+ const key = yield* digest(target, cache, from);
6393
+ return {
6394
+ key,
6395
+ directory: hostPath.join(root(cache), slugOf(target), key)
6396
+ };
6397
+ });
6398
+ const failure$1 = (target, reason, message, cause) => new StoreError({
6399
+ reason,
6400
+ reference: target.spec,
6401
+ message,
6402
+ ...cause === void 0 ? {} : { cause }
6403
+ });
6404
+ /** Generation directories, oldest first. A name that is not a number is not a generation. */
6405
+ const generations = Effect.fn("PluginStore.generations")(function* (directory) {
6406
+ return (yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []))).filter((name) => /^\d+$/.test(name)).map((name) => Number(name)).sort((left, right) => left - right);
6407
+ });
6408
+ const readMarker = (directory) => fileSystem.readFileString(hostPath.join(directory, MARKER)).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Marker))), Effect.option);
6409
+ const entryOf = (marker, input) => ({
6410
+ ...marker,
6411
+ digest: input.digest,
6412
+ generation: input.generation,
6413
+ directory: input.directory,
6414
+ url: url(hostPath.resolve(input.directory, marker.entrypoint))
6415
+ });
6416
+ /** The newest generation that carries a marker. Unmarked ones are invisible: they never happened. */
6417
+ const active = Effect.fn("PluginStore.active")(function* (directory, key) {
6418
+ for (const generation of (yield* generations(directory)).reverse()) {
6419
+ const where = hostPath.join(directory, String(generation));
6420
+ const marker = yield* readMarker(where);
6421
+ if (Option.isSome(marker)) return entryOf(marker.value, {
6422
+ digest: key,
6423
+ generation,
6424
+ directory: where
6425
+ });
6426
+ }
6427
+ });
6428
+ /** What the index files about an entry, all of it derivable from the spec and the marker. */
6429
+ const record = (target, entry, installedAt) => ({
6430
+ spec: target.spec,
6431
+ source: target.kind,
6432
+ name: entry.name,
6433
+ slug: slugOf(target),
6434
+ mutable: target.mutable,
6435
+ generation: entry.generation,
6436
+ ...entry.version === void 0 ? {} : { version: entry.version },
6437
+ ...entry.revision === void 0 ? {} : { revision: entry.revision },
6438
+ entrypoint: entry.entrypoint,
6439
+ installedAt
6440
+ });
6394
6441
  /**
6395
- * Tool-contributed guidelines followed by the standing ones, normalized and
6396
- * deduplicated with first occurrence winning.
6442
+ * Disk-only lookup. No network, no install -- this is what boot uses, and the reason booting
6443
+ * offline works.
6397
6444
  *
6398
- * Tool order is bucket order, which is the same order the index renders in and the
6399
- * same order the provider receives definitions in -- one ordering, not three.
6445
+ * The index is consulted first and is only ever a hint: a hit is proved by the marker still
6446
+ * existing, and anything else falls through to a scan that repairs the index on the way past.
6400
6447
  */
6401
- const guidelines = (tools) => {
6402
- const seen = /* @__PURE__ */ new Set();
6403
- const collected = [];
6404
- for (const line of [...tools.flatMap((tool) => tool.promptGuidelines ?? []), ...standingGuidelines]) {
6405
- const normalized = normalize(line);
6406
- if (normalized.length === 0 || seen.has(normalized)) continue;
6407
- seen.add(normalized);
6408
- collected.push(normalized);
6448
+ const resolve$2 = Effect.fn("PluginStore.resolve")(function* (target, cache, from) {
6449
+ const { key, directory } = yield* entryDir(target, cache, from);
6450
+ const base = root(cache);
6451
+ const hinted = (yield* read(base)).get(key);
6452
+ if (hinted !== void 0) {
6453
+ const where = hostPath.join(directory, String(hinted.generation));
6454
+ const marker = yield* readMarker(where);
6455
+ if (Option.isSome(marker)) return entryOf(marker.value, {
6456
+ digest: key,
6457
+ generation: hinted.generation,
6458
+ directory: where
6459
+ });
6409
6460
  }
6410
- return collected;
6411
- };
6461
+ const scanned = yield* active(directory, key);
6462
+ if (scanned === void 0) return void 0;
6463
+ yield* put(base, key, record(target, scanned, scanned.createdAt));
6464
+ return scanned;
6465
+ }, Effect.mapError((cause) => Schema.is(StoreError)(cause) || Schema.is(InstallError)(cause) ? cause : new StoreError({
6466
+ reason: "plugin-marker-invalid",
6467
+ reference: "",
6468
+ message: "cannot read the plugin store",
6469
+ cause
6470
+ })));
6412
6471
  /**
6413
- * The rendered tool index.
6472
+ * `resolve`, for a caller that needs the entry rather than an answer about it.
6414
6473
  *
6415
- * `(none)` rather than an omitted section: a model told it has no tools behaves
6416
- * better than one left to infer it from silence.
6417
- */
6418
- const toolIndex = (tools) => {
6419
- const listed = tools.filter((tool) => tool.promptSnippet !== void 0 && tool.promptSnippet.length > 0);
6420
- if (listed.length === 0) return "(none)";
6421
- return listed.map((tool) => `- ${tool.name}: ${tool.promptSnippet}`).join("\n");
6422
- };
6423
- const assemble = (input) => {
6424
- const sections = [input.promptCustom ?? foundation];
6425
- sections.push(`Available tools:\n${toolIndex(input.tools)}`);
6426
- const lines = guidelines(input.tools);
6427
- if (lines.length > 0) sections.push(`Guidelines:\n${lines.map((line) => `- ${line}`).join("\n")}`);
6428
- const append = input.promptSystemAppend?.trim();
6429
- if (append !== void 0 && append.length > 0) sections.push(append);
6430
- sections.push(`Current working directory: ${input.directory}`);
6431
- return sections.join("\n\n");
6432
- };
6433
- /**
6434
- * A caller slot, awaited lazily. A throw or rejection fails the snapshot, which the
6435
- * host attributes to this plugin's id.
6474
+ * `plugin remove` uses this as its installer: it has to identify what an entry names without
6475
+ * fetching anything, and a spec with nothing filed is a real answer there -- the command reports
6476
+ * it and moves on.
6436
6477
  */
6437
- const slot = (ctx, resolver) => Effect.tryPromise(() => Promise.resolve(resolver?.(ctx)));
6438
- const defaultPromptPlugin = (0, plugin_exports$1.define)({
6439
- id: "codework.prompt.default",
6440
- kind: "prompt",
6441
- setup: Effect.fn("DefaultPromptPlugin.setup")(function* (ctx) {
6442
- const custom = yield* slot(ctx, ctx.config.promptCustom);
6443
- const append = yield* slot(ctx, ctx.config.promptSystemAppend);
6444
- ctx.plugin.prompt.set(assemble({
6445
- tools: ctx.plugin.tools.list(),
6446
- directory: ctx.location.directory,
6447
- ...custom === void 0 ? {} : { promptCustom: custom },
6448
- ...append === void 0 ? {} : { promptSystemAppend: append }
6449
- }));
6450
- })
6478
+ const required = Effect.fn("PluginStore.required")(function* (target, cache, from) {
6479
+ const found = yield* resolve$2(target, cache, from);
6480
+ if (found === void 0) return yield* failure$1(target, "plugin-not-installed", `${target.spec} is not installed`);
6481
+ return found;
6451
6482
  });
6452
- //#endregion
6453
- //#region src/tool/truncate.ts
6454
6483
  /**
6455
- * Shared truncation utilities for tool outputs.
6456
- *
6457
- * Truncation is based on two independent limits — whichever is hit first
6458
- * wins: a line limit and a byte limit. Lines are never split (except the bash
6459
- * tail edge case where a single line exceeds the byte limit).
6484
+ * Install if absent; hand back what is filed if present.
6460
6485
  *
6461
- * The bash tool uses {@link truncateTail} (keep the end — errors / final
6462
- * results); file readers use {@link truncateHead} (keep the beginning).
6463
- */
6464
- const DEFAULT_MAX_LINES = 2e3;
6465
- const DEFAULT_MAX_BYTES = 51200;
6466
- function splitLinesForCounting(content) {
6467
- if (content.length === 0) return [];
6468
- const lines = content.split("\n");
6469
- if (content.endsWith("\n")) lines.pop();
6470
- return lines;
6471
- }
6472
- /** Format bytes as a human-readable size. */
6473
- function formatSize(bytes) {
6474
- if (bytes < 1024) return `${bytes}B`;
6475
- if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`;
6476
- return `${(bytes / 1048576).toFixed(1)}MB`;
6477
- }
6478
- /**
6479
- * Truncate content from the tail (keep the last N lines/bytes). Suitable for
6480
- * bash output where the end matters (errors, final results). May return a
6481
- * partial first line if the last line of the original content exceeds the byte
6482
- * limit.
6486
+ * `refresh` forces past both the fast path and the double-check, which is what `update` needs and
6487
+ * nothing else should use.
6483
6488
  */
6484
- function truncateTail(content, options = {}) {
6485
- const maxLines = options.maxLines ?? 2e3;
6486
- const maxBytes = options.maxBytes ?? 51200;
6487
- const totalBytes = Buffer.byteLength(content, "utf-8");
6488
- const lines = splitLinesForCounting(content);
6489
- const totalLines = lines.length;
6490
- if (totalLines <= maxLines && totalBytes <= maxBytes) return {
6491
- content,
6492
- truncated: false,
6493
- truncatedBy: null,
6494
- totalLines,
6495
- totalBytes,
6496
- outputLines: totalLines,
6497
- outputBytes: totalBytes,
6498
- lastLinePartial: false,
6499
- firstLineExceedsLimit: false,
6500
- maxLines,
6501
- maxBytes
6502
- };
6503
- const outputLinesArr = [];
6504
- let outputBytesCount = 0;
6505
- let truncatedBy = "lines";
6506
- let lastLinePartial = false;
6507
- for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
6508
- const line = lines[i] ?? "";
6509
- const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0);
6510
- if (outputBytesCount + lineBytes > maxBytes) {
6511
- truncatedBy = "bytes";
6512
- if (outputLinesArr.length === 0) {
6513
- const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
6514
- outputLinesArr.unshift(truncatedLine);
6515
- outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
6516
- lastLinePartial = true;
6517
- }
6518
- break;
6489
+ const add = Effect.fn("PluginStore.add")(function* (target, cache, options) {
6490
+ return yield* Effect.gen(function* () {
6491
+ const { key, directory } = yield* entryDir(target, cache, options.from);
6492
+ if (options.refresh !== true) {
6493
+ const found = yield* resolve$2(target, cache, options.from);
6494
+ if (found !== void 0) return { entry: found };
6519
6495
  }
6520
- outputLinesArr.unshift(line);
6521
- outputBytesCount += lineBytes;
6522
- }
6523
- if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) truncatedBy = "lines";
6524
- const outputContent = outputLinesArr.join("\n");
6525
- const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
6526
- return {
6527
- content: outputContent,
6528
- truncated: true,
6529
- truncatedBy,
6530
- totalLines,
6531
- totalBytes,
6532
- outputLines: outputLinesArr.length,
6533
- outputBytes: finalOutputBytes,
6534
- lastLinePartial,
6535
- firstLineExceedsLimit: false,
6536
- maxLines,
6537
- maxBytes
6538
- };
6539
- }
6540
- /**
6541
- * Truncate a string to fit within a byte limit (from the end). Handles
6542
- * multi-byte UTF-8 characters correctly (never splits a codepoint).
6543
- */
6544
- function truncateStringToBytesFromEnd(str, maxBytes) {
6545
- const buf = Buffer.from(str, "utf-8");
6546
- if (buf.length <= maxBytes) return str;
6547
- let start = buf.length - maxBytes;
6548
- while (start < buf.length && (buf[start] & 192) === 128) start++;
6549
- return buf.subarray(start).toString("utf-8");
6550
- }
6551
- //#endregion
6552
- //#region src/tool/accumulator.ts
6553
- function defaultTempFilePath(prefix, suffix) {
6554
- return posix.join(tmpdir(), `${prefix}-${suffix}.log`);
6555
- }
6556
- function byteLength(text) {
6557
- return Buffer.byteLength(text, "utf-8");
6558
- }
6559
- var Accumulator = class {
6560
- maxLines;
6561
- maxBytes;
6562
- maxRollingBytes;
6563
- tempFilePrefix;
6564
- decoder = new TextDecoder();
6565
- rawChunks = [];
6566
- tailText = "";
6567
- tailBytes = 0;
6568
- tailStartsAtLineBoundary = true;
6569
- totalRawBytes = 0;
6570
- totalDecodedBytes = 0;
6571
- completedLines = 0;
6572
- totalLines = 0;
6573
- currentLineBytes = 0;
6574
- hasOpenLine = false;
6575
- finished = false;
6576
- tempFilePath;
6577
- tempFile;
6578
- constructor(options = {}) {
6579
- this.maxLines = options.maxLines ?? 2e3;
6580
- this.maxBytes = options.maxBytes ?? 51200;
6581
- this.maxRollingBytes = Math.max(this.maxBytes * 2, 1);
6582
- this.tempFilePrefix = options.tempFilePrefix ?? "codework-output";
6583
- }
6584
- append(data) {
6585
- return Effect.suspend(() => {
6586
- if (this.finished) return Effect.die(/* @__PURE__ */ new Error("cannot append to a finished output accumulator"));
6587
- this.totalRawBytes += data.length;
6588
- this.appendDecodedText(this.decoder.decode(data, { stream: true }));
6589
- if (this.tempFile !== void 0 || this.shouldUseTempFile()) return Effect.gen({ self: this }, function* () {
6590
- yield* this.ensureTempFile();
6591
- yield* this.tempFile.writeAll(data).pipe(Effect.orDie);
6592
- });
6593
- if (data.length > 0) this.rawChunks.push(data);
6594
- return Effect.void;
6595
- });
6596
- }
6597
- finish() {
6598
- return Effect.suspend(() => {
6599
- if (this.finished) return Effect.void;
6600
- this.finished = true;
6601
- this.appendDecodedText(this.decoder.decode());
6602
- return Effect.gen({ self: this }, function* () {
6603
- if (this.shouldUseTempFile()) yield* this.ensureTempFile();
6604
- if (this.tempFile !== void 0) yield* this.tempFile.sync.pipe(Effect.orDie);
6605
- });
6496
+ yield* fileSystem.makeDirectory(directory, { recursive: true });
6497
+ yield* lock(hostPath.join(directory, ".lock"), () => failure$1(target, "plugin-lock-timeout", `timed out waiting for another installation of ${target.spec}`));
6498
+ const current = yield* active(directory, key);
6499
+ if (current !== void 0 && options.refresh !== true) return { entry: current };
6500
+ const real = yield* fileSystem.realPath(directory).pipe(Effect.orElseSucceed(() => directory));
6501
+ const staging = yield* Effect.acquireRelease(fileSystem.makeTempDirectory({
6502
+ directory: real,
6503
+ prefix: STAGING
6504
+ }), (staged) => fileSystem.remove(staged, {
6505
+ recursive: true,
6506
+ force: true
6507
+ }).pipe(Effect.orDie));
6508
+ yield* fileSystem.writeFileString(hostPath.join(staging, "package.json"), "{\"private\":true,\"type\":\"module\"}");
6509
+ const fetched = yield* download({
6510
+ target,
6511
+ into: staging,
6512
+ cache,
6513
+ from: options.from,
6514
+ ...options.runner === void 0 ? {} : { runner: options.runner }
6606
6515
  });
6607
- }
6608
- snapshot() {
6609
- const tailTruncation = truncateTail(this.getSnapshotText(), {
6610
- maxLines: this.maxLines,
6611
- maxBytes: this.maxBytes
6516
+ if (current !== void 0 && current.revision === fetched.revision) return { entry: current };
6517
+ const validated = yield* options.validate(fetched);
6518
+ const entrypoint = hostPath.relative(yield* fileSystem.realPath(staging), fetched.entrypoint);
6519
+ if (entrypoint.startsWith("..") || hostPath.isAbsolute(entrypoint)) return yield* new InstallError({
6520
+ reason: "plugin-no-entrypoint",
6521
+ reference: target.spec,
6522
+ message: `entrypoint escapes its installation: ${entrypoint}`
6612
6523
  });
6613
- const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes;
6614
- const truncatedBy = truncated ? tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes ? "bytes" : "lines") : null;
6615
- const truncation = {
6616
- ...tailTruncation,
6617
- truncated,
6618
- truncatedBy,
6619
- totalLines: this.totalLines,
6620
- totalBytes: this.totalDecodedBytes,
6621
- maxLines: this.maxLines,
6622
- maxBytes: this.maxBytes
6524
+ const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
6525
+ const marker = {
6526
+ spec: target.spec,
6527
+ name: fetched.name,
6528
+ ...fetched.version === void 0 ? {} : { version: fetched.version },
6529
+ ...fetched.revision === void 0 ? {} : { revision: fetched.revision },
6530
+ entrypoint,
6531
+ createdAt: now
6623
6532
  };
6533
+ yield* fileSystem.writeFileString(hostPath.join(staging, MARKER), yield* Schema.encodeEffect(Schema.fromJsonString(Marker))(marker));
6534
+ const previous = (yield* generations(directory)).at(-1) ?? 0;
6535
+ const generation = Math.max(now, previous + 1);
6536
+ const published = hostPath.join(directory, String(generation));
6537
+ yield* fileSystem.rename(staging, published);
6538
+ const entry = entryOf(marker, {
6539
+ digest: key,
6540
+ generation,
6541
+ directory: published
6542
+ });
6543
+ yield* put(root(cache), key, record(target, entry, now));
6544
+ yield* collect$1(directory);
6624
6545
  return {
6625
- content: truncation.content,
6626
- truncation,
6627
- ...this.tempFilePath === void 0 ? {} : { fullOutputPath: this.tempFilePath }
6546
+ entry,
6547
+ validated
6628
6548
  };
6629
- }
6630
- /** Bytes in the final (possibly unterminated) line — for the partial-line footer. */
6631
- getLastLineBytes() {
6632
- return this.currentLineBytes;
6633
- }
6634
- appendDecodedText(text) {
6635
- if (text.length === 0) return;
6636
- const bytes = byteLength(text);
6637
- this.totalDecodedBytes += bytes;
6638
- this.tailText += text;
6639
- this.tailBytes += bytes;
6640
- if (this.tailBytes > this.maxRollingBytes * 2) this.trimTail();
6641
- let newlines = 0;
6642
- let lastNewline = -1;
6643
- for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) {
6644
- newlines++;
6645
- lastNewline = i;
6646
- }
6647
- if (newlines === 0) {
6648
- this.currentLineBytes += bytes;
6649
- this.hasOpenLine = true;
6650
- } else {
6651
- this.completedLines += newlines;
6652
- const tail = text.slice(lastNewline + 1);
6653
- this.currentLineBytes = byteLength(tail);
6654
- this.hasOpenLine = tail.length > 0;
6655
- }
6656
- this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0);
6657
- }
6658
- trimTail() {
6659
- const buffer = Buffer.from(this.tailText, "utf-8");
6660
- if (buffer.length <= this.maxRollingBytes) {
6661
- this.tailBytes = buffer.length;
6662
- return;
6549
+ }).pipe(Effect.scoped, Effect.mapError((cause) => Schema.is(InstallError)(cause) || Schema.is(StoreError)(cause) || Schema.is(LoadError)(cause) ? cause : failure$1(target, "plugin-marker-invalid", `cannot write the store entry for ${target.spec}`, cause)));
6550
+ });
6551
+ /**
6552
+ * Expire what is superseded.
6553
+ *
6554
+ * The two newest generations are kept regardless of age: the newest is live, and the one before it
6555
+ * may still be imported by an exchange in flight. Runs at the end of an `add` that published
6556
+ * something -- no timer and no sweeper, because installs are the only thing that creates garbage.
6557
+ */
6558
+ const collect$1 = Effect.fn("PluginStore.collect")(function* (directory) {
6559
+ const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
6560
+ const keep = new Set((yield* generations(directory)).slice(-2).map(String));
6561
+ const names = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
6562
+ for (const name of names) {
6563
+ if (keep.has(name) || name === ".lock") continue;
6564
+ if (name.startsWith(STAGING)) {
6565
+ const started = yield* fileSystem.stat(hostPath.join(directory, name)).pipe(Effect.option);
6566
+ if ((Option.isNone(started) ? 0 : Option.match(started.value.mtime, {
6567
+ onNone: () => 0,
6568
+ onSome: (at) => now - at.getTime()
6569
+ })) > HOUR) yield* fileSystem.remove(hostPath.join(directory, name), {
6570
+ recursive: true,
6571
+ force: true
6572
+ }).pipe(Effect.ignore);
6573
+ continue;
6663
6574
  }
6664
- let start = buffer.length - this.maxRollingBytes;
6665
- while (start < buffer.length && (buffer[start] & 192) === 128) start++;
6666
- this.tailStartsAtLineBoundary = start === 0 ? this.tailStartsAtLineBoundary : buffer[start - 1] === 10;
6667
- this.tailText = buffer.subarray(start).toString("utf-8");
6668
- this.tailBytes = byteLength(this.tailText);
6669
- }
6670
- getSnapshotText() {
6671
- if (this.tailStartsAtLineBoundary) return this.tailText;
6672
- const firstNewline = this.tailText.indexOf("\n");
6673
- return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1);
6674
- }
6675
- shouldUseTempFile() {
6676
- return this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines;
6677
- }
6678
- ensureTempFile() {
6679
- if (this.tempFile !== void 0) return Effect.void;
6680
- return Effect.gen({ self: this }, function* () {
6681
- const suffix = Encoding.encodeHex(yield* crypto.randomBytes(8));
6682
- this.tempFilePath = defaultTempFilePath(this.tempFilePrefix, suffix);
6683
- this.tempFile = yield* fileSystem.open(this.tempFilePath, { flag: "w" });
6684
- for (const chunk of this.rawChunks) yield* this.tempFile.writeAll(chunk);
6685
- this.rawChunks = [];
6686
- }).pipe(Effect.orDie);
6575
+ if (!/^\d+$/.test(name)) continue;
6576
+ if (now - Number(name) > WEEK) yield* fileSystem.remove(hostPath.join(directory, name), {
6577
+ recursive: true,
6578
+ force: true
6579
+ }).pipe(Effect.ignore);
6687
6580
  }
6688
- };
6689
- //#endregion
6690
- //#region src/tool/progress.ts
6691
- var progress_exports = /* @__PURE__ */ __exportAll({});
6692
- import * as import__codeworksh_plugin_tool_progress from "@codeworksh/plugin/tool/progress";
6693
- __reExport(progress_exports, import__codeworksh_plugin_tool_progress);
6694
- //#endregion
6695
- //#region src/tool/shell.ts
6581
+ });
6582
+ const HOUR = 36e5;
6583
+ const WEEK = 168 * HOUR;
6696
6584
  /**
6697
- * `ToolShell` — the tool-owned, cancellable command-execution capability the bash tool depends on.
6585
+ * How long a network answer is reused before `check` asks again.
6698
6586
  *
6699
- * It deliberately does **not** reuse `sandbox/Shell` directly: the local OS path
6700
- * provides no `Shell` at all, and `sandbox/Shell` models neither cancellation
6701
- * nor a timeout. `ToolShell` makes both part of the contract. Adapters honor the
6702
- * mid-flight process kill where the SDK allows it (local, Vercel) and fall back
6703
- * to the timeout where it does not (Daytona) — "kill where it's easy".
6587
+ * Persisted in the index rather than held in memory, because the thing that benefits is a command
6588
+ * that exits: an in-memory staleness cache is worthless to `codework plugin check`.
6589
+ */
6590
+ const CHECK_TTL = 36e5;
6591
+ /**
6592
+ * Is a newer revision available?
6704
6593
  *
6705
- * Two modes, and Local has two variants:
6706
- * - Local / full OS shell → {@link local} (default)
6707
- * - Local / just-bash → {@link fromSandboxShell} over `EnvBash`
6708
- * - Remote / provider → {@link fromSandboxShell} over `EnvDaytona` / `EnvVercel`
6594
+ * The only network call in the store's vocabulary, and it is the caller's to make. A `probe` that
6595
+ * fails -- offline, registry down, a remote that refuses -- propagates rather than answering
6596
+ * `current`: an outage must not read as "you are up to date".
6709
6597
  */
6710
- /** Infra/spawn failure — not model-actionable; handlers should treat it as a defect. */
6711
- var ToolShellError = class extends Schema.TaggedError()("ToolShellError", {
6712
- command: Schema.String,
6713
- cause: Schema.optional(Schema.Defect())
6714
- }) {};
6715
- /** The command exceeded its deadline. Distinct so handlers can map it to a domain timeout. */
6716
- var ToolShellTimeout = class extends Schema.TaggedError()("ToolShellTimeout", {
6717
- command: Schema.String,
6718
- timeoutMillis: Schema.Finite
6719
- }) {};
6720
- const widenExecError = (effect) => effect.pipe(Effect.mapError((error) => error));
6721
- var ToolShell = class extends Context.Service()("@codeworksh/harness/tool/shell/ToolShell") {};
6598
+ const check = Effect.fn("PluginStore.check")(function* (target, cache, options) {
6599
+ if (!target.mutable) return { _tag: "immutable" };
6600
+ const entry = yield* resolve$2(target, cache, options.from);
6601
+ if (entry === void 0) return { _tag: "not-installed" };
6602
+ const key = yield* digest(target, cache, options.from).pipe(Effect.mapError((cause) => failure$1(target, "plugin-marker-invalid", `cannot address ${target.spec}`, cause)));
6603
+ const base = root(cache);
6604
+ const record = (yield* read(base)).get(key);
6605
+ const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
6606
+ if (options.refresh !== true && record?.checkedAt !== void 0 && now - record.checkedAt < CHECK_TTL && (record.outdated !== true || record.available !== void 0)) return record.outdated === true ? {
6607
+ _tag: "outdated",
6608
+ available: record.available,
6609
+ ...entry.revision === void 0 ? {} : { filed: entry.revision }
6610
+ } : {
6611
+ _tag: "current",
6612
+ ...entry.revision === void 0 ? {} : { revision: entry.revision }
6613
+ };
6614
+ const available = yield* options.probe(target);
6615
+ if (available === void 0) return {
6616
+ _tag: "current",
6617
+ ...entry.revision === void 0 ? {} : { revision: entry.revision }
6618
+ };
6619
+ const outdated = available !== entry.revision;
6620
+ yield* patch(base, key, {
6621
+ checkedAt: now,
6622
+ outdated,
6623
+ available
6624
+ });
6625
+ return outdated ? {
6626
+ _tag: "outdated",
6627
+ available,
6628
+ ...entry.revision === void 0 ? {} : { filed: entry.revision }
6629
+ } : {
6630
+ _tag: "current",
6631
+ ...entry.revision === void 0 ? {} : { revision: entry.revision }
6632
+ };
6633
+ });
6722
6634
  /**
6723
- * Bridge the existing `sandbox/Shell` into a `ToolShell`. Used for just-bash
6724
- * (in-process) and remote providers.
6635
+ * Refresh a mutable target, if the network says there is anything to refresh.
6725
6636
  *
6726
- * `cwd` and `env` both pass through to the backing `sandbox/Shell`, which
6727
- * resolves a relative `cwd` against its mount. The buffered `exec`
6728
- * timeout is enforced here with `Effect.timeoutOption` (interruption always
6729
- * returns control; the underlying command is only truly killed mid-flight on
6730
- * signal-aware backends, handled in the provider layer). When the backend exposes
6731
- * `stream` (e.g. Vercel `Command.logs`), it is bridged to {@link IToolShell.stream}
6732
- * so the bash tool streams over it too.
6637
+ * `add` with `refresh` is what does the work; this is the guard in front of it, so an `update`
6638
+ * that finds nothing new neither stages nor publishes.
6733
6639
  */
6734
- const fromSandboxShell = Layer.effect(ToolShell, Effect.gen(function* () {
6735
- const shell = yield* shell_exports.Shell;
6736
- const exec = (command, options) => {
6737
- const run = shell.exec(command, {
6738
- ...options?.env ? { env: options.env } : {},
6739
- ...options?.cwd ? { cwd: options.cwd } : {}
6740
- }).pipe(Effect.mapError((cause) => new ToolShellError({
6741
- command,
6742
- cause
6743
- })));
6744
- if (options?.timeout === void 0) return widenExecError(run);
6745
- const timeout = options.timeout;
6746
- return run.pipe(Effect.timeoutOption(timeout), Effect.flatMap(Option.match({
6747
- onNone: () => Effect.fail(new ToolShellTimeout({
6748
- command,
6749
- timeoutMillis: Duration.toMillis(timeout)
6750
- })),
6751
- onSome: Effect.succeed
6752
- })));
6640
+ const update = Effect.fn("PluginStore.update")(function* (target, cache, options) {
6641
+ const current = yield* resolve$2(target, cache, options.from);
6642
+ if (current === void 0) return { _tag: "not-installed" };
6643
+ if ((yield* check(target, cache, {
6644
+ refresh: true,
6645
+ probe: options.probe,
6646
+ from: options.from
6647
+ }))._tag !== "outdated") return {
6648
+ _tag: "unchanged",
6649
+ entry: current
6753
6650
  };
6754
- const sandboxStream = shell.stream;
6755
- const stream = sandboxStream ? (command, options) => sandboxStream(command, {
6756
- ...options?.env ? { env: options.env } : {},
6757
- ...options?.cwd ? { cwd: options.cwd } : {}
6758
- }).pipe(Stream.map((chunk) => chunk._tag === "exit" ? {
6759
- _tag: "Exit",
6760
- exitCode: chunk.exitCode
6651
+ const added = yield* add(target, cache, {
6652
+ refresh: true,
6653
+ validate: options.validate,
6654
+ from: options.from,
6655
+ ...options.runner === void 0 ? {} : { runner: options.runner }
6656
+ });
6657
+ return added.entry.generation === current.generation ? {
6658
+ _tag: "unchanged",
6659
+ entry: current
6761
6660
  } : {
6762
- _tag: "Output",
6763
- bytes: chunk.bytes
6764
- }), Stream.mapError((cause) => new ToolShellError({
6765
- command,
6766
- cause
6767
- }))) : void 0;
6768
- return ToolShell.of(stream ? {
6769
- exec,
6770
- stream
6771
- } : { exec });
6772
- }));
6773
- Duration.seconds(1);
6774
- //#endregion
6775
- //#region src/tool/tool.ts
6776
- var tool_exports = /* @__PURE__ */ __exportAll({});
6777
- import * as import__codeworksh_plugin_tool from "@codeworksh/plugin/tool";
6778
- __reExport(tool_exports, import__codeworksh_plugin_tool);
6779
- //#endregion
6780
- //#region src/plugin/builtin/tool/bash.ts
6661
+ _tag: "updated",
6662
+ entry: added.entry,
6663
+ ...added.validated === void 0 ? {} : { validated: added.validated }
6664
+ };
6665
+ });
6666
+ Effect.fn("PluginStore.remove")(function* (target, cache, from) {
6667
+ const { key, directory } = yield* entryDir(target, cache, from);
6668
+ const existed = yield* fileSystem.exists(directory).pipe(Effect.orElseSucceed(() => false));
6669
+ yield* fileSystem.remove(directory, {
6670
+ recursive: true,
6671
+ force: true
6672
+ }).pipe(Effect.ignore);
6673
+ yield* drop(root(cache), key);
6674
+ return existed;
6675
+ });
6676
+ Effect.fn("PluginStore.all")(function* (cache) {
6677
+ const base = root(cache);
6678
+ const slugs = yield* fileSystem.readDirectory(base).pipe(Effect.orElseSucceed(() => []));
6679
+ const found = [];
6680
+ for (const slug of slugs) {
6681
+ const digests = yield* fileSystem.readDirectory(hostPath.join(base, slug)).pipe(Effect.orElseSucceed(() => []));
6682
+ for (const key of digests) {
6683
+ const entry = yield* active(hostPath.join(base, slug, key), key);
6684
+ if (entry !== void 0) found.push(entry);
6685
+ }
6686
+ }
6687
+ return found;
6688
+ });
6689
+ const Id = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*){2}$/));
6690
+ const Definition = Schema.Struct({
6691
+ id: Id,
6692
+ kind: Schema.Literals(Object.keys(plugin_exports$1.domains)),
6693
+ events: Schema.optional(Schema.Array(Schema.declare((value) => Predicate.hasProperty(value, "type") && Predicate.isString(value.type)))),
6694
+ setup: Schema.declare((value) => Predicate.isFunction(value))
6695
+ });
6696
+ /** Every plugin failure says which file to go and edit, when there is one. */
6697
+ const where = (origin) => origin.file === void 0 ? {} : { file: origin.file };
6698
+ const detail = (cause) => cause instanceof Error ? cause.message : typeof cause === "string" ? cause : String(cause);
6699
+ /** A reference that does not name anything loadable. */
6700
+ const sourceFailure = (origin, cause) => Schema.is(SourceError)(cause) ? cause : new SourceError({
6701
+ reason: "plugin-not-found",
6702
+ reference: origin.reference,
6703
+ ...where(origin),
6704
+ message: detail(cause)
6705
+ });
6706
+ /** Node's own code for a failure, when it set one. */
6707
+ const codeOf = (cause) => Predicate.hasProperty(cause, "code") && Predicate.isString(cause.code) ? cause.code : void 0;
6781
6708
  /**
6782
- * The self-contained bash plugin — a worked example for tool plugins. The definition is pure data;
6783
- * the handler depends only on {@link ToolShell} / {@link ToolProgress}, never
6784
- * `sandbox/Shell`, so the backend (local OS / just-bash / remote provider) is
6785
- * swapped by changing the provided Layer with no change to the tool.
6709
+ * A module that would not import.
6786
6710
  *
6787
- * Two execution paths, picked by capability:
6788
- * - **variant A (buffered)** otherwise: `ToolShell.exec` returns the full output,
6789
- * truncated once after completion.
6790
- * - **variant B (streaming)** when the backend offers `ToolShell.stream`: output
6791
- * flows through an {@link Accumulator} (bounded memory + temp-file spill)
6792
- * and is reported live via {@link ToolProgress}; on timeout the partial output
6793
- * produced so far is preserved.
6711
+ * Two of these get their own reason, because the remedy differs and that is the whole value of a
6712
+ * slug. A missing dependency means the plugin is fine and its `node_modules` is not, which a
6713
+ * person fixes in the package rather than in their settings. An uncompiled one means the plugin
6714
+ * shipped its sources: Node refuses to strip types under `node_modules`, and installing a plugin
6715
+ * runs no lifecycle scripts, so a `prepare` build never happens either -- the author has to ship
6716
+ * the build output, and nothing the user can do to their settings will help.
6794
6717
  *
6795
- * Success and failure carry the *same* structured shape
6796
- * (`output`, `exitCode`, `truncated`, `fullOutputPath`)
6797
- * — a non-zero exit is just `exitCode !== 0`, not a different kind of result.
6718
+ * `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` is matched by code rather than by message: it is
6719
+ * Node's documented identifier and exact, where the wording is free to change under us. Only an
6720
+ * installed plugin can reach it -- a local path lives outside `node_modules`, where Node strips
6721
+ * types happily, which is what keeps the single-file dev workflow working.
6798
6722
  */
6799
- const BashParams = Schema.Struct({
6800
- command: Schema.String.annotate({ description: "The bash command to execute." }),
6801
- timeout: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0)).annotate({ description: "Optional timeout in seconds (must be greater than 0)." }))
6723
+ const importFailure = (origin, cause) => new LoadError({
6724
+ reason: codeOf(cause) === "ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING" ? "plugin-not-compiled" : /cannot find (package|module)/i.test(detail(cause)) ? "plugin-missing-dependency" : "plugin-import-failed",
6725
+ reference: origin.reference,
6726
+ ...where(origin),
6727
+ message: detail(cause),
6728
+ cause
6802
6729
  });
6803
- const outputFields = {
6804
- /** Combined stdout + stderr, truncated for display (full output is on disk when `truncated`). */
6805
- output: Schema.String,
6806
- truncated: Schema.Boolean,
6807
- fullOutputPath: Schema.optional(Schema.String)
6808
- };
6809
- const BashSuccess = Schema.Struct({
6810
- ...outputFields,
6811
- exitCode: Schema.Finite
6730
+ /** A module that imported and is not a plugin. */
6731
+ const definitionFailure = (origin, cause, id) => new LoadError({
6732
+ reason: "plugin-invalid-definition",
6733
+ reference: origin.reference,
6734
+ ...where(origin),
6735
+ message: detail(cause),
6736
+ ...id === void 0 ? {} : { id },
6737
+ cause
6812
6738
  });
6813
- /** Non-zero exit — expected, model-visible. Carries the same shape as success. */
6814
- var BashFailed = class extends Schema.TaggedError()("BashFailed", {
6815
- ...outputFields,
6816
- exitCode: Schema.Finite
6817
- }) {};
6818
- /** Deadline exceeded — expected, model-visible. Carries the partial output produced so far. */
6819
- var BashTimedOut = class extends Schema.TaggedError()("BashTimedOut", {
6820
- ...outputFields,
6821
- timeoutSeconds: Schema.Finite
6822
- }) {};
6823
- const BashFailure = Schema.Union([BashFailed, BashTimedOut]);
6824
- const bashDef = tool_exports.define({
6825
- name: "bash",
6826
- label: "bash",
6827
- promptSnippet: "Execute bash commands (ls, grep, find, etc.).",
6828
- 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.`,
6829
- parameters: BashParams,
6830
- success: BashSuccess,
6831
- failure: BashFailure,
6832
- encodeContent: (success) => [{
6833
- type: "text",
6834
- text: success.output
6835
- }],
6836
- encodeFailureContent: (failure) => [{
6837
- type: "text",
6838
- text: failure.output
6839
- }]
6739
+ const validate = Effect.fn("PluginLoader.validate")(function* (input, origin, builtin = false) {
6740
+ yield* Schema.decodeUnknownEffect(Definition)(input).pipe(Effect.mapError((cause) => definitionFailure(origin, cause)));
6741
+ const plugin = input;
6742
+ if (!builtin && plugin.id.startsWith("codework.")) return yield* definitionFailure(origin, /* @__PURE__ */ new Error("codework namespace is reserved for builtins"), plugin.id);
6743
+ return plugin;
6840
6744
  });
6841
- /** Combine stdout and stderr into one stream of text, stderr after stdout. */
6842
- const combineOutput = (stdout, stderr) => {
6843
- if (stderr.length === 0) return stdout;
6844
- if (stdout.length === 0) return stderr;
6845
- return `${stdout}\n${stderr}`;
6846
- };
6847
- /** One-line footer appended to truncated, model-facing output. */
6848
- const footer = (t, fullOutputPath, lastLineBytes) => {
6849
- const where = fullOutputPath ? ` Full output: ${fullOutputPath}` : "";
6850
- const startLine = t.totalLines - t.outputLines + 1;
6851
- if (t.lastLinePartial) {
6852
- const lineSize = lastLineBytes !== void 0 ? ` (line is ${formatSize(lastLineBytes)})` : "";
6853
- return `\n\n[showing last ${formatSize(t.outputBytes)} of line ${t.totalLines}${lineSize}.${where}]`;
6745
+ const Manifest = Schema.Struct({
6746
+ name: Schema.optional(Schema.String),
6747
+ exports: Schema.optional(Schema.Unknown)
6748
+ });
6749
+ const localUrl = Effect.fn("PluginLoader.localUrl")(function* (location, origin) {
6750
+ const escapes = (message) => new SourceError({
6751
+ reason: "plugin-escapes-root",
6752
+ reference: origin.reference,
6753
+ ...where(origin),
6754
+ message
6755
+ });
6756
+ if ((yield* fileSystem.stat(location)).type !== "Directory") return { url: yield* Effect.try(() => resolveModule(location, hostPath.dirname(location))) };
6757
+ const manifestPath = hostPath.join(location, "package.json");
6758
+ if (!(yield* fileSystem.exists(manifestPath))) return { url: yield* Effect.try(() => resolveModule("./index", location)) };
6759
+ const manifest = yield* fileSystem.readFileString(manifestPath).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))));
6760
+ if (manifest.exports !== void 0) {
6761
+ const name = manifest.name;
6762
+ if (!name) return yield* escapes("a local package with exports must declare its name");
6763
+ const url = yield* Effect.try(() => resolveModule(name, location));
6764
+ const resolved = yield* fileSystem.realPath(fileURLToPath(url));
6765
+ if (hostPath.relative(yield* fileSystem.realPath(location), resolved).startsWith("..")) return yield* escapes("package export escapes its root");
6766
+ return {
6767
+ url: pathToFileURL(resolved).href,
6768
+ name
6769
+ };
6854
6770
  }
6855
- if (t.truncatedBy === "lines") return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines}.${where}]`;
6856
- return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines} (${formatSize(t.maxBytes)} limit).${where}]`;
6857
- };
6858
- /** Write full output to a host temp file (best-effort; undefined on failure). */
6859
- const spillToTempFile = (content) => Effect.gen(function* () {
6860
- const suffix = Encoding.encodeHex(yield* crypto.randomBytes(6));
6861
- const path = posix.join(tmpdir(), `codework-bash-${suffix}.log`);
6862
- yield* fileSystem.writeFileString(path, content);
6863
- return path;
6864
- }).pipe(Effect.orElseSucceed(() => void 0));
6865
- /** Variant A: truncate the buffered output once, spilling the full output if truncated. */
6866
- const presentBuffered = (combined) => Effect.gen(function* () {
6867
- const t = truncateTail(combined);
6868
- if (!t.truncated) return {
6869
- output: t.content,
6870
- truncated: false
6771
+ const url = yield* Effect.try(() => resolveModule(createRequire(pathToFileURL(manifestPath)).resolve(location), location));
6772
+ return manifest.name === void 0 ? { url } : {
6773
+ url,
6774
+ name: manifest.name
6775
+ };
6776
+ });
6777
+ /**
6778
+ * Install a package into the store, importing and validating it **while it is still staged**.
6779
+ *
6780
+ * The validation is passed down rather than done afterwards for one reason: the store publishes by
6781
+ * rename, so a package that turns out not to be a plugin must be rejected before it is published.
6782
+ * Publishing first would make the broken generation the newest marked one, which `resolve` returns
6783
+ * forever after -- and re-running `add` would hit the fast path and return it again.
6784
+ */
6785
+ const install = Effect.fn("PluginLoader.install")(function* (target, cache, origin, options) {
6786
+ const added = yield* add(target, cache, {
6787
+ from: anchor$1(origin.file, options.hostDir),
6788
+ validate: (fetched) => imported(url(fetched.entrypoint), origin, options.import)
6789
+ });
6790
+ return {
6791
+ url: added.entry.url,
6792
+ ...added.entry.version === void 0 ? {} : { version: added.entry.version },
6793
+ generation: added.entry.generation,
6794
+ ...added.validated === void 0 ? {} : { plugin: added.validated }
6795
+ };
6796
+ });
6797
+ /**
6798
+ * The directory whose `.npmrc` chain names the artifact a reference resolves to: the project that
6799
+ * declared it. A settings file sits in `<root>/.codework/`, but npm reads a project `.npmrc` at the
6800
+ * package boundary discovered upward from the chain's start -- the root itself -- so anchoring at
6801
+ * the file's own directory would miss it. Absent a declaring file the host context stands in.
6802
+ */
6803
+ const anchor$1 = (file, fallback) => {
6804
+ if (file === void 0) return fallback;
6805
+ const directory = hostPath.dirname(file);
6806
+ return hostPath.basename(directory) === ".codework" ? hostPath.dirname(directory) : directory;
6807
+ };
6808
+ /** The default installer, or the one the caller injected. */
6809
+ 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));
6810
+ /** Import a module and confirm it default-exports a plugin. */
6811
+ const imported = Effect.fn("PluginLoader.imported")(function* (url, origin, load) {
6812
+ const module = yield* Effect.tryPromise({
6813
+ try: () => (load ?? importModule)(url),
6814
+ catch: (cause) => importFailure(origin, cause)
6815
+ });
6816
+ return yield* validate(Predicate.isObject(module) && "default" in module ? module.default : void 0, origin);
6817
+ });
6818
+ /**
6819
+ * Import one entrypoint and confirm it is a plugin, without touching the store.
6820
+ *
6821
+ * This is what a caller hands the store as its `validate`: the artifact is checked while it is
6822
+ * still staged, so a package that turns out not to be a plugin is never published.
6823
+ */
6824
+ const definition$1 = (entrypoint, reference) => imported(url(entrypoint), {
6825
+ index: 0,
6826
+ reference
6827
+ });
6828
+ /**
6829
+ * Install and import one reference, and report what it turned out to be, without selecting it.
6830
+ * `codework plugin add` uses this to prove a package really is a plugin before it writes the
6831
+ * reference into a settings file: the failure a user gets is the one they can act on — a bad
6832
+ * spec, an install that did not resolve, a module that exports no plugin — rather than a broken
6833
+ * configuration that only fails at the next run.
6834
+ */
6835
+ const inspect = Effect.fn("PluginLoader.inspect")(function* (reference, options) {
6836
+ const origin = {
6837
+ index: 0,
6838
+ reference,
6839
+ file: options.file
6871
6840
  };
6872
- const fullOutputPath = yield* spillToTempFile(combined);
6841
+ const target = yield* parse$2(reference, options.hostDir);
6842
+ const loaded = yield* load$3(target, origin, options);
6873
6843
  return {
6874
- output: t.content + footer(t, fullOutputPath),
6875
- truncated: true,
6876
- ...fullOutputPath !== void 0 ? { fullOutputPath } : {}
6844
+ id: loaded.plugin.id,
6845
+ ...loaded.version === void 0 ? {} : { version: loaded.version },
6846
+ ...loaded.name === void 0 ? {} : { name: loaded.name }
6877
6847
  };
6878
6848
  });
6879
- /** Variant B: present an accumulator snapshot (the temp file is spilled incrementally). */
6880
- const presentSnapshot = (snap, lastLineBytes) => {
6881
- if (!snap.truncation.truncated) return {
6882
- output: snap.content,
6883
- truncated: false
6884
- };
6849
+ const load$3 = Effect.fn("PluginLoader.load")(function* (target, origin, options) {
6850
+ 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)));
6851
+ const address = target.kind === "local" && options.reload !== void 0 && options.reload > 0 ? `${installed.url}?reload=${options.reload}` : installed.url;
6885
6852
  return {
6886
- output: snap.content + footer(snap.truncation, snap.fullOutputPath, lastLineBytes),
6887
- truncated: true,
6888
- ...snap.fullOutputPath !== void 0 ? { fullOutputPath: snap.fullOutputPath } : {}
6853
+ plugin: installed.plugin ?? (yield* imported(address, origin, options.import)),
6854
+ source: origin.reference,
6855
+ ..."version" in installed && installed.version !== void 0 ? { version: installed.version } : {},
6856
+ ..."generation" in installed && installed.generation !== void 0 ? { generation: installed.generation } : {},
6857
+ ...installed.name === void 0 ? {} : { name: installed.name }
6889
6858
  };
6890
- };
6891
- /** Variant A — buffered: one `exec`, truncate after completion. No live progress. */
6892
- const runBuffered = (shell, params) => Effect.gen(function* () {
6893
- const result = yield* shell.exec(params.command, params.timeout !== void 0 ? { timeout: Duration.seconds(params.timeout) } : void 0).pipe(Effect.catchTags({
6894
- ToolShellTimeout: (timeout) => Effect.fail(new BashTimedOut({
6895
- timeoutSeconds: timeout.timeoutMillis / 1e3,
6896
- output: "",
6897
- truncated: false
6898
- })),
6899
- ToolShellError: (cause) => Effect.die(cause)
6900
- }));
6901
- const present = yield* presentBuffered(combineOutput(result.stdout, result.stderr));
6902
- if (result.exitCode !== 0) return yield* new BashFailed({
6903
- exitCode: result.exitCode,
6904
- ...present
6905
- });
6859
+ });
6860
+ /**
6861
+ * The version-free, location-anchored spelling of a reference.
6862
+ *
6863
+ * Two references naming the same module compare equal through it: `@acme/x@1.2.0` and `@acme/x`
6864
+ * are one package, and `./plugins/x.ts` is the file it resolves to from the directory that
6865
+ * declared it. Pure -- nothing is installed, imported or read.
6866
+ */
6867
+ const canonical = (reference, hostDir) => Effect.map(parse$2(reference, hostDir), canonical$1);
6868
+ //#endregion
6869
+ //#region src/plugin/catalog.ts
6870
+ /**
6871
+ * A definition carries `setup`; a patch carries `plugin` or `package` and no `setup`. Checking
6872
+ * `setup` first matters because `Plugin` permits extra properties, so a definition holding its
6873
+ * own `plugin` property must not be read as configuration.
6874
+ *
6875
+ * e.g
6876
+ * "acme/tool@1.2.0" → module (load me)
6877
+ * define({id, kind, setup, ...}) → module (I AM the definition — embedder code only)
6878
+ * { plugin: "x", enabled: false } → config (don't load — adjust something already loaded)
6879
+ *
6880
+ * setup present → definition, always (even with a stray plugin: key)
6881
+ * setup absent + plugin:/package: present → config patch
6882
+ * setup absent + neither key → malformed
6883
+ */
6884
+ const isPatch = (reference) => Predicate.isObject(reference) && !Predicate.isFunction(reference) && !("setup" in reference) && ("plugin" in reference || "package" in reference);
6885
+ /**
6886
+ * The **load pass**: store lookup and ESM import, never the network.
6887
+ *
6888
+ * Runs at boot, at `reload`, and at any exchange that finds a reference it cannot satisfy. It
6889
+ * deliberately knows nothing about `enabled`, options or order -- those are data over modules that
6890
+ * are already loaded, they cost a walk of a short array, and they belong to every exchange.
6891
+ *
6892
+ * Loading is keyed by what a reference resolves to, so naming one module twice imports it once.
6893
+ */
6894
+ const load$2 = Effect.fn("PluginCatalog.load")(function* (references, options) {
6895
+ const plugins = /* @__PURE__ */ new Map();
6896
+ const aliases = /* @__PURE__ */ new Map();
6897
+ const versions = /* @__PURE__ */ new Map();
6898
+ const origins = /* @__PURE__ */ new Map();
6899
+ const seen = /* @__PURE__ */ new Map();
6900
+ const remember = (plugin, names, from) => {
6901
+ plugins.set(plugin.id, plugin);
6902
+ aliases.set(plugin.id, plugin.id);
6903
+ for (const name of names) aliases.set(name, plugin.id);
6904
+ if (from?.version !== void 0) versions.set(plugin.id, from.version);
6905
+ if (from !== void 0) origins.set(plugin.id, from.origin);
6906
+ };
6907
+ /** Source metadata exists for diagnostics; a silent ID replacement is where it earns that. */
6908
+ const note = (plugin, source) => plugins.has(plugin.id) ? Effect.logDebug(`plugin ${plugin.id} redefined by ${source}; the earlier definition is discarded`) : Effect.void;
6909
+ for (const builtin of options.builtins) remember(yield* validate(builtin, {
6910
+ index: -1,
6911
+ reference: builtin.id
6912
+ }, true), []);
6913
+ for (const [index, reference] of references.entries()) {
6914
+ if (isPatch(reference)) continue;
6915
+ const declared = typeof reference === "string" ? reference : Predicate.hasProperty(reference, "id") ? reference.id : void 0;
6916
+ const named = typeof declared === "string" ? declared : `<plugin object #${index}>`;
6917
+ const origin = {
6918
+ index,
6919
+ reference: named,
6920
+ file: options.declared?.get(named)
6921
+ };
6922
+ if (typeof reference !== "string") {
6923
+ if (typeof declared === "string" && plugins.get(declared) === reference) continue;
6924
+ const plugin = yield* validate(reference, origin);
6925
+ yield* note(plugin, "a supplied object");
6926
+ remember(plugin, []);
6927
+ continue;
6928
+ }
6929
+ const target = yield* parse$2(reference, options.hostDir);
6930
+ const key = target.kind === "local" ? target.path : target.spec;
6931
+ const already = seen.get(key);
6932
+ if (already !== void 0) {
6933
+ aliases.set(reference, already);
6934
+ continue;
6935
+ }
6936
+ const definition = yield* load$3(target, origin, options);
6937
+ yield* note(definition.plugin, definition.version === void 0 ? key : `${key}@${definition.version}`);
6938
+ const names = target.kind === "local" ? [target.path, ...definition.name === void 0 ? [] : [definition.name]] : [canonical$1(target), target.spec];
6939
+ remember(definition.plugin, [reference, ...names], {
6940
+ ...definition.version === void 0 ? {} : { version: definition.version },
6941
+ origin: {
6942
+ reference,
6943
+ ...definition.generation === void 0 ? {} : { generation: definition.generation },
6944
+ ...origin.file === void 0 ? {} : { file: origin.file }
6945
+ }
6946
+ });
6947
+ seen.set(key, definition.plugin.id);
6948
+ }
6906
6949
  return {
6907
- exitCode: result.exitCode,
6908
- ...present
6950
+ plugins,
6951
+ aliases,
6952
+ versions,
6953
+ origins
6909
6954
  };
6910
6955
  });
6911
- /** Variant B — streaming: accumulate output, report progress, keep partial output on timeout. */
6912
- const runStreaming = (stream, params) => Effect.scoped(Effect.gen(function* () {
6913
- const acc = new Accumulator({ tempFilePrefix: "codework-bash" });
6914
- const progress = yield* progress_exports.ToolProgress;
6915
- const exitCode = yield* Ref.make(null);
6916
- const consume = stream(params.command).pipe(Stream.runForEach((event) => event._tag === "Exit" ? Ref.set(exitCode, event.exitCode) : Effect.gen(function* () {
6917
- yield* acc.append(Buffer.from(event.bytes));
6918
- yield* progress.report({ content: [{
6919
- type: "text",
6920
- text: acc.snapshot().content
6921
- }] });
6922
- })), Effect.catchTag("ToolShellError", (cause) => Effect.die(cause)));
6923
- let timedOutAfter;
6924
- if (params.timeout !== void 0) {
6925
- const finished = yield* consume.pipe(Effect.timeoutOption(Duration.seconds(params.timeout)));
6926
- if (Option.isNone(finished)) timedOutAfter = params.timeout;
6927
- } else yield* consume;
6928
- yield* acc.finish();
6929
- const present = presentSnapshot(acc.snapshot(), acc.getLastLineBytes());
6930
- if (timedOutAfter !== void 0) return yield* new BashTimedOut({
6931
- timeoutSeconds: timedOutAfter,
6932
- ...present
6933
- });
6934
- const code = yield* Ref.get(exitCode);
6935
- if (code === null || code !== 0) return yield* new BashFailed({
6936
- exitCode: code ?? -1,
6937
- ...present
6956
+ /**
6957
+ * The **config pass**: pure data over modules that are already loaded, run at every exchange.
6958
+ *
6959
+ * An array entry is a **module** — a definition object, a path, a `file:` URL, or a package
6960
+ * spec. It takes the position it is written at, so naming a module again moves it.
6961
+ *
6962
+ * An object entry is **configuration** for a plugin an earlier entry (or the built-in list)
6963
+ * already selected, addressed by its ID (`plugin`) or the module string (`package`) that selected
6964
+ * it. It never loads, installs or reorders anything, and a name matching nothing in the selection
6965
+ * is ignored rather than failing the boot — a typo costs a debug line, never a fetch.
6966
+ *
6967
+ * Nothing here touches the disk, which is the whole point of the split: an `options` edit, an
6968
+ * `enabled` flip or a reordering applies at the next exchange without importing anything.
6969
+ */
6970
+ const select = Effect.fn("PluginCatalog.select")(function* (references, pool) {
6971
+ const operations = /* @__PURE__ */ new Map();
6972
+ const missing = [];
6973
+ const choose = (id, index) => operations.set(id, {
6974
+ enabled: true,
6975
+ index,
6976
+ options: operations.get(id)?.options ?? {}
6938
6977
  });
6978
+ for (const [index, reference] of references.entries()) {
6979
+ if (isPatch(reference)) {
6980
+ const named = reference.plugin ?? reference.package;
6981
+ const origin = {
6982
+ index,
6983
+ reference: typeof named === "string" ? named : `<plugin entry #${index}>`
6984
+ };
6985
+ const invalid = reference.plugin !== void 0 && reference.package !== void 0 ? "names both `plugin` and `package`" : typeof named !== "string" || named.length === 0 ? "names neither `plugin` nor `package`" : reference.enabled !== void 0 && typeof reference.enabled !== "boolean" ? "`enabled` is not a boolean" : reference.options !== void 0 && !isRecord$1(reference.options) ? "`options` is not a plain object" : void 0;
6986
+ if (invalid !== void 0) return yield* definitionFailure(origin, /* @__PURE__ */ new Error(`plugin entry ${invalid}`));
6987
+ const id = reference.plugin === void 0 ? pool.aliases.get(reference.package) : reference.plugin;
6988
+ const operation = id === void 0 ? void 0 : operations.get(id);
6989
+ if (id === void 0 || operation === void 0) {
6990
+ yield* Effect.logDebug(`plugin configuration ignored: nothing selected under ${named}`);
6991
+ continue;
6992
+ }
6993
+ operations.set(id, {
6994
+ enabled: reference.enabled ?? operation.enabled,
6995
+ index: operation.index,
6996
+ options: reference.options ?? operation.options
6997
+ });
6998
+ continue;
6999
+ }
7000
+ if (typeof reference !== "string") {
7001
+ const declared = Predicate.hasProperty(reference, "id") ? reference.id : void 0;
7002
+ if (typeof declared === "string" && pool.plugins.has(declared)) choose(declared, index);
7003
+ continue;
7004
+ }
7005
+ const id = pool.aliases.get(reference);
7006
+ if (id === void 0) missing.push(reference);
7007
+ else choose(id, index);
7008
+ }
7009
+ const selection = [];
7010
+ for (const [id, operation] of operations) {
7011
+ if (!operation.enabled) continue;
7012
+ const plugin = pool.plugins.get(id);
7013
+ if (plugin !== void 0) selection.push({
7014
+ plugin,
7015
+ options: Object.freeze(operation.options),
7016
+ index: operation.index
7017
+ });
7018
+ }
7019
+ selection.sort((a, b) => (0, plugin_exports$1.rank)(a.plugin.kind) - (0, plugin_exports$1.rank)(b.plugin.kind) || a.index - b.index);
6939
7020
  return {
6940
- exitCode: code,
6941
- ...present
7021
+ selection: Object.freeze(selection.map(({ plugin, options }) => ({
7022
+ plugin,
7023
+ options
7024
+ }))),
7025
+ missing
6942
7026
  };
6943
- }));
6944
- const bashHandler = (params) => Effect.gen(function* () {
6945
- const shell = yield* ToolShell;
6946
- return shell.stream !== void 0 ? yield* runStreaming(shell.stream, params) : yield* runBuffered(shell, params);
6947
7027
  });
6948
- /** The bash tool: definition + handler, wired the testable (def/exec split) way. */
6949
- const bashTool = tool_exports.implement(bashDef, bashHandler);
6950
- const bashPlugin = (0, plugin_exports$1.define)({
6951
- id: "codework.tool.bash",
6952
- kind: "tool",
6953
- setup: Effect.fn("BashPlugin.setup")(function* (ctx) {
6954
- const shell = yield* io_exports.SandboxIO.Shell;
6955
- const mounted = fromSandboxShell.pipe(Layer.provide(Layer.succeed(io_exports.SandboxIO.Shell, shell)));
6956
- ctx.plugin.tools.add(tool_exports.provide(bashTool, mounted));
6957
- })
6958
- });
6959
- //#endregion
6960
- //#region src/plugin/builtin.ts
6961
7028
  /**
6962
- * Every plugin the harness ships, and the selection a caller who passes no `plugins` gets. Tool
6963
- * contributors come first so the Prompt plugin that indexes them runs after they registered.
7029
+ * The exchange-boundary check: has the store moved under this pool?
7030
+ *
7031
+ * Every exchange already re-reads every settings layer from disk, and the config pass already
7032
+ * walks those entries against the loaded set. Two things can be settled right there, and they are
7033
+ * the same question asked twice:
7034
+ *
7035
+ * 1. a settings entry names a module that is not loaded, and the store holds it;
7036
+ * 2. a loaded module is not the newest generation the store has.
7037
+ *
7038
+ * The second is what makes `plugin update` finish. Without it the question is "is this entry
7039
+ * loaded?", the answer after an update is still yes, and the bytes just fetched sit on disk until
7040
+ * something else forces an import.
7041
+ *
7042
+ * Returns the new pool, or nothing when nothing moved -- which is the common case, and costs one
7043
+ * store lookup per filed module.
7044
+ *
7045
+ * **This is not a watcher.** Nothing polls and no fiber exists; it is the observation that we were
7046
+ * already paying for the I/O that would tell us, and discarding the answer. Three bounds keep it
7047
+ * safe: the store only and never the network, so an exchange cannot block on a registry;
7048
+ * resolve-only, so it cannot install; and once, at the top of a snapshot, so an exchange already
7049
+ * running is unaffected.
6964
7050
  */
6965
- const builtins = Object.freeze([bashPlugin, defaultPromptPlugin]);
7051
+ const follow = Effect.fn("PluginCatalog.follow")(function* (references, pool, options, filed) {
7052
+ let moved = false;
7053
+ for (const reference of references) {
7054
+ if (typeof reference !== "string" || pool.aliases.has(reference)) continue;
7055
+ if (Option.isSome(yield* filed(reference, anchor$1(options.declared?.get(reference), options.hostDir)))) {
7056
+ moved = true;
7057
+ break;
7058
+ }
7059
+ }
7060
+ if (!moved) for (const origin of pool.origins.values()) {
7061
+ if (origin.generation === void 0) continue;
7062
+ const current = yield* filed(origin.reference, anchor$1(origin.file, options.hostDir));
7063
+ if (Option.isSome(current) && current.value.generation !== void 0 && current.value.generation > origin.generation) {
7064
+ moved = true;
7065
+ break;
7066
+ }
7067
+ }
7068
+ if (!moved) return Option.none();
7069
+ const retained = [];
7070
+ for (const origin of pool.origins.values()) {
7071
+ if (references.includes(origin.reference) || Option.isSome(yield* filed(origin.reference, anchor$1(origin.file, options.hostDir)))) {
7072
+ retained.push(origin);
7073
+ continue;
7074
+ }
7075
+ yield* Effect.logWarning(`plugin ${origin.reference} no longer resolves; it is dropped from the loaded set`);
7076
+ }
7077
+ const accumulated = [...Array.from(retained, (origin) => origin.reference), ...references];
7078
+ const known = /* @__PURE__ */ new Map();
7079
+ for (const origin of retained) if (origin.file !== void 0) known.set(origin.reference, origin.file);
7080
+ for (const [reference, file] of options.declared ?? /* @__PURE__ */ new Map()) known.set(reference, file);
7081
+ return Option.some(yield* load$2(accumulated, {
7082
+ ...options,
7083
+ declared: known
7084
+ }));
7085
+ });
7086
+ Effect.fn("PluginCatalog.prepare")(function* (references, options) {
7087
+ const pool = yield* load$2(references, options);
7088
+ return (yield* select(references, pool)).selection;
7089
+ });
6966
7090
  //#endregion
6967
7091
  //#region src/runner/coordinator.ts
6968
7092
  const make$7 = (options) => Effect.gen(function* () {
@@ -7104,6 +7228,82 @@ const layer$7 = Layer.effect(Service$6, Effect.gen(function* () {
7104
7228
  });
7105
7229
  }));
7106
7230
  //#endregion
7231
+ //#region src/settings/schema.ts
7232
+ var schema_exports$3 = /* @__PURE__ */ __exportAll({});
7233
+ import * as import__codeworksh_plugin_settings from "@codeworksh/plugin/settings";
7234
+ __reExport(schema_exports$3, import__codeworksh_plugin_settings);
7235
+ //#endregion
7236
+ //#region src/settings/resolve.ts
7237
+ const patternMatches = (pattern, id) => new RegExp(`^${pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`).test(id);
7238
+ function collect(settings, provider, id) {
7239
+ const config = settings.model;
7240
+ const blocks = config.providerOptions?.[provider] ?? {};
7241
+ const patterns = Object.keys(blocks).filter((key) => key !== "*" && key !== id && key.includes("*") && patternMatches(key, id)).sort((a, b) => a.indexOf("*") - b.indexOf("*"));
7242
+ return merge({
7243
+ thinkingLevel: config.thinkingLevel,
7244
+ toolExecution: config.toolExecution,
7245
+ ...config.thinkingBudgets === void 0 ? {} : { thinkingBudgets: config.thinkingBudgets }
7246
+ }, config.options, blocks["*"], ...patterns.map((key) => blocks[key]), blocks[id]);
7247
+ }
7248
+ /**
7249
+ * Corrections to the catalog entry itself, applied before lookup.
7250
+ *
7251
+ * `protocol` is here rather than in the request bag because it is model shape: it decides which
7252
+ * wire protocol aikit speaks, and through `Model.optionsKey` which namespace the provider bag is
7253
+ * keyed by. A catalog cannot know it for a local OpenAI-compatible server, and the person
7254
+ * pointing at one does.
7255
+ */
7256
+ const resolveOverrides = (block) => ({
7257
+ ...block.contextWindow === void 0 ? {} : { contextWindow: block.contextWindow },
7258
+ ...block.protocol === void 0 ? {} : { protocol: block.protocol }
7259
+ });
7260
+ /** Protocol-independent request attributes can already be captured by State. */
7261
+ function resolveOptions(block) {
7262
+ return {
7263
+ ...Object.fromEntries(Object.entries(block).filter(([key]) => Object.hasOwn(schema_exports$3.requestFields, key))),
7264
+ ...block.extras === void 0 ? {} : { factoryOptions: merge({}, block.extras) }
7265
+ };
7266
+ }
7267
+ /** The bag key becomes knowable only after lookup at the LLM boundary. */
7268
+ function resolveRequest(block, model) {
7269
+ const bag = Object.fromEntries(Object.entries(block).filter(([key]) => !Object.hasOwn(schema_exports$3.requestFields, key) && ![
7270
+ "thinkingLevel",
7271
+ "toolExecution",
7272
+ "contextWindow",
7273
+ "protocol",
7274
+ "extras"
7275
+ ].includes(key)));
7276
+ return {
7277
+ ...resolveOptions(block),
7278
+ ...Object.keys(bag).length === 0 ? {} : { providerOptions: { [Model$1.optionsKey(model)]: bag } }
7279
+ };
7280
+ }
7281
+ /**
7282
+ * Harness-owned controls, which never reach a provider.
7283
+ *
7284
+ * Separate from {@link resolveRequest} because these steer the loop itself -- how hard to
7285
+ * think, how to schedule tool calls -- rather than the request body. Runtime bindings
7286
+ * outrank a matched block, which outranks the file's top level.
7287
+ */
7288
+ const resolveControls = (settings, block, runtime) => ({
7289
+ thinkingLevel: runtime.thinkingLevel ?? block.thinkingLevel ?? settings.model.thinkingLevel,
7290
+ toolExecution: runtime.toolExecution ?? block.toolExecution ?? settings.model.toolExecution
7291
+ });
7292
+ /** Pure configuration composition. Domain selections need not exist in the catalog. */
7293
+ function compose(settings, options = {}, session = {}) {
7294
+ const runtime = merge(options, session);
7295
+ const provider = runtime.provider ?? settings.model.provider;
7296
+ const model = runtime.model ?? settings.model.id;
7297
+ const block = collect(settings, provider, model);
7298
+ return {
7299
+ provider,
7300
+ model,
7301
+ ...resolveControls(settings, block, runtime),
7302
+ block,
7303
+ runtime
7304
+ };
7305
+ }
7306
+ //#endregion
7107
7307
  //#region src/runner/event.ts
7108
7308
  const make$6 = Effect.fn("LLMEventPublisher.make")(function* (input) {
7109
7309
  const events = yield* Service$16;
@@ -7253,82 +7453,6 @@ const make$6 = Effect.fn("LLMEventPublisher.make")(function* (input) {
7253
7453
  };
7254
7454
  });
7255
7455
  //#endregion
7256
- //#region src/settings/schema.ts
7257
- var schema_exports$3 = /* @__PURE__ */ __exportAll({});
7258
- import * as import__codeworksh_plugin_settings from "@codeworksh/plugin/settings";
7259
- __reExport(schema_exports$3, import__codeworksh_plugin_settings);
7260
- //#endregion
7261
- //#region src/settings/resolve.ts
7262
- const patternMatches = (pattern, id) => new RegExp(`^${pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`).test(id);
7263
- function collect(settings, provider, id) {
7264
- const config = settings.model;
7265
- const blocks = config.providerOptions?.[provider] ?? {};
7266
- const patterns = Object.keys(blocks).filter((key) => key !== "*" && key !== id && key.includes("*") && patternMatches(key, id)).sort((a, b) => a.indexOf("*") - b.indexOf("*"));
7267
- return merge({
7268
- thinkingLevel: config.thinkingLevel,
7269
- toolExecution: config.toolExecution,
7270
- ...config.thinkingBudgets === void 0 ? {} : { thinkingBudgets: config.thinkingBudgets }
7271
- }, config.options, blocks["*"], ...patterns.map((key) => blocks[key]), blocks[id]);
7272
- }
7273
- /**
7274
- * Corrections to the catalog entry itself, applied before lookup.
7275
- *
7276
- * `protocol` is here rather than in the request bag because it is model shape: it decides which
7277
- * wire protocol aikit speaks, and through `Model.optionsKey` which namespace the provider bag is
7278
- * keyed by. A catalog cannot know it for a local OpenAI-compatible server, and the person
7279
- * pointing at one does.
7280
- */
7281
- const resolveOverrides = (block) => ({
7282
- ...block.contextWindow === void 0 ? {} : { contextWindow: block.contextWindow },
7283
- ...block.protocol === void 0 ? {} : { protocol: block.protocol }
7284
- });
7285
- /** Protocol-independent request attributes can already be captured by State. */
7286
- function resolveOptions(block) {
7287
- return {
7288
- ...Object.fromEntries(Object.entries(block).filter(([key]) => Object.hasOwn(schema_exports$3.requestFields, key))),
7289
- ...block.extras === void 0 ? {} : { factoryOptions: merge({}, block.extras) }
7290
- };
7291
- }
7292
- /** The bag key becomes knowable only after lookup at the LLM boundary. */
7293
- function resolveRequest(block, model) {
7294
- const bag = Object.fromEntries(Object.entries(block).filter(([key]) => !Object.hasOwn(schema_exports$3.requestFields, key) && ![
7295
- "thinkingLevel",
7296
- "toolExecution",
7297
- "contextWindow",
7298
- "protocol",
7299
- "extras"
7300
- ].includes(key)));
7301
- return {
7302
- ...resolveOptions(block),
7303
- ...Object.keys(bag).length === 0 ? {} : { providerOptions: { [Model$1.optionsKey(model)]: bag } }
7304
- };
7305
- }
7306
- /**
7307
- * Harness-owned controls, which never reach a provider.
7308
- *
7309
- * Separate from {@link resolveRequest} because these steer the loop itself -- how hard to
7310
- * think, how to schedule tool calls -- rather than the request body. Runtime bindings
7311
- * outrank a matched block, which outranks the file's top level.
7312
- */
7313
- const resolveControls = (settings, block, runtime) => ({
7314
- thinkingLevel: runtime.thinkingLevel ?? block.thinkingLevel ?? settings.model.thinkingLevel,
7315
- toolExecution: runtime.toolExecution ?? block.toolExecution ?? settings.model.toolExecution
7316
- });
7317
- /** Pure configuration composition. Domain selections need not exist in the catalog. */
7318
- function compose(settings, options = {}, session = {}) {
7319
- const runtime = merge(options, session);
7320
- const provider = runtime.provider ?? settings.model.provider;
7321
- const model = runtime.model ?? settings.model.id;
7322
- const block = collect(settings, provider, model);
7323
- return {
7324
- provider,
7325
- model,
7326
- ...resolveControls(settings, block, runtime),
7327
- block,
7328
- runtime
7329
- };
7330
- }
7331
- //#endregion
7332
7456
  //#region src/runner/llm.ts
7333
7457
  const runtimeOptions = (input, model, signal) => {
7334
7458
  const { activeTools, ...options } = merge(resolveRequest(input.settings ?? {}, model), input.options);
@@ -7378,17 +7502,6 @@ const providerError = (input, failure) => {
7378
7502
  });
7379
7503
  };
7380
7504
  const providerErrorFromUnknown = (input, cause) => providerError(input, AikitFailure.normalize(cause));
7381
- const modelCatalogFailure = (cause) => {
7382
- if (typeof cause !== "object" || cause === null || !("name" in cause) || cause.name !== "ModelCatalogLoadError") return;
7383
- if (!("data" in cause) || typeof cause.data !== "object" || cause.data === null) return void 0;
7384
- const data = cause.data;
7385
- if (!("path" in data) || typeof data.path !== "string" || !("message" in data) || typeof data.message !== "string" || !("reason" in data) || data.reason !== "missing" && data.reason !== "unreadable" && data.reason !== "empty" && data.reason !== "invalid") return;
7386
- return {
7387
- path: data.path,
7388
- reason: data.reason,
7389
- message: data.message
7390
- };
7391
- };
7392
7505
  /** Read structured terminal data while remaining compatible with older Aikit messages. */
7393
7506
  const messageFailure = (message) => {
7394
7507
  const candidate = message.failure;
@@ -7398,14 +7511,7 @@ const messageFailure = (message) => {
7398
7511
  const resolve$1 = Effect.fn("LLM.resolve")(function* (input) {
7399
7512
  const model = yield* Effect.tryPromise({
7400
7513
  try: () => llm(input.provider, input.model, resolveOverrides(input.settings ?? {})),
7401
- catch: (cause) => {
7402
- const catalog = modelCatalogFailure(cause);
7403
- return catalog === void 0 ? providerErrorFromUnknown(input, cause) : new ModelCatalogError({
7404
- path: catalog.path,
7405
- reason: catalog.reason,
7406
- detail: catalog.message
7407
- });
7408
- }
7514
+ catch: (cause) => loadError(cause) ?? providerErrorFromUnknown(input, cause)
7409
7515
  });
7410
7516
  if (model === void 0) return yield* new ModelNotFoundError({
7411
7517
  provider: input.provider,
@@ -8086,7 +8192,7 @@ const parse$1 = Effect.fn("Settings.parse")(function* (path, source) {
8086
8192
  reason: "parse",
8087
8193
  detail: `${printParseErrorCode(first.error)} at ${at(source, first.offset)}`
8088
8194
  });
8089
- const document = normalize$1(json);
8195
+ const document = normalize(json);
8090
8196
  if (Predicate.isObject(document) && Predicate.isObject(json) && "plugins" in json) Object.assign(document, { plugins: json.plugins });
8091
8197
  return yield* Schema.decodeUnknownEffect(schema_exports$3.Patch)(document).pipe(Effect.mapError((error) => new SettingsError({
8092
8198
  path,
@@ -8769,7 +8875,7 @@ const layer$2 = Layer.effectDiscard(Effect.gen(function* () {
8769
8875
  }));
8770
8876
  //#endregion
8771
8877
  //#region src/session/live.ts
8772
- const layer$1 = Layer.provideMerge(layer$2, layer$22);
8878
+ const layer$1 = Layer.provideMerge(layer$2, layer$23);
8773
8879
  //#endregion
8774
8880
  //#region src/effect/harness.ts
8775
8881
  var harness_exports = /* @__PURE__ */ __exportAll({
@@ -8834,14 +8940,17 @@ const layer = (options = {}) => Layer.unwrap(Effect.gen(function* () {
8834
8940
  });
8835
8941
  });
8836
8942
  const definitions = yield* flatten([...(yield* Ref.get(pool)).plugins.values()]);
8837
- const database = layer$25(resolveDatabaseLocation(options.database ?? (yield* locationConfig), paths.data));
8943
+ const database = layer$26(resolveDatabaseLocation(options.database ?? (yield* locationConfig), paths.data));
8838
8944
  const configured = yield* loadAll(options.sandboxes ?? [], { hostCwd });
8839
- const drivers = layer$18(driver_exports.SandboxDriver.withSource(make$11().driver, "core"), driver_exports.SandboxDriver.withSource(make$9().driver, "core"), ...configured);
8840
- const sandboxes = layer$16({ hostCwd }).pipe(Layer.provideMerge(drivers), Layer.provideMerge(database));
8945
+ const drivers = layer$19(driver_exports.SandboxDriver.withSource(make$11().driver, "core"), driver_exports.SandboxDriver.withSource(make$9().driver, "core"), ...configured);
8946
+ const sandboxes = layer$17({ hostCwd }).pipe(Layer.provideMerge(drivers), Layer.provideMerge(database));
8841
8947
  const authFile$1 = authFile(options.home === void 0 ? void 0 : paths.home);
8842
8948
  const open = options.llm ?? openWith(authFile$1 === void 0 ? {} : { authFile: authFile$1 });
8843
8949
  const loop = layer$3({ request: make$5(open) });
8844
- return layer$9.pipe(Layer.provideMerge(layer$7.pipe(Layer.provide(loop))), Layer.provideMerge(layer$4({}, pool, references, followStore, rebuild)), Layer.provideMerge(layer$5(settingsOptions)), Layer.provideMerge(layer$6), Layer.provideMerge(sandboxes), Layer.provideMerge(layer$21), Layer.provideMerge(layer$1), Layer.provideMerge(layer$24), Layer.provideMerge(layer$8(definitions)), Layer.provideMerge(database), Layer.provideMerge(global));
8950
+ return layer$10.pipe(Layer.provideMerge(layer$7.pipe(Layer.provide(loop))), Layer.provideMerge(layer$4({}, pool, references, followStore, rebuild)), 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({
8951
+ home: paths.home,
8952
+ watch: options.watchModels ?? false
8953
+ })));
8845
8954
  }));
8846
8955
  //#endregion
8847
8956
  //#region src/effect/sandbox.ts
@@ -9149,6 +9258,6 @@ var DateTimeUtcFromMillis = schema_exports$9.DateTimeUtcFromMillis;
9149
9258
  var EventSchema = schema_exports$7.EventSchema;
9150
9259
  var SandboxError = errors_exports.SandboxError;
9151
9260
  var optional = schema_exports$9.optional;
9152
- export { control_exports as Control, db_exports as Database, DateTimeUtcFromMillis, event_exports as Event, list_exports as EventList, registry_exports as EventRegistry, EventSchema, global_exports as Global, harness_exports as Harness, location_exports as Location, plugin_exports as Plugin, schema_exports as PromptSchema, run_exports as Runner, sandbox_exports as Sandbox, SandboxError, session_exports as Session, session_exports$1 as SessionStore, settings_exports as Settings, state_exports as State, tool_exports as Tool, optional };
9261
+ export { control_exports as Control, db_exports as Database, DateTimeUtcFromMillis, event_exports as Event, list_exports as EventList, registry_exports as EventRegistry, EventSchema, global_exports as Global, harness_exports as Harness, location_exports as Location, catalog_exports as ModelCatalog, plugin_exports as Plugin, schema_exports as PromptSchema, run_exports as Runner, sandbox_exports as Sandbox, SandboxError, session_exports as Session, session_exports$1 as SessionStore, settings_exports as Settings, state_exports as State, tool_exports as Tool, optional };
9153
9262
 
9154
9263
  //# sourceMappingURL=effect.mjs.map