@xandreed/plugin-workspace 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/dist/bash-tool.adapter.js +10 -9
  3. package/dist/bash.adapter.d.ts +60 -8
  4. package/dist/bash.adapter.js +56 -13
  5. package/dist/file-system.adapter.d.ts +5 -1
  6. package/dist/file-system.adapter.js +1 -1
  7. package/dist/image-resizer.adapter.d.ts +5 -0
  8. package/dist/image-resizer.adapter.js +68 -0
  9. package/dist/image-resizer.entity.d.ts +23 -0
  10. package/dist/image-resizer.entity.functions.d.ts +15 -0
  11. package/dist/image-resizer.entity.functions.js +4 -0
  12. package/dist/image-resizer.entity.js +7 -0
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +4 -1
  15. package/dist/local-system.entity.d.ts +10 -3
  16. package/dist/local-system.entity.functions.d.ts +3 -2
  17. package/dist/local-system.entity.functions.js +16 -2
  18. package/dist/local-system.entity.js +3 -2
  19. package/dist/plugin.adapter.d.ts +103 -40
  20. package/dist/plugin.adapter.js +16 -5
  21. package/dist/read-tools.adapter.d.ts +10 -4
  22. package/dist/read-tools.adapter.js +40 -31
  23. package/dist/ripgrep.adapter.d.ts +74 -0
  24. package/dist/ripgrep.adapter.js +56 -0
  25. package/dist/ripgrep.entity.d.ts +63 -0
  26. package/dist/ripgrep.entity.functions.d.ts +87 -0
  27. package/dist/ripgrep.entity.functions.js +62 -0
  28. package/dist/ripgrep.entity.js +10 -0
  29. package/dist/shell.entity.functions.d.ts +2 -0
  30. package/dist/shell.entity.functions.js +1 -0
  31. package/dist/skills/skills-directory.adapter.d.ts +20 -14
  32. package/dist/skills/skills-directory.adapter.js +2 -2
  33. package/dist/skills/skills-directory.entity.d.ts +8 -5
  34. package/dist/skills/skills-directory.entity.functions.d.ts +58 -3
  35. package/dist/skills/skills-directory.entity.functions.js +5 -5
  36. package/dist/text-edit.entity.functions.js +3 -3
  37. package/dist/text-search.entity.functions.d.ts +7 -0
  38. package/dist/text-search.entity.functions.js +2 -2
  39. package/dist/workspace-path.entity.d.ts +21 -0
  40. package/dist/workspace-path.entity.functions.d.ts +39 -9
  41. package/dist/workspace-path.entity.functions.js +43 -18
  42. package/dist/workspace-path.entity.js +3 -2
  43. package/dist/workspace-tools.adapter.d.ts +48 -3
  44. package/dist/workspace-tools.adapter.js +23 -9
  45. package/dist/workspace-tools.entity.d.ts +136 -17
  46. package/dist/workspace-tools.entity.functions.d.ts +80 -5
  47. package/dist/workspace-tools.entity.functions.js +87 -31
  48. package/dist/workspace-tools.entity.js +26 -5
  49. package/dist/write-tools.adapter.js +10 -8
  50. package/package.json +6 -5
  51. package/dist/bash.adapter.test.d.ts +0 -1
  52. package/dist/file-system.adapter.test.d.ts +0 -1
  53. package/dist/local-system.entity.functions.test.d.ts +0 -1
  54. package/dist/plugin.adapter.test.d.ts +0 -1
  55. package/dist/skills/skills-directory.adapter.test.d.ts +0 -1
  56. package/dist/skills/skills-directory.entity.functions.test.d.ts +0 -1
  57. package/dist/text-edit.entity.functions.test.d.ts +0 -1
  58. package/dist/text-search.entity.functions.test.d.ts +0 -1
  59. package/dist/text-window.entity.functions.test.d.ts +0 -1
  60. package/dist/workspace-path.entity.functions.test.d.ts +0 -1
  61. package/dist/workspace-tools.adapter.test.d.ts +0 -1
  62. package/dist/workspace-tools.entity.functions.test.d.ts +0 -1
  63. package/dist/workspace-tools.journey.test.d.ts +0 -1
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @xandreed/plugin-workspace
2
2
 
3
- Efferent composable agent framework, 0.14.0. Node 24 or Bun.
3
+ Efferent composable agent framework, 0.15.1. Node 24 or Bun.
4
4
 
5
5
  Documentation: https://github.com/xandreeddev/efferent
@@ -1,21 +1,22 @@
1
1
  import { Tool } from "effect/ai";
2
- import { Clock, Effect, Option, Random, Stream } from "effect";
3
- import { Bash, CurrentToolInvocation, defineTool, Failure, publishToolProgress, RunContext } from "@xandreed/core";
2
+ import { Array as Arr, Clock, Effect, Option, Order, Random, Stream } from "effect";
3
+ import { Bash, BashEvent, CurrentToolInvocation, defineTool, Failure, publishToolProgress, RunContext } from "@xandreed/core";
4
4
  import { clipHead, tailText, utf8Length } from "./text-window.entity.functions.js";
5
5
  import { openWorkspace } from "./workspace-path.entity.functions.js";
6
6
  import { BashParams, BashToolResult, WORKSPACE_PERMISSIONS } from "./workspace-tools.entity.js";
7
7
  import { bashEnvironment, compactBash, renderBash, toolDescriptions } from "./workspace-tools.entity.functions.js";
