@demicodes/shell 0.17.4 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,8 @@ same agent can target local, container, remote, or in-memory backends.
12
12
 
13
13
  Subpaths: `@demicodes/shell/storage`, `@demicodes/shell/host-fs`.
14
14
  See [docs/shell-yield-control-plan.md](../../docs/shell-yield-control-plan.md) for
15
- the model-facing control surface and yield wakeups.
15
+ the model-facing control surface and yield wakeups, and
16
+ [docs/bash-behavior.md](../../docs/bash-behavior.md) for Host-backed behavior
17
+ versus GNU bash.
16
18
 
17
19
  Part of [Demi](../../README.md). Apache-2.0.
@@ -13,6 +13,12 @@ interface Host {
13
13
  fs: HostFileSystem;
14
14
  process: HostProcess;
15
15
  store: HostStore;
16
+ identity: HostIdentity;
17
+ }
18
+ interface HostIdentity {
19
+ uid: number;
20
+ gid: number;
21
+ hostname: string;
16
22
  }
17
23
  interface HostFileSystem {
18
24
  readFile(path: string, options?: {
@@ -80,6 +86,17 @@ interface HostFileSystem {
80
86
  }
81
87
  interface HostProcess {
82
88
  spawn(params: HostSpawnParams): Promise<HostSpawnHandle>;
89
+ openCwd(path: string): Promise<HostCwd>;
90
+ }
91
+ type SpawnErrorKind = 'executable_not_found' | 'permission_denied' | 'cwd_unusable' | 'is_directory' | 'other';
92
+ interface HostCwd {
93
+ readonly path: string;
94
+ spawnPath(): string;
95
+ chdir(path: string): Promise<void>;
96
+ snapshot(): Promise<{
97
+ restore(): void;
98
+ }>;
99
+ close(): Promise<void>;
83
100
  }
84
101
  /**
85
102
  * Keyed JSON state storage. Implementations must round-trip `Uint8Array` and
@@ -99,6 +116,13 @@ interface HostFileStat {
99
116
  mode: number;
100
117
  size: number;
101
118
  mtime: Date;
119
+ uid?: number;
120
+ gid?: number;
121
+ ino?: number;
122
+ dev?: number;
123
+ nlink?: number;
124
+ isCharacterDevice?: boolean;
125
+ isFIFO?: boolean;
102
126
  }
103
127
  interface HostDirent {
104
128
  name: string;
@@ -129,6 +153,11 @@ interface HostProcessOutputChunk {
129
153
  interface HostSpawnExit {
130
154
  exitCode: number | null;
131
155
  signal?: string;
156
+ spawnError?: {
157
+ kind: SpawnErrorKind;
158
+ };
132
159
  }
160
+ /** Path-string cwd for test doubles and Hosts that cannot hold a directory fd. */
161
+ declare function createLogicalHostCwd(initialPath: string): HostCwd;
133
162
  //#endregion
134
- export { HostProcess as a, HostSpawnHandle as c, HostFileSystem as i, HostSpawnParams as l, HostDirent as n, HostProcessOutputChunk as o, HostFileStat as r, HostSpawnExit as s, Host as t, HostStore as u };
163
+ export { HostFileSystem as a, HostProcessOutputChunk as c, HostSpawnParams as d, HostStore as f, HostFileStat as i, HostSpawnExit as l, createLogicalHostCwd as m, HostCwd as n, HostIdentity as o, SpawnErrorKind as p, HostDirent as r, HostProcess as s, Host as t, HostSpawnHandle as u };
@@ -1,4 +1,4 @@
1
- import { t as Host } from "./host-B-oKG7x5.mjs";
1
+ import { t as Host } from "./host-BupAFtK6.mjs";
2
2
  import { BufferEncoding, CpOptions, DirentEntry, FileContent, FsStat, IFileSystem, MkdirOptions, ReadFileOptions, RmOptions, WriteFileOptions } from "@demicodes/just-bash/fs/interface";
3
3
  //#region src/host-fs.d.ts
4
4
  interface HostBackedFileSystemOptions {
package/dist/host-fs.mjs CHANGED
@@ -134,7 +134,14 @@ function toFsStat(value) {
134
134
  isSymbolicLink: value.isSymbolicLink,
135
135
  mode: value.mode,
136
136
  size: value.size,
137
- mtime: value.mtime
137
+ mtime: value.mtime,
138
+ uid: value.uid,
139
+ gid: value.gid,
140
+ ino: value.ino,
141
+ dev: value.dev,
142
+ nlink: value.nlink,
143
+ isCharacterDevice: value.isCharacterDevice,
144
+ isFIFO: value.isFIFO
138
145
  };
139
146
  }
140
147
  function toDirentEntry(value) {
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
- import { a as HostProcess, c as HostSpawnHandle, i as HostFileSystem, l as HostSpawnParams, n as HostDirent, o as HostProcessOutputChunk, r as HostFileStat, s as HostSpawnExit, t as Host, u as HostStore } from "./host-B-oKG7x5.mjs";
1
+ import { a as HostFileSystem, c as HostProcessOutputChunk, d as HostSpawnParams, f as HostStore, i as HostFileStat, l as HostSpawnExit, m as createLogicalHostCwd, n as HostCwd, o as HostIdentity, p as SpawnErrorKind, r as HostDirent, s as HostProcess, t as Host, u as HostSpawnHandle } from "./host-BupAFtK6.mjs";
2
2
  import { HostBackedFileSystem, HostBackedFileSystemOptions } from "./host-fs.mjs";
3
- import { _ as runRegisteredCommand, a as CommandIO, c as CommandRegistry, d as CommandStdin, f as CommandStorage, g as renderCommandHelp, h as parseCommandInput, i as CommandExecutionContext, l as CommandRunContext, m as emptyStdin, n as COMMAND_HELP_DEFAULTS, o as CommandInputSpec, p as ParsedCommandInput, r as Command, s as CommandOutputSpec, t as AgentSessionCommandStorage, u as CommandRunResult } from "./storage-DYvEwSc9.mjs";
3
+ import { _ as runRegisteredCommand, a as CommandIO, c as CommandRegistry, d as CommandStdin, f as CommandStorage, g as renderCommandHelp, h as parseCommandInput, i as CommandExecutionContext, l as CommandRunContext, m as emptyStdin, n as COMMAND_HELP_DEFAULTS, o as CommandInputSpec, p as ParsedCommandInput, r as Command, s as CommandOutputSpec, t as AgentSessionCommandStorage, u as CommandRunResult } from "./storage-BDRwHNOB.mjs";
4
4
  import { CommandName } from "@demicodes/just-bash/commands";
5
5
  import { Interpreter, InterpreterState } from "@demicodes/just-bash/interpreter";
6
6
  import { CommandRegistry as CommandRegistry$1, ExecResult } from "@demicodes/just-bash/types";
@@ -19,6 +19,7 @@ interface ShellSession {
19
19
  fs: HostBackedFileSystem;
20
20
  interpreter: Interpreter;
21
21
  forkCommands: CommandRegistry$1;
22
+ cwdHandle: HostCwd;
22
23
  accumulator: ExecAccumulator;
23
24
  foreground?: ForegroundProcess;
24
25
  activeCommandId?: string;
@@ -43,10 +44,7 @@ interface BackgroundJob {
43
44
  droppedStderrChars: number;
44
45
  stdoutPump: Promise<void>;
45
46
  stderrPump: Promise<void>;
46
- exitPromise: Promise<{
47
- exitCode: number | null;
48
- signal?: string;
49
- }>;
47
+ exitPromise: Promise<HostSpawnExit>;
50
48
  }
51
49
  interface ForegroundProcess {
52
50
  commandId: string;
@@ -76,10 +74,7 @@ interface ForegroundProcess {
76
74
  audit: BashAuditEvent[];
77
75
  stdoutPump: Promise<void>;
78
76
  stderrPump: Promise<void>;
79
- exitPromise: Promise<{
80
- exitCode: number | null;
81
- signal?: string;
82
- }>;
77
+ exitPromise: Promise<HostSpawnExit>;
83
78
  outputSinks: Record<1 | 2, ForegroundSink>;
84
79
  abortController: AbortController;
85
80
  }
@@ -303,6 +298,7 @@ declare class BashEnvironment {
303
298
  private raceForeground;
304
299
  private waitForBoundary;
305
300
  private hostSpawn;
301
+ private hostResolveCommand;
306
302
  private collectExited;
307
303
  private finishExited;
308
304
  private collectAborted;
@@ -320,24 +316,12 @@ declare class BashEnvironment {
320
316
  * copy: `CommandName` is just-bash's own "safe to run anywhere" set — it
321
317
  * already excludes the commands that need real process/network access
322
318
  * (curl → NetworkCommandName, python3/python → PythonCommandName, js-exec/node
323
- * → JavaScriptCommandName all live in separate, non-portable type unions). A
324
- * hand-picked subset of this list drifts silently as just-bash adds commands
325
- * (this one was missing echo, pwd, printf, date, env, hostname, whoami, gzip,
326
- * tar, split, sqlite3, xan, and more — all already categorized as safe
327
- * upstream, just never backported here). Deriving it removes that drift
328
- * entirely: everything just-bash calls portable (minus the exceptions above),
329
- * we call portable.
319
+ * → JavaScriptCommandName all live in separate, non-portable type unions).
320
+ * Unix names `hostSpawn` first (`preferHostSpawn`); portable is fallback for
321
+ * `executable_not_found` only.
330
322
  */
331
323
  declare const DEMI_PORTABLE_COMMANDS: readonly CommandName[];
332
- /**
333
- * Portable commands routed real-binary-first (`preferHostSpawn`): recursive
334
- * tree scanners whose in-process implementations read files whole and burn
335
- * the embedding host's main thread for minutes on large trees. A real
336
- * process runs off-thread with its output bounded by the capture limit;
337
- * hosts without the binary fall back to the portable implementation on
338
- * first use.
339
- */
340
- declare const HOST_PREFERRED_SCAN_COMMANDS: ReadonlySet<string>;
324
+ declare function shouldPreferHostSpawn(name: string): boolean;
341
325
  /**
342
326
  * Names registered commands must not shadow, derived from the actual portable
343
327
  * command set plus interpreter builtins and pass-through system tools — not a
@@ -351,4 +335,4 @@ declare function shellQuote(value: string): string;
351
335
  /** Picks a heredoc delimiter that does not collide with any line already in `body`. */
352
336
  declare function heredocDelimiter(body: string): string;
353
337
  //#endregion
354
- export { AgentSessionCommandStorage, BashAuditEvent, BashEnvironment, BashEnvironmentOptions, BinaryStdout, COMMAND_HELP_DEFAULTS, Command, CommandExecutionContext, CommandIO, CommandInputSpec, CommandMetadataRecord, CommandOutputSpec, CommandRegistry, CommandRunContext, CommandRunResult, CommandStdin, CommandStorage, DEMI_PORTABLE_COMMANDS, HOST_PREFERRED_SCAN_COMMANDS, Host, HostBackedFileSystem, HostBackedFileSystemOptions, HostDirent, HostFileStat, HostFileSystem, HostProcess, HostProcessOutputChunk, HostSpawnExit, HostSpawnHandle, HostSpawnParams, HostStore, MAX_TIMEOUT_MS, ParsedCommandInput, RESERVED_COMMAND_NAMES, ShellAbortInput, ShellCommandStatus, ShellExecInput, ShellOutputChunk, ShellOutputRecordChunk, ShellOutputView, ShellStatusInput, ShellStreamView, ShellWriteInput, emptyStdin, heredocDelimiter, parseCommandInput, renderCommandHelp, runRegisteredCommand, shellQuote };
338
+ export { AgentSessionCommandStorage, BashAuditEvent, BashEnvironment, BashEnvironmentOptions, BinaryStdout, COMMAND_HELP_DEFAULTS, Command, CommandExecutionContext, CommandIO, CommandInputSpec, CommandMetadataRecord, CommandOutputSpec, CommandRegistry, CommandRunContext, CommandRunResult, CommandStdin, CommandStorage, DEMI_PORTABLE_COMMANDS, Host, HostBackedFileSystem, HostBackedFileSystemOptions, HostCwd, HostDirent, HostFileStat, HostFileSystem, HostIdentity, HostProcess, HostProcessOutputChunk, HostSpawnExit, HostSpawnHandle, HostSpawnParams, HostStore, MAX_TIMEOUT_MS, ParsedCommandInput, RESERVED_COMMAND_NAMES, ShellAbortInput, ShellCommandStatus, ShellExecInput, ShellOutputChunk, ShellOutputRecordChunk, ShellOutputView, ShellStatusInput, ShellStreamView, ShellWriteInput, SpawnErrorKind, createLogicalHostCwd, emptyStdin, heredocDelimiter, parseCommandInput, renderCommandHelp, runRegisteredCommand, shellQuote, shouldPreferHostSpawn };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { HostBackedFileSystem } from "./host-fs.mjs";
2
2
  import { AgentSessionCommandStorage } from "./storage.mjs";
3
- import { asError, concatBytes, decodeLatin1, decodeUtf8, decodeUtf8Strict, encodeLatin1, encodeUtf8, tail, utf8Bytes, utf8Slice } from "@demicodes/utils";
3
+ import { asError, concatBytes, decodeLatin1, decodeUtf8, decodeUtf8Strict, encodeLatin1, encodeUtf8, isAbsolutePath, tail, utf8Bytes, utf8Slice } from "@demicodes/utils";
4
4
  import { createLazyCommands, getCommandNames } from "@demicodes/just-bash/commands";
5
5
  import { ArithmeticError, BadSubstitutionError, ExecutionLimitError, ExitError, Interpreter } from "@demicodes/just-bash/interpreter";
6
6
  import { decodeBytesToUtf8, unsafeBytesFromLatin1 } from "@demicodes/just-bash/encoding";
@@ -30,6 +30,21 @@ const REAL_SPAWN_DEPENDENT_COMMANDS = /* @__PURE__ */ new Set([
30
30
  "sleep"
31
31
  ]);
32
32
  /**
33
+ * just-bash portable names that implement bash builtins (or shell state).
34
+ * They stay in-process. `true`/`false` are omitted from the registry so the
35
+ * interpreter builtins run.
36
+ */
37
+ const IN_PROCESS_PORTABLE_COMMANDS = /* @__PURE__ */ new Set([
38
+ "echo",
39
+ "printf",
40
+ "pwd",
41
+ "alias",
42
+ "unalias",
43
+ "history",
44
+ "help",
45
+ "time"
46
+ ]);
47
+ /**
33
48
  * Fork portable commands BashEnvironment registers in every shell, so
34
49
  * cat/ls/grep-class tools work without local coreutils on any Host backend.
35
50
  *
@@ -37,28 +52,14 @@ const REAL_SPAWN_DEPENDENT_COMMANDS = /* @__PURE__ */ new Set([
37
52
  * copy: `CommandName` is just-bash's own "safe to run anywhere" set — it
38
53
  * already excludes the commands that need real process/network access
39
54
  * (curl → NetworkCommandName, python3/python → PythonCommandName, js-exec/node
40
- * → JavaScriptCommandName all live in separate, non-portable type unions). A
41
- * hand-picked subset of this list drifts silently as just-bash adds commands
42
- * (this one was missing echo, pwd, printf, date, env, hostname, whoami, gzip,
43
- * tar, split, sqlite3, xan, and more — all already categorized as safe
44
- * upstream, just never backported here). Deriving it removes that drift
45
- * entirely: everything just-bash calls portable (minus the exceptions above),
46
- * we call portable.
47
- */
48
- const DEMI_PORTABLE_COMMANDS = getCommandNames().filter((name) => !REAL_SPAWN_DEPENDENT_COMMANDS.has(name));
49
- /**
50
- * Portable commands routed real-binary-first (`preferHostSpawn`): recursive
51
- * tree scanners whose in-process implementations read files whole and burn
52
- * the embedding host's main thread for minutes on large trees. A real
53
- * process runs off-thread with its output bounded by the capture limit;
54
- * hosts without the binary fall back to the portable implementation on
55
- * first use.
55
+ * → JavaScriptCommandName all live in separate, non-portable type unions).
56
+ * Unix names `hostSpawn` first (`preferHostSpawn`); portable is fallback for
57
+ * `executable_not_found` only.
56
58
  */
57
- const HOST_PREFERRED_SCAN_COMMANDS = /* @__PURE__ */ new Set([
58
- "rg",
59
- "grep",
60
- "find"
61
- ]);
59
+ const DEMI_PORTABLE_COMMANDS = getCommandNames().filter((name) => !REAL_SPAWN_DEPENDENT_COMMANDS.has(name) && name !== "true" && name !== "false");
60
+ function shouldPreferHostSpawn(name) {
61
+ return !IN_PROCESS_PORTABLE_COMMANDS.has(name);
62
+ }
62
63
  /** Shell language words and builtins the interpreter itself owns. */
63
64
  const SHELL_BUILTIN_NAMES = [
64
65
  ".",
@@ -82,17 +83,17 @@ const SHELL_BUILTIN_NAMES = [
82
83
  "shift",
83
84
  "source",
84
85
  "test",
86
+ "true",
87
+ "false",
85
88
  "unset",
86
89
  "wait"
87
90
  ];
88
91
  /**
89
92
  * Ecosystem tools the model expects to reach through Host.process.spawn.
90
93
  *
91
- * xargs and yq are deliberately absent even though real versions exist: both
92
- * now have a real (non-simulated-elsewhere) implementation in just-bash's own
93
- * portable set above, which registers and dispatches before Host.process.spawn
94
- * is ever consulted — listing them here too would claim they fall through to
95
- * a real spawn, which no longer happens.
94
+ * xargs and yq stay out of this list: they are already in the portable set
95
+ * (`preferHostSpawn`, then the in-process implementation if the Host has no
96
+ * binary). Listing them here would duplicate a reserved name.
96
97
  */
97
98
  const SYSTEM_TOOL_NAMES = [
98
99
  "bun",
@@ -127,6 +128,7 @@ function emptyStdin() {
127
128
  bytes: /* @__PURE__ */ new Uint8Array(0)
128
129
  };
129
130
  }
131
+ async function* emptyStdinStream() {}
130
132
  const EXECUTION_ONLY_FIELDS = [
131
133
  "successOutput",
132
134
  "failureOutput",
@@ -220,7 +222,7 @@ function parseArgs(command, path, argv, startIndex, stdin) {
220
222
  setParsedValue(values, field, token);
221
223
  positionalIndex += 1;
222
224
  }
223
- if (command.stdinField) values[command.stdinField] = stdin.text;
225
+ if (command.stdinField && values[command.stdinField] === void 0) values[command.stdinField] = stdin.text;
224
226
  return {
225
227
  path: [...path],
226
228
  help: false,
@@ -261,7 +263,9 @@ async function runRegisteredCommand(root, ctx) {
261
263
  cwd: ctx.cwd,
262
264
  io: parsed.json ? capture : ctx.io,
263
265
  storage: ctx.storage,
264
- host: ctx.host
266
+ host: ctx.host,
267
+ signal: ctx.signal ?? new AbortController().signal,
268
+ stdinStream: ctx.stdinStream ?? emptyStdinStream()
265
269
  });
266
270
  if (parsed.json && result.exitCode === 0) {
267
271
  const raw = capture.stdoutText();
@@ -695,24 +699,34 @@ var CommandArtifactStore = class {
695
699
  };
696
700
  //#endregion
697
701
  //#region src/registered-command-adapter.ts
698
- function commandToForkCommand(session, command, storage, host) {
702
+ /**
703
+ * Runs a registered command as a shell foreground job: it exposes the same
704
+ * control surface as a host process (abort signal, live stdout/stderr view,
705
+ * stdin as a post-start chunk stream) through a virtual process handle, so
706
+ * `shell_status` / `shell_write` / `shell_abort` apply uniformly.
707
+ */
708
+ function commandToForkCommand(session, command, storage, host, captureLimitBytes) {
699
709
  return {
700
710
  name: command.name,
701
711
  consumesStdin: treeConsumesStdin(command),
702
712
  execute: async (args, ctx) => {
703
713
  const stdin = decodeForkStdin(ctx.stdin);
704
- const io = createForwardingIO();
705
714
  const argv = [command.name, ...args];
715
+ const job = new VirtualForegroundJob(session, command.name, args, ctx.cwd, captureLimitBytes);
716
+ job.install();
706
717
  try {
707
- const result = await runRegisteredCommand(command, {
718
+ const result = await Promise.race([runRegisteredCommand(command, {
708
719
  argv,
709
720
  stdin,
710
721
  env: mapToRecord(ctx.env),
711
722
  cwd: ctx.cwd,
712
- io,
723
+ io: job.io,
713
724
  storage,
714
- host
715
- });
725
+ host,
726
+ signal: job.signal,
727
+ stdinStream: job.stdinChunks()
728
+ }), job.killedResult()]);
729
+ if (job.foreground.captureOverflowed) return job.overflowResult();
716
730
  session.accumulator.audit.push({
717
731
  kind: "registered-command",
718
732
  name: command.name,
@@ -726,12 +740,13 @@ function commandToForkCommand(session, command, storage, host) {
726
740
  metadata: result.metadata
727
741
  });
728
742
  return {
729
- stdout: io.stdoutLatin1(),
743
+ stdout: job.stdoutLatin1(),
730
744
  stdoutKind: "bytes",
731
- stderr: io.stderrText(),
745
+ stderr: job.stderrText(),
732
746
  exitCode: result.exitCode
733
747
  };
734
748
  } catch (error) {
749
+ if (job.foreground.captureOverflowed) return job.overflowResult();
735
750
  const message = error instanceof Error ? error.message : String(error);
736
751
  session.accumulator.audit.push({
737
752
  kind: "registered-command",
@@ -740,35 +755,171 @@ function commandToForkCommand(session, command, storage, host) {
740
755
  exitCode: 1
741
756
  });
742
757
  return {
743
- stdout: io.stdoutLatin1(),
758
+ stdout: job.stdoutLatin1(),
744
759
  stdoutKind: "bytes",
745
- stderr: `${io.stderrText()}${command.name}: ${message}\n`,
760
+ stderr: `${job.stderrText()}${command.name}: ${message}\n`,
746
761
  exitCode: 1
747
762
  };
763
+ } finally {
764
+ job.release();
748
765
  }
749
766
  }
750
767
  };
751
768
  }
752
- /** Collects command output as raw bytes; stdout stays byte-clean for the pipe. */
753
- var ForwardingIO = class {
754
- stdoutChunks = [];
755
- stderrChunks = [];
756
- async stdout(data) {
757
- this.stdoutChunks.push(typeof data === "string" ? encodeUtf8(data) : data);
769
+ /**
770
+ * In-process stand-in for an OS process. `kill` maps to the abort signal
771
+ * (there is no OS process to signal); a SIGKILL additionally abandons the
772
+ * run so the shell never waits on an in-process function that ignores its
773
+ * signal.
774
+ */
775
+ var VirtualForegroundJob = class {
776
+ session;
777
+ captureLimitBytes;
778
+ foreground;
779
+ io;
780
+ abortController = new AbortController();
781
+ stdinQueue = new StdinChunkQueue();
782
+ settleExit;
783
+ killed;
784
+ killedPromise;
785
+ exitPromise;
786
+ installed = false;
787
+ constructor(session, command, args, cwd, captureLimitBytes) {
788
+ this.session = session;
789
+ this.captureLimitBytes = captureLimitBytes;
790
+ this.exitPromise = new Promise((resolve) => {
791
+ this.settleExit = resolve;
792
+ });
793
+ this.killedPromise = new Promise((_resolve, reject) => {
794
+ this.killed = reject;
795
+ });
796
+ this.killedPromise.catch(() => {});
797
+ const handle = {
798
+ stdout: emptyByteStream(),
799
+ stderr: emptyByteStream(),
800
+ writeStdin: async (data) => this.stdinQueue.push(data),
801
+ closeStdin: async () => this.stdinQueue.close(),
802
+ kill: async (signal) => {
803
+ this.abortController.abort();
804
+ this.stdinQueue.close();
805
+ if (signal === "SIGKILL") {
806
+ this.settleExit({
807
+ exitCode: 137,
808
+ signal: "SIGKILL"
809
+ });
810
+ this.killed(/* @__PURE__ */ new Error(`${command}: killed`));
811
+ }
812
+ },
813
+ wait: () => this.exitPromise
814
+ };
815
+ const startedAt = Date.now();
816
+ this.foreground = {
817
+ commandId: session.activeCommandId ?? "",
818
+ command,
819
+ args,
820
+ cwd,
821
+ handle,
822
+ startedAt,
823
+ lastOutputAt: startedAt,
824
+ rawStdoutBuffer: "",
825
+ rawStdoutBytes: [],
826
+ rawStderrBuffer: "",
827
+ stdoutBuffer: "",
828
+ stderrBuffer: "",
829
+ outputChunks: [],
830
+ outputBytes: 0,
831
+ capturedBytes: 0,
832
+ captureOverflowed: false,
833
+ audit: [],
834
+ stdoutPump: Promise.resolve(),
835
+ stderrPump: Promise.resolve(),
836
+ exitPromise: this.exitPromise,
837
+ outputSinks: createOutputSinks(session.fs, cwd, void 0),
838
+ abortController: this.abortController
839
+ };
840
+ this.io = {
841
+ stdout: (data) => {
842
+ recordForegroundChunk(this.foreground, 1, toBytes(data), this.captureLimitBytes);
843
+ },
844
+ stderr: (data) => {
845
+ recordForegroundChunk(this.foreground, 2, toBytes(data), this.captureLimitBytes);
846
+ }
847
+ };
758
848
  }
759
- async stderr(data) {
760
- this.stderrChunks.push(typeof data === "string" ? encodeUtf8(data) : data);
849
+ get signal() {
850
+ return this.abortController.signal;
851
+ }
852
+ stdinChunks() {
853
+ return this.stdinQueue.stream();
854
+ }
855
+ killedResult() {
856
+ return this.killedPromise;
857
+ }
858
+ /** Registers this job as the session foreground so shell control verbs route here. */
859
+ install() {
860
+ if (this.session.foreground || !this.foreground.commandId) return;
861
+ this.session.foreground = this.foreground;
862
+ this.installed = true;
863
+ notifyForegroundWaiters(this.session.foregroundWaiters, this.foreground);
864
+ }
865
+ release() {
866
+ this.stdinQueue.close();
867
+ this.settleExit({ exitCode: 0 });
868
+ if (this.installed && this.session.foreground === this.foreground) this.session.foreground = void 0;
761
869
  }
762
870
  stdoutLatin1() {
763
- return decodeLatin1(concatBytes(this.stdoutChunks));
871
+ return decodeLatin1(concatBytes(this.foreground.rawStdoutBytes));
764
872
  }
765
873
  stderrText() {
766
- return decodeUtf8(concatBytes(this.stderrChunks));
874
+ return this.foreground.rawStderrBuffer;
875
+ }
876
+ overflowResult() {
877
+ return {
878
+ stdout: "",
879
+ stdoutKind: "bytes",
880
+ stderr: `${this.foreground.command}: output exceeded the ${this.captureLimitBytes}-byte capture limit and the command was stopped; the shell buffers whole command outputs in memory — narrow the output at the source (filters, head, tighter paths)\n`,
881
+ exitCode: 137
882
+ };
883
+ }
884
+ };
885
+ /** Async chunk queue: each pushed chunk is delivered once, in order; close ends the stream. */
886
+ var StdinChunkQueue = class {
887
+ chunks = [];
888
+ waiter = null;
889
+ isClosed = false;
890
+ push(data) {
891
+ if (this.isClosed) return;
892
+ this.chunks.push(data);
893
+ this.wake();
894
+ }
895
+ close() {
896
+ if (this.isClosed) return;
897
+ this.isClosed = true;
898
+ this.wake();
899
+ }
900
+ async *stream() {
901
+ while (true) {
902
+ const chunk = this.chunks.shift();
903
+ if (chunk) {
904
+ yield chunk;
905
+ continue;
906
+ }
907
+ if (this.isClosed) return;
908
+ await new Promise((resolve) => {
909
+ this.waiter = resolve;
910
+ });
911
+ }
912
+ }
913
+ wake() {
914
+ const waiter = this.waiter;
915
+ this.waiter = null;
916
+ waiter?.();
767
917
  }
768
918
  };
769
- function createForwardingIO() {
770
- return new ForwardingIO();
919
+ function toBytes(data) {
920
+ return typeof data === "string" ? encodeUtf8(data) : data;
771
921
  }
922
+ async function* emptyByteStream() {}
772
923
  function treeConsumesStdin(command) {
773
924
  if (command.stdinField) return true;
774
925
  return command.subcommands?.some(treeConsumesStdin) ?? false;
@@ -877,7 +1028,7 @@ var BashEnvironment = class {
877
1028
  if (input.cwd !== void 0) {
878
1029
  if (!(await this.host.fs.stat(input.cwd).catch(() => null))?.isDirectory) throw new Error(`Shell exec cwd is not a directory: ${input.cwd}`);
879
1030
  }
880
- const session = input.shellId ? this.requireShell(input.shellId) : input.ephemeral ? this.createShell(input.agentSessionId, input.cwd) : this.availableDefaultShell(input.agentSessionId);
1031
+ const session = input.shellId ? this.requireShell(input.shellId) : input.ephemeral ? await this.createShell(input.agentSessionId, input.cwd) : await this.availableDefaultShell(input.agentSessionId);
881
1032
  if (session.exited) throw new Error(`Shell session "${session.id}" has exited`);
882
1033
  if (session.pendingExec || session.foreground) {
883
1034
  const commandId = session.activeCommandId ?? session.foreground?.commandId ?? "unknown";
@@ -944,6 +1095,7 @@ var BashEnvironment = class {
944
1095
  await job.exitPromise.catch(() => {});
945
1096
  }
946
1097
  session.backgroundJobs.clear();
1098
+ await session.cwdHandle.close().catch(() => {});
947
1099
  if (session.abortController) session.abortController.abort();
948
1100
  }
949
1101
  requireShell(shellId) {
@@ -968,21 +1120,21 @@ var BashEnvironment = class {
968
1120
  if (!foreground || foreground.commandId !== commandId) throw new Error(`Command "${commandId}" has no foreground process`);
969
1121
  return foreground;
970
1122
  }
971
- defaultShell(agentSessionId) {
1123
+ async defaultShell(agentSessionId) {
972
1124
  if (!agentSessionId) return this.createShell(void 0);
973
1125
  const existingShellId = this.defaultShellByAgentSessionId.get(agentSessionId);
974
1126
  const existing = existingShellId ? this.shells.get(existingShellId) : void 0;
975
1127
  if (existing && !existing.exited) return existing;
976
- const shell = this.createShell(agentSessionId);
1128
+ const shell = await this.createShell(agentSessionId);
977
1129
  this.defaultShellByAgentSessionId.set(agentSessionId, shell.id);
978
1130
  return shell;
979
1131
  }
980
- availableDefaultShell(agentSessionId) {
981
- const shell = this.defaultShell(agentSessionId);
1132
+ async availableDefaultShell(agentSessionId) {
1133
+ const shell = await this.defaultShell(agentSessionId);
982
1134
  if (!agentSessionId || shell.exited || !shell.pendingExec && !shell.foreground) return shell;
983
1135
  return this.createShell(agentSessionId);
984
1136
  }
985
- createShell(agentSessionId, initialCwd) {
1137
+ async createShell(agentSessionId, initialCwd) {
986
1138
  const id = this.shellIdFactory();
987
1139
  const commandStorageId = agentSessionId ?? id;
988
1140
  const cwd = initialCwd ?? this.host.defaultCwd;
@@ -996,10 +1148,12 @@ var BashEnvironment = class {
996
1148
  if (!env.has("PS1")) env.set("PS1", "");
997
1149
  if (!env.has("PS2")) env.set("PS2", "> ");
998
1150
  if (!env.has("SHLVL")) env.set("SHLVL", "1");
1151
+ const cwdHandle = await this.host.process.openCwd(cwd);
999
1152
  const exportedVars = /* @__PURE__ */ new Set(["PWD", "DEMI_SHELL_ID"]);
1000
1153
  if (agentSessionId) exportedVars.add("DEMI_SESSION_ID");
1001
1154
  for (const key of env.keys()) if (key !== key.toLowerCase()) exportedVars.add(key);
1002
1155
  for (const key of Object.keys(this.initialEnv)) exportedVars.add(key);
1156
+ if (!env.has("HOSTNAME")) env.set("HOSTNAME", this.host.identity.hostname);
1003
1157
  const state = {
1004
1158
  env,
1005
1159
  cwd,
@@ -1015,8 +1169,8 @@ var BashEnvironment = class {
1015
1169
  lastBackgroundPid: 0,
1016
1170
  virtualPid: 1,
1017
1171
  virtualPpid: 0,
1018
- virtualUid: 1e3,
1019
- virtualGid: 1e3,
1172
+ virtualUid: this.host.identity.uid,
1173
+ virtualGid: this.host.identity.gid,
1020
1174
  bashPid: 1,
1021
1175
  nextVirtualPid: 2,
1022
1176
  currentLine: 1,
@@ -1064,6 +1218,7 @@ var BashEnvironment = class {
1064
1218
  fs,
1065
1219
  interpreter: void 0,
1066
1220
  forkCommands,
1221
+ cwdHandle,
1067
1222
  accumulator: {
1068
1223
  stdout: "",
1069
1224
  stderr: "",
@@ -1077,7 +1232,7 @@ var BashEnvironment = class {
1077
1232
  };
1078
1233
  for (const command of createPortableCommands(session)) forkCommands.set(command.name, command);
1079
1234
  const storage = new AgentSessionCommandStorage(this.host.store, commandStorageId);
1080
- for (const command of this.commands.list()) forkCommands.set(command.name, commandToForkCommand(session, command, storage, this.host));
1235
+ for (const command of this.commands.list()) forkCommands.set(command.name, commandToForkCommand(session, command, storage, this.host, this.captureLimitBytes));
1081
1236
  session.abortController = new AbortController();
1082
1237
  const limits = resolveLimits({
1083
1238
  maxOutputSize: this.captureLimitBytes,
@@ -1096,6 +1251,11 @@ var BashEnvironment = class {
1096
1251
  exitCode: 0
1097
1252
  }),
1098
1253
  hostSpawn: (command, args, opts) => this.hostSpawn(session, command, args, opts),
1254
+ hostResolveCommand: (name, env) => this.hostResolveCommand(session, name, env),
1255
+ hostCwd: {
1256
+ enter: (path) => session.cwdHandle.chdir(path),
1257
+ snapshot: () => session.cwdHandle.snapshot()
1258
+ },
1099
1259
  rejectTimedPipelines: true,
1100
1260
  jobControl: {
1101
1261
  startBackground: (statement) => this.startBackgroundJob(session, statement),
@@ -1177,7 +1337,7 @@ var BashEnvironment = class {
1177
1337
  const handle = await this.host.process.spawn({
1178
1338
  command: backgroundCommand.command,
1179
1339
  args: backgroundCommand.args,
1180
- cwd: session.state.cwd,
1340
+ cwd: session.cwdHandle.spawnPath(),
1181
1341
  env: this.exportedEnv(session),
1182
1342
  killProcessGroup: true
1183
1343
  });
@@ -1365,10 +1525,11 @@ var BashEnvironment = class {
1365
1525
  }
1366
1526
  async hostSpawn(session, command, args, opts) {
1367
1527
  if (session.foreground) throw new Error(`hostSpawn: session "${session.id}" already has a foreground process`);
1528
+ const spawnCwd = session.cwdHandle.spawnPath();
1368
1529
  const handle = await this.host.process.spawn({
1369
1530
  command,
1370
1531
  args,
1371
- cwd: opts.cwd,
1532
+ cwd: spawnCwd,
1372
1533
  env: opts.env,
1373
1534
  killProcessGroup: true
1374
1535
  });
@@ -1422,8 +1583,13 @@ var BashEnvironment = class {
1422
1583
  const exit = await foreground.exitPromise;
1423
1584
  await Promise.allSettled([foreground.stdoutPump, foreground.stderrPump]);
1424
1585
  const stdout = foreground.captureOverflowed ? "" : decodeLatin1(concatBytes(foreground.rawStdoutBytes));
1425
- const exitCode = foreground.captureOverflowed ? 137 : exit.exitCode ?? 127;
1426
- const stderr = foreground.captureOverflowed ? `${command}: output exceeded the ${this.captureLimitBytes}-byte capture limit and the process was killed; the shell buffers whole command outputs in memory — narrow the output at the source (filters, head, tighter paths)\n` : exit.exitCode === null && foreground.rawStderrBuffer.length === 0 ? `${command}: ${exit.signal ?? "command not found"}\n` : foreground.rawStderrBuffer;
1586
+ let exitCode = foreground.captureOverflowed ? 137 : exit.exitCode ?? 127;
1587
+ let stderr = foreground.captureOverflowed ? `${command}: output exceeded the ${this.captureLimitBytes}-byte capture limit and the process was killed; the shell buffers whole command outputs in memory — narrow the output at the source (filters, head, tighter paths)\n` : foreground.rawStderrBuffer;
1588
+ let spawnError = exit.spawnError;
1589
+ if (!foreground.captureOverflowed && exit.spawnError) {
1590
+ exitCode = spawnErrorExitCode(exit.spawnError.kind);
1591
+ stderr = spawnErrorStderr(command, opts.cwd, opts.env, exit.spawnError.kind);
1592
+ } else if (!foreground.captureOverflowed && exit.exitCode === null && foreground.rawStderrBuffer.length === 0) stderr = `${command}: ${exit.signal ?? "command not found"}\n`;
1427
1593
  foreground.audit[0] = {
1428
1594
  kind: "system-command",
1429
1595
  name: command,
@@ -1444,9 +1610,39 @@ var BashEnvironment = class {
1444
1610
  stdout,
1445
1611
  stdoutKind: "bytes",
1446
1612
  stderr,
1447
- exitCode
1613
+ exitCode,
1614
+ ...spawnError ? { spawnError } : {}
1448
1615
  };
1449
1616
  }
1617
+ async hostResolveCommand(session, name, env) {
1618
+ if (name.includes("/")) {
1619
+ const resolved = isAbsolutePath(name) ? name : `${session.state.cwd.replace(/\/+$/, "")}/${name}`;
1620
+ try {
1621
+ if (!(await this.host.fs.stat(resolved)).isDirectory) return {
1622
+ kind: "file",
1623
+ value: name
1624
+ };
1625
+ } catch {
1626
+ return null;
1627
+ }
1628
+ return null;
1629
+ }
1630
+ const pathEnv = env.PATH ?? "";
1631
+ for (const dir of pathEnv.split(":")) {
1632
+ if (!dir) continue;
1633
+ const full = isAbsolutePath(dir) ? `${dir.replace(/\/+$/, "")}/${name}` : `${session.state.cwd.replace(/\/+$/, "")}/${dir}/${name}`;
1634
+ try {
1635
+ if ((await this.host.fs.stat(full)).isDirectory) continue;
1636
+ return {
1637
+ kind: "file",
1638
+ value: isAbsolutePath(dir) ? full : `${dir}/${name}`
1639
+ };
1640
+ } catch {
1641
+ continue;
1642
+ }
1643
+ }
1644
+ return null;
1645
+ }
1450
1646
  collectExited(session, record, resultOrError, foreground, input = {}) {
1451
1647
  if (record.status !== "running") return this.commandStatus(record, input);
1452
1648
  if (resultOrError instanceof Error) {
@@ -1629,7 +1825,7 @@ var BashEnvironment = class {
1629
1825
  function createPortableCommands(session) {
1630
1826
  return createLazyCommands([...DEMI_PORTABLE_COMMANDS]).map((command) => ({
1631
1827
  ...command,
1632
- preferHostSpawn: HOST_PREFERRED_SCAN_COMMANDS.has(command.name),
1828
+ preferHostSpawn: shouldPreferHostSpawn(command.name),
1633
1829
  execute: async (args, ctx) => {
1634
1830
  const result = await command.execute(args, ctx);
1635
1831
  session.accumulator.audit.push({
@@ -1644,6 +1840,20 @@ function createPortableCommands(session) {
1644
1840
  }));
1645
1841
  }
1646
1842
  /** True when the string contains a char > 0xFF, i.e. already-decoded Unicode text. */
1843
+ function spawnErrorExitCode(kind) {
1844
+ if (kind === "permission_denied" || kind === "is_directory") return 126;
1845
+ return 127;
1846
+ }
1847
+ function spawnErrorStderr(command, cwd, env, kind) {
1848
+ if (kind === "permission_denied") return `bash: ${command}: Permission denied\n`;
1849
+ if (kind === "is_directory") return `bash: ${command}: Is a directory\n`;
1850
+ if (kind === "cwd_unusable") return `bash: ${cwd}: No such file or directory\n`;
1851
+ if (kind === "executable_not_found") {
1852
+ if (command.includes("/") || !env.PATH) return `bash: ${command}: No such file or directory\n`;
1853
+ return `bash: ${command}: command not found\n`;
1854
+ }
1855
+ return `bash: ${command}: ${kind}\n`;
1856
+ }
1647
1857
  function hasWideChar(value) {
1648
1858
  for (let i = 0; i < value.length; i += 1) if (value.charCodeAt(i) > 255) return true;
1649
1859
  return false;
@@ -1765,6 +1975,41 @@ function tailString(value) {
1765
1975
  return tail(value, 4096);
1766
1976
  }
1767
1977
  //#endregion
1978
+ //#region src/host.ts
1979
+ /** Path-string cwd for test doubles and Hosts that cannot hold a directory fd. */
1980
+ function createLogicalHostCwd(initialPath) {
1981
+ let path = initialPath;
1982
+ return {
1983
+ get path() {
1984
+ return path;
1985
+ },
1986
+ spawnPath() {
1987
+ return path;
1988
+ },
1989
+ async chdir(next) {
1990
+ if (next === ".") return;
1991
+ path = resolveLogicalCwd(path, next);
1992
+ },
1993
+ async snapshot() {
1994
+ const saved = path;
1995
+ return { restore() {
1996
+ path = saved;
1997
+ } };
1998
+ },
1999
+ async close() {}
2000
+ };
2001
+ }
2002
+ function resolveLogicalCwd(base, next) {
2003
+ if (next.startsWith("/")) return next;
2004
+ const parts = base.split("/").filter(Boolean);
2005
+ for (const part of next.split("/")) {
2006
+ if (!part || part === ".") continue;
2007
+ if (part === "..") parts.pop();
2008
+ else parts.push(part);
2009
+ }
2010
+ return `/${parts.join("/")}`;
2011
+ }
2012
+ //#endregion
1768
2013
  //#region src/shell-quote.ts
1769
2014
  /** Single-quotes a value for safe use as one shell word. */
1770
2015
  function shellQuote(value) {
@@ -1778,4 +2023,4 @@ function heredocDelimiter(body) {
1778
2023
  return delimiter;
1779
2024
  }
1780
2025
  //#endregion
1781
- export { AgentSessionCommandStorage, BashEnvironment, COMMAND_HELP_DEFAULTS, CommandRegistry, DEMI_PORTABLE_COMMANDS, HOST_PREFERRED_SCAN_COMMANDS, HostBackedFileSystem, MAX_TIMEOUT_MS, RESERVED_COMMAND_NAMES, emptyStdin, heredocDelimiter, parseCommandInput, renderCommandHelp, runRegisteredCommand, shellQuote };
2026
+ export { AgentSessionCommandStorage, BashEnvironment, COMMAND_HELP_DEFAULTS, CommandRegistry, DEMI_PORTABLE_COMMANDS, HostBackedFileSystem, MAX_TIMEOUT_MS, RESERVED_COMMAND_NAMES, createLogicalHostCwd, emptyStdin, heredocDelimiter, parseCommandInput, renderCommandHelp, runRegisteredCommand, shellQuote, shouldPreferHostSpawn };
@@ -1,4 +1,4 @@
1
- import { t as Host, u as HostStore } from "./host-B-oKG7x5.mjs";
1
+ import { f as HostStore, t as Host } from "./host-BupAFtK6.mjs";
2
2
  import { z } from "zod";
3
3
  //#region src/command.d.ts
4
4
  type CommandInputSpec = Record<string, z.ZodType>;
@@ -47,6 +47,13 @@ interface CommandRunContext {
47
47
  storage: CommandStorage;
48
48
  /** Host of the BashEnvironment executing this command. */
49
49
  host: Host;
50
+ /** Aborted when the shell command is aborted (shell_abort, shell teardown). */
51
+ signal: AbortSignal;
52
+ /**
53
+ * Stdin written after the command started: each `shell_write` call arrives
54
+ * as one chunk. Ends when the command's shell job is released.
55
+ */
56
+ stdinStream: AsyncIterable<Uint8Array>;
50
57
  }
51
58
  interface CommandRunResult {
52
59
  exitCode: number;
@@ -77,6 +84,8 @@ interface CommandExecutionContext {
77
84
  io: CommandIO;
78
85
  storage: CommandStorage;
79
86
  host: Host;
87
+ signal?: AbortSignal;
88
+ stdinStream?: AsyncIterable<Uint8Array>;
80
89
  }
81
90
  declare class CommandRegistry {
82
91
  private readonly commands;
@@ -1,2 +1,2 @@
1
- import { t as AgentSessionCommandStorage } from "./storage-DYvEwSc9.mjs";
1
+ import { t as AgentSessionCommandStorage } from "./storage-BDRwHNOB.mjs";
2
2
  export { AgentSessionCommandStorage };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@demicodes/shell",
3
3
  "description": "Sandboxable bash engine and Host contract for Demi.",
4
- "version": "0.17.4",
4
+ "version": "0.19.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "exports": {
@@ -19,8 +19,8 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@demicodes/just-bash": "^3.1.0-demi.3",
23
- "@demicodes/utils": "^0.17.4",
22
+ "@demicodes/just-bash": "^3.1.0-demi.4",
23
+ "@demicodes/utils": "^0.19.0",
24
24
  "zod": "^4.0.0"
25
25
  },
26
26
  "license": "Apache-2.0",