@demicodes/shell 0.17.3 → 0.18.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-CAc1N6MN.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.
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.
47
58
  */
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.
56
- */
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",
@@ -877,7 +878,7 @@ var BashEnvironment = class {
877
878
  if (input.cwd !== void 0) {
878
879
  if (!(await this.host.fs.stat(input.cwd).catch(() => null))?.isDirectory) throw new Error(`Shell exec cwd is not a directory: ${input.cwd}`);
879
880
  }
880
- const session = input.shellId ? this.requireShell(input.shellId) : input.ephemeral ? this.createShell(input.agentSessionId, input.cwd) : this.availableDefaultShell(input.agentSessionId);
881
+ const session = input.shellId ? this.requireShell(input.shellId) : input.ephemeral ? await this.createShell(input.agentSessionId, input.cwd) : await this.availableDefaultShell(input.agentSessionId);
881
882
  if (session.exited) throw new Error(`Shell session "${session.id}" has exited`);
882
883
  if (session.pendingExec || session.foreground) {
883
884
  const commandId = session.activeCommandId ?? session.foreground?.commandId ?? "unknown";
@@ -944,6 +945,7 @@ var BashEnvironment = class {
944
945
  await job.exitPromise.catch(() => {});
945
946
  }
946
947
  session.backgroundJobs.clear();
948
+ await session.cwdHandle.close().catch(() => {});
947
949
  if (session.abortController) session.abortController.abort();
948
950
  }
949
951
  requireShell(shellId) {
@@ -968,21 +970,21 @@ var BashEnvironment = class {
968
970
  if (!foreground || foreground.commandId !== commandId) throw new Error(`Command "${commandId}" has no foreground process`);
969
971
  return foreground;
970
972
  }
971
- defaultShell(agentSessionId) {
973
+ async defaultShell(agentSessionId) {
972
974
  if (!agentSessionId) return this.createShell(void 0);
973
975
  const existingShellId = this.defaultShellByAgentSessionId.get(agentSessionId);
974
976
  const existing = existingShellId ? this.shells.get(existingShellId) : void 0;
975
977
  if (existing && !existing.exited) return existing;
976
- const shell = this.createShell(agentSessionId);
978
+ const shell = await this.createShell(agentSessionId);
977
979
  this.defaultShellByAgentSessionId.set(agentSessionId, shell.id);
978
980
  return shell;
979
981
  }
980
- availableDefaultShell(agentSessionId) {
981
- const shell = this.defaultShell(agentSessionId);
982
+ async availableDefaultShell(agentSessionId) {
983
+ const shell = await this.defaultShell(agentSessionId);
982
984
  if (!agentSessionId || shell.exited || !shell.pendingExec && !shell.foreground) return shell;
983
985
  return this.createShell(agentSessionId);
984
986
  }
985
- createShell(agentSessionId, initialCwd) {
987
+ async createShell(agentSessionId, initialCwd) {
986
988
  const id = this.shellIdFactory();
987
989
  const commandStorageId = agentSessionId ?? id;
988
990
  const cwd = initialCwd ?? this.host.defaultCwd;
@@ -996,10 +998,12 @@ var BashEnvironment = class {
996
998
  if (!env.has("PS1")) env.set("PS1", "");
997
999
  if (!env.has("PS2")) env.set("PS2", "> ");
998
1000
  if (!env.has("SHLVL")) env.set("SHLVL", "1");
1001
+ const cwdHandle = await this.host.process.openCwd(cwd);
999
1002
  const exportedVars = /* @__PURE__ */ new Set(["PWD", "DEMI_SHELL_ID"]);
1000
1003
  if (agentSessionId) exportedVars.add("DEMI_SESSION_ID");
1001
1004
  for (const key of env.keys()) if (key !== key.toLowerCase()) exportedVars.add(key);
1002
1005
  for (const key of Object.keys(this.initialEnv)) exportedVars.add(key);
1006
+ if (!env.has("HOSTNAME")) env.set("HOSTNAME", this.host.identity.hostname);
1003
1007
  const state = {
1004
1008
  env,
1005
1009
  cwd,
@@ -1015,8 +1019,8 @@ var BashEnvironment = class {
1015
1019
  lastBackgroundPid: 0,
1016
1020
  virtualPid: 1,
1017
1021
  virtualPpid: 0,
1018
- virtualUid: 1e3,
1019
- virtualGid: 1e3,
1022
+ virtualUid: this.host.identity.uid,
1023
+ virtualGid: this.host.identity.gid,
1020
1024
  bashPid: 1,
1021
1025
  nextVirtualPid: 2,
1022
1026
  currentLine: 1,
@@ -1064,6 +1068,7 @@ var BashEnvironment = class {
1064
1068
  fs,
1065
1069
  interpreter: void 0,
1066
1070
  forkCommands,
1071
+ cwdHandle,
1067
1072
  accumulator: {
1068
1073
  stdout: "",
1069
1074
  stderr: "",
@@ -1096,6 +1101,11 @@ var BashEnvironment = class {
1096
1101
  exitCode: 0
1097
1102
  }),
1098
1103
  hostSpawn: (command, args, opts) => this.hostSpawn(session, command, args, opts),
1104
+ hostResolveCommand: (name, env) => this.hostResolveCommand(session, name, env),
1105
+ hostCwd: {
1106
+ enter: (path) => session.cwdHandle.chdir(path),
1107
+ snapshot: () => session.cwdHandle.snapshot()
1108
+ },
1099
1109
  rejectTimedPipelines: true,
1100
1110
  jobControl: {
1101
1111
  startBackground: (statement) => this.startBackgroundJob(session, statement),
@@ -1177,7 +1187,7 @@ var BashEnvironment = class {
1177
1187
  const handle = await this.host.process.spawn({
1178
1188
  command: backgroundCommand.command,
1179
1189
  args: backgroundCommand.args,
1180
- cwd: session.state.cwd,
1190
+ cwd: session.cwdHandle.spawnPath(),
1181
1191
  env: this.exportedEnv(session),
1182
1192
  killProcessGroup: true
1183
1193
  });
@@ -1365,10 +1375,11 @@ var BashEnvironment = class {
1365
1375
  }
1366
1376
  async hostSpawn(session, command, args, opts) {
1367
1377
  if (session.foreground) throw new Error(`hostSpawn: session "${session.id}" already has a foreground process`);
1378
+ const spawnCwd = session.cwdHandle.spawnPath();
1368
1379
  const handle = await this.host.process.spawn({
1369
1380
  command,
1370
1381
  args,
1371
- cwd: opts.cwd,
1382
+ cwd: spawnCwd,
1372
1383
  env: opts.env,
1373
1384
  killProcessGroup: true
1374
1385
  });
@@ -1422,8 +1433,13 @@ var BashEnvironment = class {
1422
1433
  const exit = await foreground.exitPromise;
1423
1434
  await Promise.allSettled([foreground.stdoutPump, foreground.stderrPump]);
1424
1435
  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;
1436
+ let exitCode = foreground.captureOverflowed ? 137 : exit.exitCode ?? 127;
1437
+ 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;
1438
+ let spawnError = exit.spawnError;
1439
+ if (!foreground.captureOverflowed && exit.spawnError) {
1440
+ exitCode = spawnErrorExitCode(exit.spawnError.kind);
1441
+ stderr = spawnErrorStderr(command, opts.cwd, opts.env, exit.spawnError.kind);
1442
+ } else if (!foreground.captureOverflowed && exit.exitCode === null && foreground.rawStderrBuffer.length === 0) stderr = `${command}: ${exit.signal ?? "command not found"}\n`;
1427
1443
  foreground.audit[0] = {
1428
1444
  kind: "system-command",
1429
1445
  name: command,
@@ -1444,9 +1460,39 @@ var BashEnvironment = class {
1444
1460
  stdout,
1445
1461
  stdoutKind: "bytes",
1446
1462
  stderr,
1447
- exitCode
1463
+ exitCode,
1464
+ ...spawnError ? { spawnError } : {}
1448
1465
  };
1449
1466
  }
1467
+ async hostResolveCommand(session, name, env) {
1468
+ if (name.includes("/")) {
1469
+ const resolved = isAbsolutePath(name) ? name : `${session.state.cwd.replace(/\/+$/, "")}/${name}`;
1470
+ try {
1471
+ if (!(await this.host.fs.stat(resolved)).isDirectory) return {
1472
+ kind: "file",
1473
+ value: name
1474
+ };
1475
+ } catch {
1476
+ return null;
1477
+ }
1478
+ return null;
1479
+ }
1480
+ const pathEnv = env.PATH ?? "";
1481
+ for (const dir of pathEnv.split(":")) {
1482
+ if (!dir) continue;
1483
+ const full = isAbsolutePath(dir) ? `${dir.replace(/\/+$/, "")}/${name}` : `${session.state.cwd.replace(/\/+$/, "")}/${dir}/${name}`;
1484
+ try {
1485
+ if ((await this.host.fs.stat(full)).isDirectory) continue;
1486
+ return {
1487
+ kind: "file",
1488
+ value: isAbsolutePath(dir) ? full : `${dir}/${name}`
1489
+ };
1490
+ } catch {
1491
+ continue;
1492
+ }
1493
+ }
1494
+ return null;
1495
+ }
1450
1496
  collectExited(session, record, resultOrError, foreground, input = {}) {
1451
1497
  if (record.status !== "running") return this.commandStatus(record, input);
1452
1498
  if (resultOrError instanceof Error) {
@@ -1629,7 +1675,7 @@ var BashEnvironment = class {
1629
1675
  function createPortableCommands(session) {
1630
1676
  return createLazyCommands([...DEMI_PORTABLE_COMMANDS]).map((command) => ({
1631
1677
  ...command,
1632
- preferHostSpawn: HOST_PREFERRED_SCAN_COMMANDS.has(command.name),
1678
+ preferHostSpawn: shouldPreferHostSpawn(command.name),
1633
1679
  execute: async (args, ctx) => {
1634
1680
  const result = await command.execute(args, ctx);
1635
1681
  session.accumulator.audit.push({
@@ -1644,6 +1690,20 @@ function createPortableCommands(session) {
1644
1690
  }));
1645
1691
  }
1646
1692
  /** True when the string contains a char > 0xFF, i.e. already-decoded Unicode text. */
1693
+ function spawnErrorExitCode(kind) {
1694
+ if (kind === "permission_denied" || kind === "is_directory") return 126;
1695
+ return 127;
1696
+ }
1697
+ function spawnErrorStderr(command, cwd, env, kind) {
1698
+ if (kind === "permission_denied") return `bash: ${command}: Permission denied\n`;
1699
+ if (kind === "is_directory") return `bash: ${command}: Is a directory\n`;
1700
+ if (kind === "cwd_unusable") return `bash: ${cwd}: No such file or directory\n`;
1701
+ if (kind === "executable_not_found") {
1702
+ if (command.includes("/") || !env.PATH) return `bash: ${command}: No such file or directory\n`;
1703
+ return `bash: ${command}: command not found\n`;
1704
+ }
1705
+ return `bash: ${command}: ${kind}\n`;
1706
+ }
1647
1707
  function hasWideChar(value) {
1648
1708
  for (let i = 0; i < value.length; i += 1) if (value.charCodeAt(i) > 255) return true;
1649
1709
  return false;
@@ -1765,6 +1825,41 @@ function tailString(value) {
1765
1825
  return tail(value, 4096);
1766
1826
  }
1767
1827
  //#endregion
1828
+ //#region src/host.ts
1829
+ /** Path-string cwd for test doubles and Hosts that cannot hold a directory fd. */
1830
+ function createLogicalHostCwd(initialPath) {
1831
+ let path = initialPath;
1832
+ return {
1833
+ get path() {
1834
+ return path;
1835
+ },
1836
+ spawnPath() {
1837
+ return path;
1838
+ },
1839
+ async chdir(next) {
1840
+ if (next === ".") return;
1841
+ path = resolveLogicalCwd(path, next);
1842
+ },
1843
+ async snapshot() {
1844
+ const saved = path;
1845
+ return { restore() {
1846
+ path = saved;
1847
+ } };
1848
+ },
1849
+ async close() {}
1850
+ };
1851
+ }
1852
+ function resolveLogicalCwd(base, next) {
1853
+ if (next.startsWith("/")) return next;
1854
+ const parts = base.split("/").filter(Boolean);
1855
+ for (const part of next.split("/")) {
1856
+ if (!part || part === ".") continue;
1857
+ if (part === "..") parts.pop();
1858
+ else parts.push(part);
1859
+ }
1860
+ return `/${parts.join("/")}`;
1861
+ }
1862
+ //#endregion
1768
1863
  //#region src/shell-quote.ts
1769
1864
  /** Single-quotes a value for safe use as one shell word. */
1770
1865
  function shellQuote(value) {
@@ -1778,4 +1873,4 @@ function heredocDelimiter(body) {
1778
1873
  return delimiter;
1779
1874
  }
1780
1875
  //#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 };
1876
+ 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>;
@@ -1,2 +1,2 @@
1
- import { t as AgentSessionCommandStorage } from "./storage-DYvEwSc9.mjs";
1
+ import { t as AgentSessionCommandStorage } from "./storage-CAc1N6MN.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.3",
4
+ "version": "0.18.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.3",
22
+ "@demicodes/just-bash": "^3.1.0-demi.4",
23
+ "@demicodes/utils": "^0.18.0",
24
24
  "zod": "^4.0.0"
25
25
  },
26
26
  "license": "Apache-2.0",