8
- import { failure } from "./read-tools.adapter.js";
9
- const BashTool = (description) => Tool.make("bash", { description, parameters: BashParams, success: BashToolResult, failure: Failure, failureMode: "return" }), isChunk = (event) => event._tag === "BashChunk", isExit = (event) => event._tag === "BashExit", runsOf = (chunks) => chunks.reduce((runs, chunk) => {
8
+ import { failure, WORKSPACE_SERVICES } from "./read-tools.adapter.js";
9
+ const BashTool = (description) => Tool.make("bash", { description, parameters: BashParams, success: BashToolResult, failure: Failure, failureMode: "return" }), runsOf = (chunks) => chunks.reduce((runs, chunk) => {
10
10
  const last = runs.at(-1);
11
11
  if (last !== void 0 && last.stream === chunk.stream)
12
12
  runs.splice(-1, 1, { stream: chunk.stream, text: last.text + chunk.text });
13
13
  else
14
14
  runs.push({ stream: chunk.stream, text: chunk.text });
15
15
  return runs;
16
- }, []), prune = (access, dir, keep) => access.fs.list(dir).pipe(Effect.map((entries) => entries.filter((entry) => entry.kind === "file" && /^bash-.*\.log$/.test(entry.name)).map((entry) => entry.name).sort()), Effect.flatMap((names) => Effect.forEach(names.slice(0, Math.max(0, names.length - keep)), (name) => access.fs.remove(access.path.join(dir, name)), { discard: !0 })), Effect.ignore);
16
+ }, []), prune = (access, dir, keep) => access.fs.list(dir).pipe(Effect.map((entries) => Arr.sort(entries.filter((entry) => entry.kind === "file" && /^bash-.*\.log$/.test(entry.name)).map((entry) => entry.name), Order.String)), Effect.flatMap((names) => Effect.forEach(names.slice(0, Math.max(0, names.length - keep)), (name) => access.fs.remove(access.path.join(dir, name)), { concurrency: 1, discard: !0 })), Effect.ignore({ log: "Warn", message: `the older spill files in ${dir} were not pruned` }));
17
17
  export const bashTool = (context) => defineTool({
18
18
  tool: BashTool(toolDescriptions(context.config).bash),
19
+ requires: [...WORKSPACE_SERVICES, Bash],
19
20
  handler: (params) => Effect.gen(function* () {
20
21
  const { config } = context, access = yield* openWorkspace(context.path), bash = yield* Bash, run = yield* Effect.serviceOption(RunContext), invocation = yield* Effect.service(CurrentToolInvocation), timeoutMs = Math.min(params.timeout === void 0 ? config.bashTimeoutMs : params.timeout * 1000, config.bashMaxTimeoutMs), options = {
21
22
  cwd: access.root,
@@ -40,12 +41,12 @@ export const bashTool = (context) => defineTool({
40
41
  const next = fitting(spill.bytes, text);
41
42
  return (next.piece.length === 0 ? Effect.void : access.fs.append(spill.file, next.piece).pipe(Effect.catch((error) => Effect.logWarning(`bash: could not add to ${spill.file}: ${error.message}`)))).pipe(Effect.as({ ...spill, bytes: next.bytes, cut: next.cut }));
42
43
  }, over = (state) => state.bytes - state.crlf - 1 > config.maxBytes || state.breaks > config.maxLines, step = (state, group) => Effect.gen(function* () {
43
- const chunks = group.filter(isChunk);
44
- yield* Effect.forEach(runsOf(chunks), (current) => publishToolProgress(current.stream, current.text), { discard: !0 });
44
+ const chunks = group.filter(BashEvent.guards.BashChunk);
45
+ yield* Effect.forEach(runsOf(chunks), (current) => publishToolProgress(current.stream, current.text), { concurrency: 1, discard: !0 });
45
46
  const text = chunks.map((chunk) => chunk.text).join(""), bytes = state.bytes + utf8Length(text), breaks = state.breaks + (text.split(`
46
47
  `).length - 1), crlf = state.crlf + (text.split(`\r
47
48
  `).length - 1) + (state.tail.endsWith("\r") && text.startsWith(`
48
- `) ? 1 : 0), exit = Option.orElse(Option.fromNullishOr(group.find(isExit)), () => state.exit), base = { tail: (state.tail + text).slice(-(config.maxBytes + 2)), bytes, breaks, crlf, open: text.length === 0 ? state.open : !text.endsWith(`
49
+ `) ? 1 : 0), exit = Option.orElse(Option.fromNullishOr(group.find(BashEvent.guards.BashExit)), () => state.exit), base = { tail: (state.tail + text).slice(-(config.maxBytes + 2)), bytes, breaks, crlf, open: text.length === 0 ? state.open : !text.endsWith(`
49
50
  `), exit };
50
51
  if (Option.isSome(state.spill))
51
52
  return { ...base, whole: Option.none(), spill: Option.some(yield* addToSpill(state.spill.value, text)) };
@@ -67,6 +68,6 @@ export const bashTool = (context) => defineTool({
67
68
  durationMs: Option.match(gathered.exit, { onNone: () => ended - started, onSome: (exit) => exit.durationMs })
68
69
  };
69
70
  }),
70
- view: { version: "1", render: renderBash, compact: compactBash },
71
+ view: { version: "2", render: renderBash, compact: compactBash },
71
72
  annotations: { permissions: [WORKSPACE_PERMISSIONS.exec], pollable: !0 }
72
73
  });
@@ -1,8 +1,47 @@
1
- import { Effect, Layer } from "effect";
2
- import type { Context } from "effect";
1
+ import type { Readable } from "node:stream";
2
+ import { Cause, Effect, Layer, Option, Queue, Stream } from "effect";
3
+ import type { Context, Scope } from "effect";
3
4
  import { Bash, BashError } from "@xandreed/core";
5
+ import type { BashChunk, BashEvent, BashExit, BashOptions } from "@xandreed/core";
4
6
  import type { BashLiveOptions } from "./local-system.entity.js";
5
7
  type BashShape = Context.Service.Shape<typeof Bash>;
8
+ /**
9
+ * The most events a command's stream holds before the command is made to
10
+ * wait: a pipe read is at most 64 KB on Node, so about 1 MB of output.
11
+ */
12
+ export declare const OUTPUT_BUFFER = 16;
13
+ /** How a command's output pipes feed its stream (`relayOutput`). */
14
+ export interface OutputRelay {
15
+ /** Read `pipe` as `name`: UTF-8, a character split between reads arriving whole; an error on it fails the stream. */
16
+ readonly attach: (name: BashChunk["stream"], pipe: Readable) => void;
17
+ /** Stop pausing the pipes: what is left of the output is read at once. */
18
+ readonly flow: () => void;
19
+ /** The decoders' last characters, then `exit`, after everything before them; the stream ends after the exit. */
20
+ readonly finish: (exit: BashExit) => void;
21
+ }
22
+ /**
23
+ * The bridge from a command's output pipes to its stream's buffer (`queue`,
24
+ * which holds at most `OUTPUT_BUFFER` events). An event goes straight in
25
+ * while there is room and nothing waits before it. Otherwise it waits, in
26
+ * arrival order, and the pipe it came from is paused, so the command blocks
27
+ * on a full pipe instead of its output piling up in memory; a fiber of the
28
+ * stream's scope moves waiting events in as the reader takes them, and
29
+ * resumes their pipes. `flow` ends the pausing, for once the command has
30
+ * exited (what is left then is the pipes' content, and whatever a
31
+ * background process writes in `exitGraceMs`). An error on a pipe fails the
32
+ * stream with `SpawnFailed`.
33
+ */
34
+ export declare const relayOutput: (queue: Queue.Queue<{
35
+ readonly _tag: "BashChunk";
36
+ readonly stream: "stdout" | "stderr";
37
+ readonly text: string;
38
+ } | {
39
+ readonly _tag: "BashExit";
40
+ readonly termination: "interrupted" | "exit" | "signal" | "timeout";
41
+ readonly exitCode: Option.Option<number>;
42
+ readonly signal: Option.Option<string>;
43
+ readonly durationMs: number;
44
+ }, BashError | Cause.Done<void>>) => Effect.Effect<OutputRelay, never, Scope.Scope>;
6
45
  /**
7
46
  * Commands on the local machine through `node:child_process`, for Node and
8
47
  * Bun: `<shell> -c <command>`, detached into a process group of its own, in
@@ -12,18 +51,31 @@ type BashShape = Context.Service.Shape<typeof Bash>;
12
51
  * `ShellMissing`. Each command checks its `cwd` first (`CwdMissing`); its
13
52
  * stdin is written and closed (a command that never reads it is fine), its
14
53
  * stdout and stderr decoded as UTF-8 per stream, a character split between
15
- * reads arriving whole. A timeout, or a caller that stops reading before
54
+ * reads arriving whole. While the command runs, its output is read only as
55
+ * fast as the stream is (`relayOutput`): on Node a reader that falls behind
56
+ * holds the command back on a full pipe, so output is never gathered
57
+ * without bound (Bun reads a child's pipes ahead into its own memory, the
58
+ * reader notwithstanding). A timeout, or a caller that stops reading before
16
59
  * the end, kills the whole group; with `killBackground` (the default) so
17
60
  * does the command's return. The stream ends once the command has exited
18
61
  * and its output has closed, or `exitGraceMs` after the exit if something
19
62
  * still holds the output open; the adapter then closes its end of the
20
63
  * output pipes, so a background process kept with `killBackground: false`
21
64
  * that still writes to them gets `EPIPE` or dies of `SIGPIPE` unless it was
22
- * started with its own output (`cmd > log 2>&1 &`). Output is buffered
23
- * without bound until it is read. POSIX only (Linux and macOS): it relies
24
- * on process groups. Passes `bashConformance` on Bun and Node.
65
+ * started with its own output (`cmd > log 2>&1 &`). POSIX only (Linux and
66
+ * macOS): it relies on process groups. Passes `bashConformance` on Bun and
67
+ * Node (on Bun without the hold-back half of `backpressure`).
68
+ */
69
+ export declare const makeBash: (options?: BashLiveOptions | undefined) => Effect.Effect<{
70
+ readonly stream: (command: string, options: BashOptions) => Stream.Stream<BashEvent, BashError>;
71
+ readonly exec: (command: string, options: BashOptions) => Effect.Effect<import("@xandreed/core").BashResult, BashError>;
72
+ }, BashError, never>;
73
+ /** Whether a `Bash` service is one `makeBash` built, so its commands see the same files as `localFileSystem`. */
74
+ export declare const runsOnThisMachine: (bash: BashShape) => boolean;
75
+ /**
76
+ * The local `Bash` (`makeBash`) as a layer; building it fails with `ShellMissing` when the shell is not installed.
77
+ *
78
+ * @public
25
79
  */
26
- export declare const makeBash: (options?: BashLiveOptions) => Effect.Effect<BashShape, BashError>;
27
- /** The local `Bash` (`makeBash`) as a layer; building it fails with `ShellMissing` when the shell is not installed. */
28
80
  export declare const BashLive: (options?: BashLiveOptions) => Layer.Layer<Bash, BashError>;
29
81
  export {};
@@ -5,13 +5,16 @@ import { Cause, Clock, Deferred, Effect, Layer, MutableRef, Option, Queue, Resul
5
5
  import { Bash, BashError, collectBashResult, resolveBashEnv } from "@xandreed/core";
6
6
  import { bashExitOf, errorMessageOf, executableCandidates, isClosedInput } from "./local-system.entity.functions.js";
7
7
  import { localSystemDefaults } from "./local-system.entity.js";
8
- const isExecutableFile = (path) => Effect.tryPromise(() => access(path, constants.X_OK)).pipe(Effect.andThen(Effect.tryPromise(() => stat(path))), Effect.map((info) => info.isFile()), Effect.orElseSucceed(() => !1)), resolveShell = (shell, path) => Effect.findFirst(executableCandidates(shell, path), isExecutableFile).pipe(Effect.flatMap(Option.match({
8
+ const isExecutableFile = (path) => Effect.tryPromise(() => access(path, constants.X_OK)).pipe(Effect.andThen(Effect.tryPromise(() => stat(path))), Effect.map((info) => info.isFile()), Effect.tapError(() => Effect.logDebug(`${path} is not an executable file`)), Effect.orElseSucceed(() => !1)), resolveShell = (shell, path) => Effect.findFirst(executableCandidates(shell, path), isExecutableFile).pipe(Effect.flatMap(Option.match({
9
9
  onNone: () => Effect.fail(new BashError({
10
10
  reason: "ShellMissing",
11
11
  message: `no executable ${shell} ${shell.startsWith("/") ? "at that path" : `on the PATH (${Option.getOrElse(path, () => "unset")})`}`
12
12
  })),
13
13
  onSome: Effect.succeed
14
- }))), checkCwd = (cwd) => (cwd.startsWith("/") ? Effect.tryPromise(() => stat(cwd)).pipe(Effect.map((info) => info.isDirectory()), Effect.orElseSucceed(() => !1)) : Effect.succeed(!1)).pipe(Effect.flatMap((directory) => directory ? Effect.void : Effect.fail(new BashError({ reason: "CwdMissing", message: `cwd is not an existing absolute directory: ${cwd}` })))), killGroup = (child, exited) => Option.match(Option.fromNullishOr(child.pid), {
14
+ }))), checkCwd = (cwd) => {
15
+ const missing = () => new BashError({ reason: "CwdMissing", message: `cwd is not an existing absolute directory: ${cwd}` });
16
+ return cwd.startsWith("/") ? Effect.tryPromise({ try: () => stat(cwd), catch: missing }).pipe(Effect.flatMap((info) => info.isDirectory() ? Effect.void : Effect.fail(missing()))) : Effect.fail(missing());
17
+ }, killGroup = (child, exited) => Option.match(Option.fromNullishOr(child.pid), {
15
18
  onNone: () => {
16
19
  return;
17
20
  },
@@ -21,10 +24,42 @@ const isExecutableFile = (path) => Effect.tryPromise(() => access(path, constant
21
24
  Result.try(() => process.kill(pid, "SIGKILL"));
22
25
  }
23
26
  });
24
- export const makeBash = (options = {}) => Effect.gen(function* () {
27
+ export const OUTPUT_BUFFER = 16, relayOutput = Effect.fnUntraced(function* (queue) {
28
+ const waiting = yield* Queue.unbounded(), behind = MutableRef.make(0), flowing = MutableRef.make(!1), pipes = MutableRef.make([]), decoders = { stdout: new TextDecoder, stderr: new TextDecoder }, wait = (event, pipe) => {
29
+ if (Option.isSome(pipe) && !MutableRef.get(flowing))
30
+ pipe.value.pause();
31
+ MutableRef.increment(behind);
32
+ Queue.offerUnsafe(waiting, { event, pipe });
33
+ }, deliver = (event, pipe) => {
34
+ if (MutableRef.get(behind) > 0 || !Queue.offerUnsafe(queue, event))
35
+ wait(event, Option.some(pipe));
36
+ }, chunk = (name, text) => text.length === 0 ? Option.none() : Option.some({ _tag: "BashChunk", stream: name, text });
37
+ yield* Queue.take(waiting).pipe(Effect.flatMap(({ event, pipe }) => Queue.offer(queue, event).pipe(Effect.andThen(Effect.sync(() => {
38
+ MutableRef.decrement(behind);
39
+ Option.map(pipe, (readable) => readable.resume());
40
+ if (event._tag === "BashExit")
41
+ Queue.endUnsafe(queue);
42
+ })))), Effect.forever, Effect.forkScoped);
43
+ return {
44
+ attach: (name, pipe) => {
45
+ MutableRef.update(pipes, (all) => [...all, pipe]);
46
+ pipe.on("data", (bytes) => Option.map(chunk(name, decoders[name].decode(bytes, { stream: !0 })), (event) => deliver(event, pipe)));
47
+ pipe.on("error", (error) => Queue.failCauseUnsafe(queue, Cause.fail(new BashError({ reason: "SpawnFailed", message: `could not read the command's ${name}: ${errorMessageOf(error)}` }))));
48
+ },
49
+ flow: () => {
50
+ MutableRef.set(flowing, !0);
51
+ MutableRef.get(pipes).forEach((pipe) => pipe.resume());
52
+ },
53
+ finish: (exit) => {
54
+ Option.map(chunk("stdout", decoders.stdout.decode()), (event) => wait(event, Option.none()));
55
+ Option.map(chunk("stderr", decoders.stderr.decode()), (event) => wait(event, Option.none()));
56
+ wait(exit, Option.none());
57
+ }
58
+ };
59
+ }), makeBash = Effect.fnUntraced(function* (options = {}) {
25
60
  const { shell, killBackground, exitGraceMs } = { ...localSystemDefaults, ...options }, parentEnv = () => options.parentEnv ?? process.env, shellPath = yield* resolveShell(shell, Option.fromNullishOr(parentEnv().PATH)), stream = (command, run) => Stream.callback((queue) => Effect.gen(function* () {
26
61
  yield* checkCwd(run.cwd);
27
- const clock = yield* Effect.clockWith((current) => Effect.succeed(current)), exited = yield* Deferred.make(), closed = yield* Deferred.make(), finished = MutableRef.make(!1), decoders = { stdout: new TextDecoder, stderr: new TextDecoder }, emit = (name, text) => text.length > 0 && Queue.offerUnsafe(queue, { _tag: "BashChunk", stream: name, text }), started = yield* Clock.currentTimeMillis, child = yield* Effect.acquireRelease(Effect.try({
62
+ const clock = yield* Effect.clockWith((current) => Effect.succeed(current)), exited = yield* Deferred.make(), closed = yield* Deferred.make(), finished = MutableRef.make(!1), output = yield* relayOutput(queue), started = yield* Clock.currentTimeMillis, child = yield* Effect.acquireRelease(Effect.try({
28
63
  try: () => {
29
64
  const spawned = spawn(shellPath, ["-c", command], {
30
65
  cwd: run.cwd,
@@ -32,8 +67,10 @@ export const makeBash = (options = {}) => Effect.gen(function* () {
32
67
  detached: !0,
33
68
  stdio: [run.stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe"]
34
69
  });
35
- spawned.stdout?.on("data", (chunk) => emit("stdout", decoders.stdout.decode(chunk, { stream: !0 })));
36
- spawned.stderr?.on("data", (chunk) => emit("stderr", decoders.stderr.decode(chunk, { stream: !0 })));
70
+ if (spawned.stdout !== null)
71
+ output.attach("stdout", spawned.stdout);
72
+ if (spawned.stderr !== null)
73
+ output.attach("stderr", spawned.stderr);
37
74
  spawned.once("error", (error) => Deferred.doneUnsafe(exited, Effect.fail(new BashError({ reason: "SpawnFailed", message: errorMessageOf(error) }))));
38
75
  spawned.once("exit", (code, signal) => Deferred.doneUnsafe(exited, Effect.succeed({ code, signal, at: clock.currentTimeMillisUnsafe() })));
39
76
  spawned.once("close", () => Deferred.doneUnsafe(closed, Effect.void));
@@ -54,16 +91,22 @@ export const makeBash = (options = {}) => Effect.gen(function* () {
54
91
  })));
55
92
  if (killBackground)
56
93
  killGroup(child, !0);
94
+ output.flow();
57
95
  const drained = yield* Deferred.await(closed).pipe(Effect.timeoutOption(exitGraceMs));
58
96
  if (Option.isNone(drained)) {
59
97
  child.stdout?.destroy();
60
98
  child.stderr?.destroy();
61
99
  }
62
- emit("stdout", decoders.stdout.decode());
63
- emit("stderr", decoders.stderr.decode());
64
100
  MutableRef.set(finished, !0);
65
- yield* Queue.offer(queue, bashExitOf({ code: end.code, signal: end.signal, timedOut, durationMs: end.at - started }));
66
- yield* Queue.end(queue);
67
- }));
68
- return Bash.of({ stream, exec: (command, run) => collectBashResult(stream(command, run)) });
69
- }), BashLive = (options = {}) => Layer.effect(Bash, makeBash(options));
101
+ output.finish(bashExitOf({ code: end.code, signal: end.signal, timedOut, durationMs: end.at - started }));
102
+ }), { bufferSize: OUTPUT_BUFFER }), local = Bash.of({
103
+ stream,
104
+ exec: Effect.fn("bash.exec")(function* (command, run) {
105
+ return yield* collectBashResult(stream(command, run));
106
+ })
107
+ });
108
+ localShells.add(local);
109
+ return local;
110
+ });
111
+ const localShells = new WeakSet;
112
+ export const runsOnThisMachine = (bash) => localShells.has(bash), BashLive = (options = {}) => Layer.effect(Bash, makeBash(options));
@@ -15,6 +15,10 @@ type Fs = Context.Service.Shape<typeof FileSystem>;
15
15
  * `fileSystemConformance` on Bun and Node.
16
16
  */
17
17
  export declare const localFileSystem: Fs;
18
- /** The local `FileSystem` (`localFileSystem`) as a layer. */
18
+ /**
19
+ * The local `FileSystem` (`localFileSystem`) as a layer.
20
+ *
21
+ * @public
22
+ */
19
23
  export declare const FileSystemLive: Layer.Layer<FileSystem>;
20
24
  export {};
@@ -18,7 +18,7 @@ const refused = (path, reason, message) => new FsError({ path, reason, message }
18
18
  return size <= 0 ? Effect.succeed(chunks) : attempt(path, () => handle.read(new Uint8Array(size), 0, size, position)).pipe(Effect.flatMap(({ bytesRead, buffer }) => bytesRead === 0 ? Effect.succeed(chunks) : gather(position === null ? null : position + bytesRead, remaining - bytesRead, [...chunks, buffer.subarray(0, bytesRead)])));
19
19
  };
20
20
  return gather(offset === 0 ? null : offset, Option.getOrElse(length, () => Number.POSITIVE_INFINITY), []).pipe(Effect.map(joined));
21
- })), (handle) => Effect.tryPromise(() => handle.close()).pipe(Effect.ignore)), readBytes = (path, range) => {
21
+ })), (handle) => Effect.tryPromise(() => handle.close()).pipe(Effect.ignore({ log: "Warn", message: `the read handle of ${path} did not close` }))), readBytes = (path, range) => {
22
22
  const offset = range?.offset ?? 0, length = Option.fromNullishOr(range?.length);
23
23
  if (!(Number.isInteger(offset) && offset >= 0 && Option.match(length, { onNone: () => !0, onSome: (count) => Number.isInteger(count) && count >= 0 })))
24
24
  return Effect.fail(refused(path, "Invalid", `offset and length must be whole numbers of at least 0, not ${JSON.stringify(range)}`));
@@ -0,0 +1,5 @@
1
+ import { Layer } from "effect";
2
+ import { Bash, FileSystem, ImageResizer } from "@xandreed/core";
3
+ import { ImageResizerConfig } from "./image-resizer.entity.js";
4
+ /** Fit images through an optional host CLI; FileSystem and Bash must share a file tree. @public */
5
+ export declare const ImageResizerCliLive: (options?: Partial<ImageResizerConfig>) => Layer.Layer<ImageResizer, import("@xandreed/core").HarnessError, FileSystem | Bash>;
@@ -0,0 +1,68 @@
1
+ import { Effect, Layer, Option, Path, Schema } from "effect";
2
+ import { Bash, FileSystem, harnessError, ImageFitTarget, ImageInput, ImageResizeError, ImageResizer, imageSizeOf, mediaFromBytes, mergeOptions, sniffImageType } from "@xandreed/core";
3
+ import { imageOpacityCommand, imageResizeCommand, imageToolProbe, privateDirectoryCommand, privateInputCommand, privately } from "./image-resizer.entity.functions.js";
4
+ import { ImageResizerConfig, imageResizerDefaults, ImageResizeTool } from "./image-resizer.entity.js";
5
+ import { bashEnvironment } from "./workspace-tools.entity.functions.js";
6
+ import { workspaceToolsDefaults } from "./workspace-tools.entity.js";
7
+ const resizeError = (reason, message, cause) => new ImageResizeError({ reason, message, ...cause === void 0 ? {} : { cause } }), succeeded = (result) => result.termination === "exit" && Option.contains(result.exitCode, 0), makeResizer = Effect.fnUntraced(function* (options) {
8
+ const config = yield* Schema.decodeUnknownEffect(ImageResizerConfig)(mergeOptions(ImageResizerConfig, imageResizerDefaults, options), { onExcessProperty: "error" }).pipe(Effect.mapError((cause) => harnessError("config.options", "the image resizer options do not decode", cause))), fs = yield* FileSystem, bash = yield* Bash, path = yield* Path.Path.pipe(Effect.provide(Path.layer)), environment = bashEnvironment(workspaceToolsDefaults, Option.none()), candidates = config.tool === "auto" ? ImageResizeTool.literals : [config.tool], available = yield* Effect.findFirst(candidates, (tool) => bash.exec(imageToolProbe(tool), { cwd: "/", timeoutMs: Math.min(config.timeoutMs, 5000), ...environment }).pipe(Effect.map(succeeded), Effect.catch((error) => Effect.logDebug("an image conversion tool could not be probed", error).pipe(Effect.as(!1)))));
9
+ if (Option.isNone(available))
10
+ return yield* Effect.fail(harnessError("images.unavailable", "no image conversion tool is available; install ImageMagick, libvips or sips, or provide an ImageResizer"));
11
+ const tool = available.value, temporary = config.tempDir === null ? (yield* bash.exec(`printf '%s' "\${TMPDIR:-/tmp}"`, { cwd: "/", timeoutMs: Math.min(config.timeoutMs, 5000), ...environment }).pipe(Effect.mapError((cause) => harnessError("config.options", "the image resizer could not read TMPDIR; set tempDir", cause)))).stdout : config.tempDir;
12
+ if (!temporary.startsWith("/") || temporary.includes("\x00"))
13
+ return yield* Effect.fail(harnessError("config.options", "the image resizer tempDir must be an absolute POSIX directory"));
14
+ const fit = Effect.fn("images.fit")(function* (image, target) {
15
+ const input = yield* Schema.decodeUnknownEffect(ImageInput)(image).pipe(Effect.mapError((cause) => resizeError("Unsupported", "the image input does not decode", cause))), bounds = yield* Schema.decodeUnknownEffect(ImageFitTarget)(target).pipe(Effect.mapError((cause) => resizeError("Failed", "the image fit target does not decode", cause))), type = sniffImageType(input.bytes);
16
+ if (!Option.contains(type, input.mediaType))
17
+ return yield* Effect.fail(resizeError("Unsupported", "the bytes are not an image of the declared type"));
18
+ const size = imageSizeOf(input.bytes, input.mediaType);
19
+ if (Option.isNone(size))
20
+ return yield* Effect.fail(resizeError("Unsupported", "the image header does not give its dimensions"));
21
+ const original = size.value;
22
+ if (input.bytes.length <= bounds.maxBytes && Math.max(original.width, original.height) <= bounds.maxSide)
23
+ return input.bytes;
24
+ if (original.width * original.height > config.maxSourcePixels)
25
+ return yield* Effect.fail(resizeError("Unsupported", `the source image declares ${original.width}x${original.height} pixels, more than the resizer's maxSourcePixels (${config.maxSourcePixels})`));
26
+ return yield* Effect.scoped(Effect.gen(function* () {
27
+ const nonce = yield* Effect.sync(() => crypto.randomUUID()), directory = path.join(temporary, `efferent-img-${nonce}`);
28
+ yield* Effect.addFinalizer(() => fs.remove(directory, { recursive: !0 }).pipe(Effect.catch((error) => error.reason === "NotFound" ? Effect.void : Effect.logWarning("the image conversion temporary directory could not be removed", error))));
29
+ const created = yield* bash.exec(privateDirectoryCommand(directory), { cwd: temporary, timeoutMs: Math.min(config.timeoutMs, 5000), ...environment }).pipe(Effect.mapError((cause) => resizeError("Failed", "the image conversion directory could not be created", cause)));
30
+ if (!succeeded(created))
31
+ return yield* Effect.fail(resizeError("Failed", "the image conversion directory could not be created", created));
32
+ const written = yield* bash.exec(privateInputCommand("input.img"), { cwd: directory, stdin: input.bytes, timeoutMs: config.timeoutMs, ...environment }).pipe(Effect.mapError((cause) => resizeError("Failed", "the image conversion input could not be written", cause)));
33
+ if (!succeeded(written))
34
+ return yield* Effect.fail(resizeError("Failed", "the image conversion input could not be written", written));
35
+ const convert = (side, jpeg) => Effect.gen(function* () {
36
+ const output = path.join(directory, jpeg ? "output.jpg" : "output.png"), result = yield* bash.exec(privately(imageResizeCommand(tool, "input.img", jpeg ? "output.jpg" : "output.png", side, jpeg)), { cwd: directory, timeoutMs: config.timeoutMs, ...environment }).pipe(Effect.mapError((cause) => resizeError("Failed", "the image conversion command could not run", cause)));
37
+ if (!succeeded(result))
38
+ return yield* Effect.fail(resizeError("Failed", result.termination === "timeout" ? "the image conversion command timed out" : "the image conversion command failed", result));
39
+ const bytes = yield* fs.readBytes(output).pipe(Effect.mapError((cause) => resizeError("Failed", "the converted image could not be read", cause))), outputType = sniffImageType(bytes), outputSize = Option.flatMap(outputType, (mediaType) => imageSizeOf(bytes, mediaType));
40
+ if (Option.isNone(outputSize))
41
+ return yield* Effect.fail(resizeError("Failed", "the converted image header does not give its dimensions"));
42
+ const actual = outputSize.value;
43
+ if (!(actual.width <= original.width && actual.height <= original.height || actual.width <= original.height && actual.height <= original.width))
44
+ return yield* Effect.fail(resizeError("Failed", "the image converter enlarged the source"));
45
+ if (bytes.length > bounds.maxBytes || Math.max(actual.width, actual.height) > bounds.maxSide)
46
+ return Option.none();
47
+ return yield* mediaFromBytes(bytes).pipe(Effect.as(Option.some(bytes)), Effect.mapError((cause) => resizeError("Failed", "the converted image is not valid media", cause)));
48
+ }), fallback = (side) => Effect.gen(function* () {
49
+ const command = imageOpacityCommand(tool, "input.img");
50
+ if (input.mediaType === "image/png" && command.length > 0 ? yield* bash.exec(command, { cwd: directory, timeoutMs: config.timeoutMs, ...environment }).pipe(Effect.map((result) => succeeded(result) && (result.stdout.trim().toLowerCase() === "true" || /hasAlpha:\s*no\b/i.test(result.stdout))), Effect.catch((cause) => Effect.logDebug("the source opacity could not be checked", cause).pipe(Effect.as(!1)))) : !1) {
51
+ const fitted = yield* convert(side, !0);
52
+ if (Option.isSome(fitted))
53
+ return fitted.value;
54
+ }
55
+ return yield* Effect.fail(resizeError("CannotFit", "the image could not fit the requested byte and pixel limits"));
56
+ }), attempt = (side, remaining) => convert(side, input.mediaType === "image/jpeg").pipe(Effect.flatMap(Option.match({
57
+ onSome: Effect.succeed,
58
+ onNone: () => {
59
+ const smaller = Math.max(1, Math.floor(side * 0.75));
60
+ return remaining > 1 && smaller < side ? attempt(smaller, remaining - 1) : fallback(side);
61
+ }
62
+ })));
63
+ return yield* attempt(Math.min(bounds.maxSide, Math.max(original.width, original.height)), 4);
64
+ })).pipe(Effect.timeoutOrElse({ duration: config.timeoutMs, orElse: () => Effect.fail(resizeError("Failed", "the image conversion exceeded its time limit")) }));
65
+ });
66
+ return ImageResizer.of({ fit });
67
+ });
68
+ export const ImageResizerCliLive = (options = {}) => Layer.effect(ImageResizer, makeResizer(options));
@@ -0,0 +1,23 @@
1
+ import { Schema } from "effect";
2
+ /** The optional host CLI used to fit images. @public */
3
+ export declare const ImageResizeTool: Schema.Literals<readonly ["magick", "convert", "vips", "sips"]>;
4
+ export type ImageResizeTool = typeof ImageResizeTool.Type;
5
+ /**
6
+ * Opt-in image conversion through the host's matching FileSystem and Bash.
7
+ * auto probes once in tool order. tempDir is an existing absolute directory;
8
+ * null reads the Bash environment's TMPDIR, falling back to /tmp. timeoutMs
9
+ * bounds one fit, including its conversion attempts (cleanup follows).
10
+ * maxSourcePixels refuses a source whose header declares more pixels
11
+ * (width times height) before any converter decodes it, so a small file
12
+ * that declares a huge canvas cannot hold the host's memory or disk.
13
+ * @public
14
+ */
15
+ export declare const ImageResizerConfig: Schema.Struct<{
16
+ readonly tool: Schema.Literals<readonly ["auto", "magick", "convert", "vips", "sips"]>;
17
+ readonly tempDir: Schema.NullOr<Schema.String>;
18
+ readonly timeoutMs: Schema.Int;
19
+ readonly maxSourcePixels: Schema.Int;
20
+ }>;
21
+ export type ImageResizerConfig = typeof ImageResizerConfig.Type;
22
+ /** @public */
23
+ export declare const imageResizerDefaults: ImageResizerConfig;
@@ -0,0 +1,15 @@
1
+ import type { ImageResizeTool } from "./image-resizer.entity.js";
2
+ export declare const imageToolProbe: (tool: ImageResizeTool) => string;
3
+ /** A first-frame conversion; every shell word is literal and the box never upscales. */
4
+ export declare const imageResizeCommand: (tool: ImageResizeTool, input: string, output: string, side: number, jpeg?: boolean) => string;
5
+ /** The converter's own opacity check; a backend without one keeps transparency conservatively. */
6
+ export declare const imageOpacityCommand: (tool: ImageResizeTool, input: string) => string;
7
+ /**
8
+ * Run a command so that what it creates is its owner's alone: directories
9
+ * 0700, files 0600, whatever the host's umask.
10
+ */
11
+ export declare const privately: (command: string) => string;
12
+ /** Create the conversion directory, private and new (it fails when the path exists). */
13
+ export declare const privateDirectoryCommand: (directory: string) => string;
14
+ /** Write standard input to a private file in the command's directory. */
15
+ export declare const privateInputCommand: (name: string) => string;
@@ -0,0 +1,4 @@
1
+ import { Match } from "effect";
2
+ import { shellQuote } from "./shell.entity.functions.js";
3
+ const executable = { magick: "magick", convert: "convert", vips: "vipsthumbnail", sips: "sips" };
4
+ export const imageToolProbe = (tool) => [executable[tool], tool === "magick" || tool === "convert" ? "-version" : "--version"].map(shellQuote).join(" "), imageResizeCommand = (tool, input, output, side, jpeg = !1) => Match.value(tool).pipe(Match.when("magick", () => ["magick", `${input}[0]`, "-coalesce", "-auto-orient", "-strip", "-resize", `${side}x${side}>`, "-quality", "85", output]), Match.when("convert", () => ["convert", `${input}[0]`, "-coalesce", "-auto-orient", "-strip", "-resize", `${side}x${side}>`, "-quality", "85", output]), Match.when("vips", () => ["vipsthumbnail", input, "--size", `${side}x${side}>`, "--path", `${output}[strip${jpeg ? ",Q=85" : ""}]`]), Match.when("sips", () => ["sips", "-s", "format", jpeg ? "jpeg" : "png", ...jpeg ? ["-s", "formatOptions", "85"] : [], "-Z", String(side), input, "--out", output]), Match.exhaustive, (args) => args.map(shellQuote).join(" ")), imageOpacityCommand = (tool, input) => Match.value(tool).pipe(Match.when("magick", () => ["magick", `${input}[0]`, "-format", "%[opaque]", "info:"]), Match.when("convert", () => ["convert", `${input}[0]`, "-format", "%[opaque]", "info:"]), Match.when("vips", () => []), Match.when("sips", () => ["sips", "-g", "hasAlpha", input]), Match.exhaustive, (args) => args.map(shellQuote).join(" ")), privately = (command) => `umask 077 && ${command}`, privateDirectoryCommand = (directory) => privately(["mkdir", "--", directory].map(shellQuote).join(" ")), privateInputCommand = (name) => privately(`cat > ${shellQuote(name)}`);
@@ -0,0 +1,7 @@
1
+ import { Schema } from "effect";
2
+ export const ImageResizeTool = Schema.Literals(["magick", "convert", "vips", "sips"]), ImageResizerConfig = Schema.Struct({
3
+ tool: Schema.Literals(["auto", ...ImageResizeTool.literals]),
4
+ tempDir: Schema.NullOr(Schema.String.check(Schema.makeFilter((path) => path.startsWith("/") && !path.includes("\x00") ? !0 : "an absolute POSIX directory"))),
5
+ timeoutMs: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 600000 })),
6
+ maxSourcePixels: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1e9 }))
7
+ }), imageResizerDefaults = { tool: "auto", tempDir: null, timeoutMs: 30000, maxSourcePixels: 1e8 };
package/dist/index.d.ts CHANGED
@@ -8,5 +8,7 @@ export { SkillsDirectoryConfig, skillsDirectoryDefaults } from "./skills/skills-
8
8
  export { ALWAYS_SECTION_ID, SKILLS_CAPABILITY_ID, skillsCapability } from "./skills/skills-directory.entity.functions.js";
9
9
  export { loadSkillsDirectory, skillsDirectoryPlugin, SkillsDirectoryPluginLive } from "./skills/skills-directory.adapter.js";
10
10
  export { workspaceTools } from "./workspace-tools.adapter.js";
11
- export { BashParams, BashToolResult, EditParams, EditResult, FindParams, FindResult, GrepLine, GrepParams, GrepResult, LsEntry, LsParams, LsResult, ReadParams, ReadResult, TextEdit, WORKSPACE_PERMISSIONS, WorkspaceSkillName, WorkspaceToolName, workspaceToolSchemas, WorkspaceToolsConfig, workspaceToolsDefaults, WriteParams, WriteResult, } from "./workspace-tools.entity.js";
12
- export { applyTextEdits, normalizeLine } from "./text-edit.entity.functions.js";
11
+ export { BashParams, BashToolResult, EditParams, EditResult, FindParams, FindResult, GrepLine, GrepParams, GrepResult, GrepUnreadable, LsEntry, LsParams, LsResult, ReadParams, ReadResult, TextEdit, WORKSPACE_PERMISSIONS, WorkspaceSkillName, WorkspaceToolName, workspaceToolSchemas, WorkspaceToolsConfig, workspaceToolsDefaults, WriteParams, WriteResult, } from "./workspace-tools.entity.js";
12
+ export { ImageResizerCliLive } from "./image-resizer.adapter.js";
13
+ export { ImageResizeTool, ImageResizerConfig, imageResizerDefaults } from "./image-resizer.entity.js";
14
+ export { imageResizerPlugin, ImageResizerPluginLive } from "./plugin.adapter.js";
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  GrepLine,
18
18
  GrepParams,
19
19
  GrepResult,
20
+ GrepUnreadable,
20
21
  LsEntry,
21
22
  LsParams,
22
23
  LsResult,
@@ -32,4 +33,6 @@ export {
32
33
  WriteParams,
33
34
  WriteResult
34
35
  } from "./workspace-tools.entity.js";
35
- export { applyTextEdits, normalizeLine } from "./text-edit.entity.functions.js";
36
+ export { ImageResizerCliLive } from "./image-resizer.adapter.js";
37
+ export { ImageResizeTool, ImageResizerConfig, imageResizerDefaults } from "./image-resizer.entity.js";
38
+ export { imageResizerPlugin, ImageResizerPluginLive } from "./plugin.adapter.js";
@@ -13,22 +13,29 @@ import { Schema } from "effect";
13
13
  * output pipes close then, so a background process kept with
14
14
  * `killBackground: false` must have its own output (`cmd > log 2>&1 &`):
15
15
  * one still writing to the command's dies of `SIGPIPE` or gets `EPIPE`.
16
+ *
17
+ * @public
16
18
  */
17
19
  export declare const LocalSystemConfig: Schema.Struct<{
18
20
  readonly shell: Schema.NonEmptyString;
19
21
  readonly killBackground: Schema.Boolean;
20
22
  readonly exitGraceMs: Schema.Int;
23
+ /** The host's base environment, or null to inherit from the agent process. Filtered per command by `resolveBashEnv`. */
24
+ readonly parentEnv: Schema.NullOr<Schema.$Record<Schema.String, Schema.String>>;
21
25
  }>;
22
26
  export type LocalSystemConfig = typeof LocalSystemConfig.Type;
27
+ /** @public */
23
28
  export declare const localSystemDefaults: LocalSystemConfig;
24
29
  /**
25
30
  * `BashLive`'s options: the plugin's configuration, each part optional, and
26
31
  * `parentEnv`, the environment commands inherit from (filtered by
27
32
  * `resolveBashEnv`) and the shell is looked up on; `process.env` when not
28
- * given.
33
+ * given or null.
34
+ *
35
+ * @public
29
36
  */
30
- export type BashLiveOptions = Partial<LocalSystemConfig> & {
31
- readonly parentEnv?: Readonly<Record<string, string | undefined>>;
37
+ export type BashLiveOptions = Partial<Omit<LocalSystemConfig, "parentEnv">> & {
38
+ readonly parentEnv?: Readonly<Record<string, string | undefined>> | null;
32
39
  };
33
40
  /** How a spawned process ended, as Node reports it, and whether the adapter killed it for running past its timeout. */
34
41
  export declare const ProcessEnd: Schema.Struct<{
@@ -1,8 +1,6 @@
1
1
  import { Option, Result } from "effect";
2
2
  import type { BashExit, FsReason } from "@xandreed/core";
3
3
  import type { ProcessEnd } from "./local-system.entity.js";
4
- /** The `code` a Node error carries (`ENOENT`, `ERR_FS_EISDIR`, …), when it has one. */
5
- export declare const errorCodeOf: (error: unknown) => Option.Option<string>;
6
4
  /** A thrown value's message, for the `message` of the error it becomes. */
7
5
  export declare const errorMessageOf: (error: unknown) => string;
8
6
  /**
@@ -12,8 +10,11 @@ export declare const errorMessageOf: (error: unknown) => string;
12
10
  * and `EPERM` `Permission`; `EINVAL`, a symbolic link loop (`ELOOP`), a
13
11
  * name too long and an unusable argument (a path with a NUL byte) are
14
12
  * `Invalid`; anything else, or no code at all, is `Io`.
13
+ *
14
+ * @public
15
15
  */
16
16
  export declare const fsReasonOf: (error: unknown) => FsReason;
17
+ export declare const rgReasonOf: (code: number) => FsReason;
17
18
  /**
18
19
  * Whether a write to a command's standard input failed only because the
19
20
  * command no longer reads it: it exited or closed its input first (`EPIPE`),
@@ -1,5 +1,6 @@
1
1
  import { Option, Result } from "effect";
2
- export const errorCodeOf = (error) => typeof error === "object" && error !== null && ("code" in error) && typeof error.code === "string" ? Option.some(error.code) : Option.none(), errorMessageOf = (error) => error instanceof Error ? error.message : String(error);
2
+ import { errorCodeOf } from "@xandreed/core";
3
+ export const errorMessageOf = (error) => error instanceof Error ? error.message : String(error);
3
4
  const reasons = new Map([
4
5
  ["ENOENT", "NotFound"],
5
6
  ["EEXIST", "Exists"],
@@ -17,7 +18,20 @@ const reasons = new Map([
17
18
  export const fsReasonOf = (error) => Option.match(errorCodeOf(error), {
18
19
  onNone: () => "Io",
19
20
  onSome: (code) => reasons.get(code) ?? "Io"
20
- }), isClosedInput = (error) => Option.match(errorCodeOf(error), { onNone: () => !1, onSome: (code) => code === "EPIPE" || code === "ERR_STREAM_DESTROYED" }), executableCandidates = (name, path) => name.startsWith("/") ? [name] : name.includes("/") || name.length === 0 ? [] : Option.getOrElse(path, () => "").split(":").filter((dir) => dir.startsWith("/")).map((dir) => `${dir.endsWith("/") ? dir.slice(0, -1) : dir}/${name}`), removalPlace = (path) => {
21
+ });
22
+ const rgReasons = new Map([
23
+ [1, "Permission"],
24
+ [13, "Permission"],
25
+ [2, "NotFound"],
26
+ [20, "NotDirectory"],
27
+ [21, "IsDirectory"],
28
+ [22, "Invalid"],
29
+ [36, "Invalid"],
30
+ [40, "Invalid"],
31
+ [62, "Invalid"],
32
+ [63, "Invalid"]
33
+ ]);
34
+ export const rgReasonOf = (code) => rgReasons.get(code) ?? "Io", isClosedInput = (error) => Option.match(errorCodeOf(error), { onNone: () => !1, onSome: (code) => code === "EPIPE" || code === "ERR_STREAM_DESTROYED" }), executableCandidates = (name, path) => name.startsWith("/") ? [name] : name.includes("/") || name.length === 0 ? [] : Option.getOrElse(path, () => "").split(":").filter((dir) => dir.startsWith("/")).map((dir) => `${dir.endsWith("/") ? dir.slice(0, -1) : dir}/${name}`), removalPlace = (path) => {
21
35
  const segments = path.split("/").filter((segment) => segment.length > 0);
22
36
  return !path.startsWith("/") ? Result.fail(`paths must be absolute: ${path}`) : Option.match(Option.fromNullishOr(segments.at(-1)), {
23
37
  onNone: () => Result.fail("the root directory is never removed"),
@@ -2,8 +2,9 @@ import { Schema } from "effect";
2
2
  export const LocalSystemConfig = Schema.Struct({
3
3
  shell: Schema.NonEmptyString,
4
4
  killBackground: Schema.Boolean,
5
- exitGraceMs: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 0, maximum: 60000 })))
6
- }), localSystemDefaults = { shell: "bash", killBackground: !0, exitGraceMs: 500 }, ProcessEnd = Schema.Struct({
5
+ exitGraceMs: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 0, maximum: 60000 }))),
6
+ parentEnv: Schema.NullOr(Schema.Record(Schema.String, Schema.String))
7
+ }), localSystemDefaults = { shell: "bash", killBackground: !0, exitGraceMs: 500, parentEnv: null }, ProcessEnd = Schema.Struct({
7
8
  code: Schema.NullOr(Schema.Int),
8
9
  signal: Schema.NullOr(Schema.String),
9
10
  timedOut: Schema.Boolean,