@demicodes/shell 0.14.2 → 0.15.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.
@@ -1,6 +1,15 @@
1
1
  //#region src/host.d.ts
2
2
  interface Host {
3
3
  defaultCwd: string;
4
+ /**
5
+ * Directory where command artifacts (stdout.txt / stderr.txt / stdout.bin /
6
+ * meta.json) are written as plain files, laid out as
7
+ * `<dir>/<storageId>/<commandId>/`. Contract: the path is reachable through
8
+ * `fs` AND visible to processes started via `process.spawn` — one shared
9
+ * filesystem namespace, so any tool (portable or real) can read and search
10
+ * artifacts with ordinary file operations.
11
+ */
12
+ commandArtifactsDir: string;
4
13
  fs: HostFileSystem;
5
14
  process: HostProcess;
6
15
  store: HostStore;
@@ -1,22 +1,7 @@
1
- import { t as Host } from "./host-DIg4RxcL.mjs";
1
+ import { t as Host } from "./host-B-oKG7x5.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
- type VirtualFileSystemNode = {
5
- kind: 'file';
6
- content: Uint8Array;
7
- mode?: number;
8
- mtime?: Date;
9
- } | {
10
- kind: 'directory';
11
- entries: DirentEntry[];
12
- mode?: number;
13
- mtime?: Date;
14
- };
15
- interface VirtualFileSystemProvider {
16
- lookup(path: string): Promise<VirtualFileSystemNode | null> | VirtualFileSystemNode | null;
17
- }
18
4
  interface HostBackedFileSystemOptions {
19
- lookup?: VirtualFileSystemProvider['lookup'];
20
5
  /**
21
6
  * Ceiling for a single file read through this filesystem. Portable commands
22
7
  * (cat/grep/rg/head/...) run in-process and load files whole, so one read of
@@ -27,13 +12,8 @@ interface HostBackedFileSystemOptions {
27
12
  */
28
13
  maxFileReadBytes?: number;
29
14
  }
30
- /** Builds a virtual directory node; entry names containing a dot are treated as files. */
31
- declare function virtualDirectory(names: string[]): VirtualFileSystemNode;
32
- /** Builds a virtual file node from raw bytes. */
33
- declare function virtualFile(content: Uint8Array): VirtualFileSystemNode;
34
15
  declare class HostBackedFileSystem implements IFileSystem {
35
16
  private readonly host;
36
- private readonly options;
37
17
  private readonly maxFileReadBytes;
38
18
  constructor(host: Host, options?: HostBackedFileSystemOptions);
39
19
  readFile(path: string, options?: ReadFileOptions | BufferEncoding): Promise<string>;
@@ -57,8 +37,6 @@ declare class HostBackedFileSystem implements IFileSystem {
57
37
  readlink(path: string): Promise<string>;
58
38
  realpath(path: string): Promise<string>;
59
39
  utimes(path: string, atime: Date, mtime: Date): Promise<void>;
60
- private lookupVirtual;
61
- private assertWritablePath;
62
40
  }
63
41
  //#endregion
64
- export { HostBackedFileSystem, HostBackedFileSystemOptions, VirtualFileSystemNode, VirtualFileSystemProvider, virtualDirectory, virtualFile };
42
+ export { HostBackedFileSystem, HostBackedFileSystemOptions };
package/dist/host-fs.mjs CHANGED
@@ -1,83 +1,40 @@
1
1
  import { decodeUtf8, encodeUtf8, isAbsolutePath, normalizePath } from "@demicodes/utils";
2
2
  //#region src/host-fs.ts
3
3
  const DEFAULT_FILE_READ_LIMIT_BYTES = 67108864;
4
- /** Builds a virtual directory node; entry names containing a dot are treated as files. */
5
- function virtualDirectory(names) {
6
- return {
7
- kind: "directory",
8
- entries: names.sort().map((name) => ({
9
- name,
10
- isFile: name.includes("."),
11
- isDirectory: !name.includes("."),
12
- isSymbolicLink: false
13
- }))
14
- };
15
- }
16
- /** Builds a virtual file node from raw bytes. */
17
- function virtualFile(content) {
18
- return {
19
- kind: "file",
20
- content
21
- };
22
- }
23
4
  var HostBackedFileSystem = class {
24
5
  host;
25
- options;
26
6
  maxFileReadBytes;
27
7
  constructor(host, options = {}) {
28
8
  this.host = host;
29
- this.options = options;
30
9
  this.maxFileReadBytes = options.maxFileReadBytes ?? DEFAULT_FILE_READ_LIMIT_BYTES;
31
10
  }
32
11
  async readFile(path, options) {
33
12
  return decodeBytes(await this.readFileBuffer(path), encodingFrom(options));
34
13
  }
35
14
  async readFileBuffer(path) {
36
- const virtual = await this.lookupVirtual(path);
37
- if (virtual) {
38
- if (virtual.kind !== "file") throw new Error(`EISDIR: illegal operation on a directory, read '${path}'`);
39
- return virtual.content;
40
- }
41
15
  const stat = await this.host.fs.stat(path, { cwd: this.host.defaultCwd });
42
16
  if (stat.isFile && stat.size > this.maxFileReadBytes) throw new Error(`EFBIG: file exceeds the ${this.maxFileReadBytes}-byte in-shell read limit (${stat.size} bytes), read '${path}'; read a slice instead, or process it with a real process via 'bash -c'`);
43
17
  return this.host.fs.readFile(path, { cwd: this.host.defaultCwd });
44
18
  }
45
19
  async writeFile(path, content, options) {
46
- this.assertWritablePath(path);
47
20
  await this.host.fs.writeFile(path, encodeContent(content, encodingFrom(options)), { cwd: this.host.defaultCwd });
48
21
  }
49
22
  async appendFile(path, content, options) {
50
- this.assertWritablePath(path);
51
23
  await this.host.fs.appendFile(path, encodeContent(content, encodingFrom(options)), { cwd: this.host.defaultCwd });
52
24
  }
53
25
  async exists(path) {
54
- if (isVirtualPath(path)) return await this.lookupVirtual(path) !== null;
55
26
  return this.host.fs.exists(path, { cwd: this.host.defaultCwd });
56
27
  }
57
28
  async stat(path) {
58
- const virtual = await this.lookupVirtual(path);
59
- if (virtual) return virtualStat(virtual);
60
29
  return toFsStat(await this.host.fs.stat(path, { cwd: this.host.defaultCwd }));
61
30
  }
62
31
  async lstat(path) {
63
- const virtual = await this.lookupVirtual(path);
64
- if (virtual) return virtualStat(virtual);
65
32
  return toFsStat(await this.host.fs.lstat(path, { cwd: this.host.defaultCwd }));
66
33
  }
67
34
  async readdir(path) {
68
- const virtual = await this.lookupVirtual(path);
69
- if (virtual) {
70
- if (virtual.kind !== "directory") throw new Error(`ENOTDIR: not a directory, scandir '${path}'`);
71
- return virtual.entries.map((entry) => entry.name);
72
- }
73
35
  return this.host.fs.readdir(path, { cwd: this.host.defaultCwd });
74
36
  }
75
37
  async readdirWithFileTypes(path) {
76
- const virtual = await this.lookupVirtual(path);
77
- if (virtual) {
78
- if (virtual.kind !== "directory") throw new Error(`ENOTDIR: not a directory, scandir '${path}'`);
79
- return virtual.entries;
80
- }
81
38
  return (await this.host.fs.readdir(path, {
82
39
  cwd: this.host.defaultCwd,
83
40
  withFileTypes: true
@@ -91,14 +48,12 @@ var HostBackedFileSystem = class {
91
48
  return [];
92
49
  }
93
50
  async mkdir(path, options) {
94
- this.assertWritablePath(path);
95
51
  await this.host.fs.mkdir(path, {
96
52
  cwd: this.host.defaultCwd,
97
53
  recursive: options?.recursive
98
54
  });
99
55
  }
100
56
  async rm(path, options) {
101
- this.assertWritablePath(path);
102
57
  await this.host.fs.rm(path, {
103
58
  cwd: this.host.defaultCwd,
104
59
  recursive: options?.recursive,
@@ -106,62 +61,32 @@ var HostBackedFileSystem = class {
106
61
  });
107
62
  }
108
63
  async cp(src, dest, options) {
109
- this.assertWritablePath(dest);
110
- if (isVirtualPath(src)) {
111
- const virtual = await this.lookupVirtual(src);
112
- if (!virtual) throw new Error(`ENOENT: no such file or directory, copyfile '${src}'`);
113
- if (virtual.kind !== "file") throw new Error(`EISDIR: illegal operation on a directory, copyfile '${src}'`);
114
- await this.host.fs.writeFile(dest, virtual.content, {
115
- cwd: this.host.defaultCwd,
116
- createParents: options?.recursive
117
- });
118
- return;
119
- }
120
64
  await this.host.fs.cp(src, dest, {
121
65
  cwd: this.host.defaultCwd,
122
66
  recursive: options?.recursive
123
67
  });
124
68
  }
125
69
  async mv(src, dest) {
126
- this.assertWritablePath(src);
127
- this.assertWritablePath(dest);
128
70
  await this.host.fs.mv(src, dest, { cwd: this.host.defaultCwd });
129
71
  }
130
72
  async chmod(path, mode) {
131
- this.assertWritablePath(path);
132
73
  await this.host.fs.chmod(path, mode, { cwd: this.host.defaultCwd });
133
74
  }
134
75
  async symlink(target, linkPath) {
135
- this.assertWritablePath(linkPath);
136
76
  await this.host.fs.symlink(target, linkPath, { cwd: this.host.defaultCwd });
137
77
  }
138
78
  async link(existingPath, newPath) {
139
- this.assertWritablePath(existingPath);
140
- this.assertWritablePath(newPath);
141
79
  await this.host.fs.link(existingPath, newPath, { cwd: this.host.defaultCwd });
142
80
  }
143
81
  async readlink(path) {
144
- if (isVirtualPath(path)) throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
145
82
  return this.host.fs.readlink(path, { cwd: this.host.defaultCwd });
146
83
  }
147
84
  async realpath(path) {
148
- if (isVirtualPath(path)) {
149
- if (!await this.lookupVirtual(path)) throw new Error(`ENOENT: no such file or directory, realpath '${path}'`);
150
- return normalizePath(path);
151
- }
152
85
  return this.host.fs.realpath(path, { cwd: this.host.defaultCwd });
153
86
  }
154
87
  async utimes(path, atime, mtime) {
155
- this.assertWritablePath(path);
156
88
  await this.host.fs.utimes(path, atime, mtime, { cwd: this.host.defaultCwd });
157
89
  }
158
- async lookupVirtual(path) {
159
- if (!this.options.lookup || !isVirtualPath(path)) return null;
160
- return await this.options.lookup(normalizePath(path)) ?? null;
161
- }
162
- assertWritablePath(path) {
163
- if (isVirtualPath(path)) throw new Error(`EROFS: read-only virtual filesystem, '${path}'`);
164
- }
165
90
  };
166
91
  function encodingFrom(options) {
167
92
  if (!options) return void 0;
@@ -171,13 +96,13 @@ function encodingFrom(options) {
171
96
  function encodeContent(content, encoding) {
172
97
  if (content instanceof Uint8Array) return content;
173
98
  if (encoding === "binary" || encoding === "latin1") return latin1ToBytes(content);
174
- if (encoding === "base64") return base64ToBytes$1(content);
99
+ if (encoding === "base64") return base64ToBytes(content);
175
100
  if (encoding === "hex") return hexToBytes(content);
176
101
  return encodeUtf8(content);
177
102
  }
178
103
  function decodeBytes(bytes, encoding) {
179
104
  if (encoding === "binary" || encoding === "latin1") return bytesToLatin1(bytes);
180
- if (encoding === "base64") return bytesToBase64$1(bytes);
105
+ if (encoding === "base64") return bytesToBase64(bytes);
181
106
  if (encoding === "hex") return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
182
107
  return decodeUtf8(bytes);
183
108
  }
@@ -191,10 +116,10 @@ function bytesToLatin1(bytes) {
191
116
  for (let index = 0; index < bytes.length; index += 8192) result += String.fromCharCode(...bytes.subarray(index, index + 8192));
192
117
  return result;
193
118
  }
194
- function base64ToBytes$1(content) {
119
+ function base64ToBytes(content) {
195
120
  return latin1ToBytes(atob(content));
196
121
  }
197
- function bytesToBase64$1(bytes) {
122
+ function bytesToBase64(bytes) {
198
123
  return btoa(bytesToLatin1(bytes));
199
124
  }
200
125
  function hexToBytes(content) {
@@ -220,28 +145,5 @@ function toDirentEntry(value) {
220
145
  isSymbolicLink: value.isSymbolicLink
221
146
  };
222
147
  }
223
- function virtualStat(node) {
224
- const now = /* @__PURE__ */ new Date(0);
225
- if (node.kind === "directory") return {
226
- isFile: false,
227
- isDirectory: true,
228
- isSymbolicLink: false,
229
- mode: node.mode ?? 365,
230
- size: 0,
231
- mtime: node.mtime ?? now
232
- };
233
- return {
234
- isFile: true,
235
- isDirectory: false,
236
- isSymbolicLink: false,
237
- mode: node.mode ?? 292,
238
- size: node.content.byteLength,
239
- mtime: node.mtime ?? now
240
- };
241
- }
242
- function isVirtualPath(path) {
243
- const normalized = normalizePath(path);
244
- return normalized === "/@" || normalized.startsWith("/@/");
245
- }
246
148
  //#endregion
247
- export { HostBackedFileSystem, virtualDirectory, virtualFile };
149
+ export { HostBackedFileSystem };
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-DIg4RxcL.mjs";
2
- import { HostBackedFileSystem, HostBackedFileSystemOptions, VirtualFileSystemNode, VirtualFileSystemProvider, virtualDirectory, virtualFile } 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-CNMBL9_j.mjs";
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";
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";
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";
@@ -227,6 +227,8 @@ type ShellCommandStatus = {
227
227
  status: 'exited';
228
228
  shellId: string;
229
229
  commandId: string;
230
+ /** Real directory holding this command's artifact files. */
231
+ artifactDir: string;
230
232
  exitCode: number;
231
233
  stdout: ShellStreamView;
232
234
  stderr: ShellStreamView;
@@ -241,6 +243,7 @@ type ShellCommandStatus = {
241
243
  status: 'running';
242
244
  shellId: string;
243
245
  commandId: string;
246
+ artifactDir: string;
244
247
  stdout: ShellStreamView;
245
248
  stderr: ShellStreamView;
246
249
  output: ShellOutputView;
@@ -250,6 +253,7 @@ type ShellCommandStatus = {
250
253
  status: 'aborted';
251
254
  shellId: string;
252
255
  commandId: string;
256
+ artifactDir: string;
253
257
  stdout: ShellStreamView;
254
258
  stderr: ShellStreamView;
255
259
  output: ShellOutputView;
@@ -304,11 +308,7 @@ declare class BashEnvironment {
304
308
  private collectAborted;
305
309
  private collectAbortedWithoutForeground;
306
310
  private commandStatus;
307
- private lookupVirtualArtifact;
308
- private commandArtifactIds;
309
- private commandArtifact;
310
311
  private persistCommandArtifact;
311
- private syncRunningRecord;
312
312
  }
313
313
  //#endregion
314
314
  //#region src/portable-commands.d.ts
@@ -329,6 +329,15 @@ declare class BashEnvironment {
329
329
  * we call portable.
330
330
  */
331
331
  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>;
332
341
  /**
333
342
  * Names registered commands must not shadow, derived from the actual portable
334
343
  * command set plus interpreter builtins and pass-through system tools — not a
@@ -342,4 +351,4 @@ declare function shellQuote(value: string): string;
342
351
  /** Picks a heredoc delimiter that does not collide with any line already in `body`. */
343
352
  declare function heredocDelimiter(body: string): string;
344
353
  //#endregion
345
- 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, 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, VirtualFileSystemNode, VirtualFileSystemProvider, emptyStdin, heredocDelimiter, parseCommandInput, renderCommandHelp, runRegisteredCommand, shellQuote, virtualDirectory, virtualFile };
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 };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { HostBackedFileSystem, virtualDirectory, virtualFile } from "./host-fs.mjs";
1
+ import { HostBackedFileSystem } from "./host-fs.mjs";
2
2
  import { AgentSessionCommandStorage } from "./storage.mjs";
3
- import { asError, base64ToBytes, bytesToBase64, concatBytes, decodeLatin1, decodeUtf8, decodeUtf8Strict, encodeLatin1, encodeUtf8, tail, utf8Bytes, utf8Slice } from "@demicodes/utils";
3
+ import { asError, concatBytes, decodeLatin1, decodeUtf8, decodeUtf8Strict, encodeLatin1, encodeUtf8, 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";
@@ -46,6 +46,19 @@ const REAL_SPAWN_DEPENDENT_COMMANDS = /* @__PURE__ */ new Set([
46
46
  * we call portable.
47
47
  */
48
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
+ ]);
49
62
  /** Shell language words and builtins the interpreter itself owns. */
50
63
  const SHELL_BUILTIN_NAMES = [
51
64
  ".",
@@ -618,39 +631,63 @@ function appendVisibleChunk(foreground, targetFd, text, byteLength) {
618
631
  //#endregion
619
632
  //#region src/command-artifact-store.ts
620
633
  /**
621
- * Owns persistence of shell command artifacts: a per-storage-id cache plus the
622
- * set of released (tombstoned) commands. `BashEnvironment` delegates the storage
623
- * and release-tracking side of the `/@` virtual filesystem here, keeping only the
624
- * in-memory record lookups that need live command state.
634
+ * Writes shell command artifacts as plain files under
635
+ * `host.commandArtifactsDir/<storageId>/<commandId>/` one shared filesystem
636
+ * namespace with the processes the shell spawns, so any tool (portable or
637
+ * real) reads and searches artifacts with ordinary file operations.
625
638
  */
626
639
  var CommandArtifactStore = class {
627
- store;
628
- storageById = /* @__PURE__ */ new Map();
640
+ host;
629
641
  released = /* @__PURE__ */ new Set();
630
- constructor(store) {
631
- this.store = store;
632
- }
633
- /** The artifact storage for one agent session or anonymous shell. */
634
- storageFor(commandStorageId) {
635
- const existing = this.storageById.get(commandStorageId);
636
- if (existing) return existing;
637
- const storage = new AgentSessionCommandStorage(this.store, commandStorageId);
638
- this.storageById.set(commandStorageId, storage);
639
- return storage;
640
- }
641
- /** Whether a command's artifact has been released (tombstoned). */
642
+ writeChains = /* @__PURE__ */ new Map();
643
+ constructor(host) {
644
+ this.host = host;
645
+ }
646
+ dirFor(commandStorageId, commandId) {
647
+ return `${this.host.commandArtifactsDir}/${commandStorageId}/${commandId}`;
648
+ }
649
+ /** Whether a command's artifact has been released (removed from disk). */
642
650
  isReleased(commandStorageId, commandId) {
643
651
  return this.released.has(this.key(commandStorageId, commandId));
644
652
  }
645
- /** Persists an artifact unless its command has already been released. */
646
- persist(commandStorageId, commandId, artifact) {
647
- if (this.isReleased(commandStorageId, commandId)) return;
648
- this.storageFor(commandStorageId).writeJson(`commands/${commandId}/artifact.json`, artifact).catch(() => {});
653
+ /** Persists the artifact files unless the command has already been released. */
654
+ persist(commandStorageId, commandId, files) {
655
+ const key = this.key(commandStorageId, commandId);
656
+ if (this.released.has(key)) return;
657
+ const dir = this.dirFor(commandStorageId, commandId);
658
+ this.chain(key, async () => {
659
+ if (this.released.has(key)) return;
660
+ const writeOptions = {
661
+ cwd: this.host.defaultCwd,
662
+ createParents: true
663
+ };
664
+ await this.host.fs.writeFile(`${dir}/meta.json`, encodeUtf8(files.meta), writeOptions);
665
+ await this.host.fs.writeFile(`${dir}/stdout.txt`, encodeUtf8(files.stdout), writeOptions);
666
+ await this.host.fs.writeFile(`${dir}/stderr.txt`, encodeUtf8(files.stderr), writeOptions);
667
+ if (files.stdoutBin) await this.host.fs.writeFile(`${dir}/stdout.bin`, files.stdoutBin, writeOptions);
668
+ });
649
669
  }
650
- /** Tombstones a command and removes its persisted artifact. */
670
+ /** Tombstones a command and removes its artifact directory. */
651
671
  async release(commandStorageId, commandId) {
652
- this.released.add(this.key(commandStorageId, commandId));
653
- await this.storageFor(commandStorageId).delete(`commands/${commandId}/artifact.json`).catch(() => {});
672
+ const key = this.key(commandStorageId, commandId);
673
+ this.released.add(key);
674
+ await new Promise((resolve) => {
675
+ this.chain(key, async () => {
676
+ await this.host.fs.rm(this.dirFor(commandStorageId, commandId), {
677
+ cwd: this.host.defaultCwd,
678
+ recursive: true,
679
+ force: true
680
+ }).catch(() => {});
681
+ resolve();
682
+ });
683
+ });
684
+ }
685
+ chain(key, work) {
686
+ const next = (this.writeChains.get(key) ?? Promise.resolve()).then(work).catch(() => {});
687
+ this.writeChains.set(key, next);
688
+ next.finally(() => {
689
+ if (this.writeChains.get(key) === next) this.writeChains.delete(key);
690
+ });
654
691
  }
655
692
  key(commandStorageId, commandId) {
656
693
  return `${commandStorageId}\0${commandId}`;
@@ -811,7 +848,7 @@ var BashEnvironment = class {
811
848
  artifacts;
812
849
  constructor(options) {
813
850
  this.host = options.host;
814
- this.artifacts = new CommandArtifactStore(this.host.store);
851
+ this.artifacts = new CommandArtifactStore(this.host);
815
852
  this.commands = options.commands ?? new CommandRegistry();
816
853
  this.shellIdFactory = options.shellIdFactory ?? (() => globalThis.crypto.randomUUID());
817
854
  this.commandIdFactory = options.commandIdFactory ?? (() => globalThis.crypto.randomUUID());
@@ -949,7 +986,7 @@ var BashEnvironment = class {
949
986
  const id = this.shellIdFactory();
950
987
  const commandStorageId = agentSessionId ?? id;
951
988
  const cwd = initialCwd ?? this.host.defaultCwd;
952
- const fs = new HostBackedFileSystem(this.host, { lookup: (path) => this.lookupVirtualArtifact(commandStorageId, path) });
989
+ const fs = new HostBackedFileSystem(this.host);
953
990
  const env = /* @__PURE__ */ new Map();
954
991
  for (const [key, value] of Object.entries(this.initialEnv)) env.set(key, value);
955
992
  env.set("PWD", cwd);
@@ -1115,6 +1152,7 @@ var BashEnvironment = class {
1115
1152
  id,
1116
1153
  shellId: session.id,
1117
1154
  commandStorageId: session.commandStorageId,
1155
+ artifactDir: this.artifacts.dirFor(session.commandStorageId, id),
1118
1156
  script,
1119
1157
  startedAt: now,
1120
1158
  lastOutputAt: now,
@@ -1463,7 +1501,8 @@ var BashEnvironment = class {
1463
1501
  totalBytes: bytes.length,
1464
1502
  limitBytes: cap
1465
1503
  };
1466
- stdoutText = `<binary stdout: ${bytes.length} bytes${truncated ? `, exceeds the ${cap}-byte binary limit` : ""}; raw bytes at /@/commands/${record.id}/stdout.bin>\n`;
1504
+ stdoutText = `<binary stdout: ${bytes.length} bytes${truncated ? `, exceeds the ${cap}-byte binary limit` : ""}; raw bytes at ${record.artifactDir}/stdout.bin>\n`;
1505
+ record.pendingBinaryArtifact = bytes;
1467
1506
  }
1468
1507
  }
1469
1508
  const stderrText = foreground ? resultOrError.stderr : decodeBytesToUtf8(unsafeBytesFromLatin1(resultOrError.stderr));
@@ -1543,6 +1582,7 @@ var BashEnvironment = class {
1543
1582
  const base = {
1544
1583
  shellId: record.shellId,
1545
1584
  commandId: record.id,
1585
+ artifactDir: record.artifactDir,
1546
1586
  stdout,
1547
1587
  stderr,
1548
1588
  output,
@@ -1570,70 +1610,24 @@ var BashEnvironment = class {
1570
1610
  status: "running"
1571
1611
  };
1572
1612
  }
1573
- async lookupVirtualArtifact(commandStorageId, path) {
1574
- const parts = path.split("/").filter(Boolean);
1575
- if (parts.length === 1 && parts[0] === "@") return virtualDirectory(["commands"]);
1576
- if (parts.length === 2 && parts[0] === "@" && parts[1] === "commands") return virtualDirectory(await this.commandArtifactIds(commandStorageId));
1577
- if (parts.length === 3 && parts[0] === "@" && parts[1] === "commands") {
1578
- const artifact = await this.commandArtifact(commandStorageId, parts[2]);
1579
- if (!artifact) return null;
1580
- const entries = [
1581
- "meta.json",
1582
- "stderr.txt",
1583
- "stdout.txt"
1584
- ];
1585
- if (artifact.stdoutBinary) entries.push("stdout.bin");
1586
- return virtualDirectory(entries);
1587
- }
1588
- if (parts.length !== 4 || parts[0] !== "@" || parts[1] !== "commands") return null;
1589
- const artifact = await this.commandArtifact(commandStorageId, parts[2]);
1590
- if (!artifact) return null;
1591
- const fileName = parts[3];
1592
- if (fileName === "stdout.txt") return virtualFile(encodeUtf8(artifact.stdout));
1593
- if (fileName === "stdout.bin" && artifact.stdoutBinary) return virtualFile(base64ToBytes(artifact.stdoutBinary.base64));
1594
- if (fileName === "stderr.txt") return virtualFile(encodeUtf8(artifact.stderr));
1595
- if (fileName === "meta.json") return virtualFile(encodeUtf8(`${JSON.stringify(commandArtifactMeta(artifact), null, 2)}\n`));
1596
- return null;
1597
- }
1598
- async commandArtifactIds(commandStorageId) {
1599
- const ids = /* @__PURE__ */ new Set();
1600
- for (const record of this.commandsById.values()) if (record.commandStorageId === commandStorageId && !this.artifacts.isReleased(commandStorageId, record.id)) ids.add(record.id);
1601
- const keys = await this.artifacts.storageFor(commandStorageId).list("commands").catch(() => []);
1602
- for (const key of keys) {
1603
- const match = /^commands\/([^/]+)\/artifact\.json$/.exec(key);
1604
- if (match && !this.artifacts.isReleased(commandStorageId, match[1])) ids.add(match[1]);
1605
- }
1606
- return [...ids];
1607
- }
1608
- async commandArtifact(commandStorageId, commandId) {
1609
- if (this.artifacts.isReleased(commandStorageId, commandId)) return null;
1610
- const record = this.commandsById.get(commandId);
1611
- if (record?.commandStorageId === commandStorageId) {
1612
- this.syncRunningRecord(record);
1613
- return commandArtifactFromRecord(record);
1614
- }
1615
- const value = await this.artifacts.storageFor(commandStorageId).readJson(`commands/${commandId}/artifact.json`).catch(() => null);
1616
- return isCommandArtifact(value) ? value : null;
1617
- }
1618
1613
  persistCommandArtifact(record) {
1619
1614
  const fingerprint = `${record.status}:${record.exitCode ?? ""}:${record.stdout.length}:${record.stderr.length}:${record.binaryStdout?.totalBytes ?? ""}`;
1620
1615
  if (record.persistedFingerprint === fingerprint) return;
1621
1616
  record.persistedFingerprint = fingerprint;
1622
- this.artifacts.persist(record.commandStorageId, record.id, commandArtifactFromRecord(record));
1623
- }
1624
- syncRunningRecord(record) {
1625
- const foreground = this.shells.get(record.shellId)?.foreground;
1626
- if (record.status === "running" && foreground?.commandId === record.id) {
1627
- record.stdout = foreground.stdoutBuffer;
1628
- record.stderr = foreground.stderrBuffer;
1629
- record.outputChunks = [...foreground.outputChunks];
1630
- record.lastOutputAt = foreground.lastOutputAt;
1631
- }
1617
+ const stdoutBin = record.pendingBinaryArtifact;
1618
+ record.pendingBinaryArtifact = void 0;
1619
+ this.artifacts.persist(record.commandStorageId, record.id, {
1620
+ meta: `${JSON.stringify(commandArtifactMeta(record), null, 2)}\n`,
1621
+ stdout: record.stdout,
1622
+ stderr: record.stderr,
1623
+ ...stdoutBin ? { stdoutBin } : {}
1624
+ });
1632
1625
  }
1633
1626
  };
1634
1627
  function createPortableCommands(session) {
1635
1628
  return createLazyCommands([...DEMI_PORTABLE_COMMANDS]).map((command) => ({
1636
1629
  ...command,
1630
+ preferHostSpawn: HOST_PREFERRED_SCAN_COMMANDS.has(command.name),
1637
1631
  execute: async (args, ctx) => {
1638
1632
  const result = await command.execute(args, ctx);
1639
1633
  session.accumulator.audit.push({
@@ -1668,7 +1662,7 @@ function streamView(record, stream, explicitOffset, maxOutputBytes) {
1668
1662
  if (explicitOffset === void 0) if (stream === "stdout") record.stdoutOffset = nextOffset;
1669
1663
  else record.stderrOffset = nextOffset;
1670
1664
  return {
1671
- path: `/@/commands/${record.id}/${stream}.txt`,
1665
+ path: `${record.artifactDir}/${stream}.txt`,
1672
1666
  offset: nextOffset,
1673
1667
  delta,
1674
1668
  tail: tailString(text),
@@ -1703,7 +1697,7 @@ function mergedOutputView(record, explicitOffset, maxOutputBytes) {
1703
1697
  const truncated = nextOffset < totalBytes;
1704
1698
  if (explicitOffset === void 0) record.outputOffset = nextOffset;
1705
1699
  return {
1706
- path: `demi://shell/${record.shellId}/commands/${record.id}/output`,
1700
+ path: record.artifactDir,
1707
1701
  offset: nextOffset,
1708
1702
  text,
1709
1703
  tail: tailOutputText(record.outputChunks),
@@ -1730,55 +1724,29 @@ function ensureRecordOutputCoverage(record) {
1730
1724
  appendRecordOutput(record, "stdout", record.stdout);
1731
1725
  appendRecordOutput(record, "stderr", record.stderr);
1732
1726
  }
1733
- function commandArtifactFromRecord(record) {
1727
+ function commandArtifactMeta(record) {
1734
1728
  return {
1735
1729
  status: record.status,
1736
1730
  shellId: record.shellId,
1737
1731
  commandId: record.id,
1732
+ script: record.script,
1738
1733
  startedAt: record.startedAt,
1739
1734
  lastOutputAt: record.lastOutputAt,
1740
1735
  exitCode: record.exitCode ?? null,
1741
- stdout: record.stdout,
1742
- stderr: record.stderr,
1743
- ...record.binaryStdout ? { stdoutBinary: {
1744
- base64: bytesToBase64(record.binaryStdout.data),
1745
- truncated: record.binaryStdout.truncated,
1746
- totalBytes: record.binaryStdout.totalBytes
1747
- } } : {}
1748
- };
1749
- }
1750
- function commandArtifactMeta(artifact) {
1751
- const stdoutPath = `/@/commands/${artifact.commandId}/stdout.txt`;
1752
- const stderrPath = `/@/commands/${artifact.commandId}/stderr.txt`;
1753
- return {
1754
- status: artifact.status,
1755
- shellId: artifact.shellId,
1756
- commandId: artifact.commandId,
1757
- startedAt: artifact.startedAt,
1758
- lastOutputAt: artifact.lastOutputAt,
1759
- runningMs: Date.now() - artifact.startedAt,
1760
- idleMs: Date.now() - artifact.lastOutputAt,
1761
- exitCode: artifact.exitCode,
1762
1736
  stdout: {
1763
- path: stdoutPath,
1764
- bytes: utf8Bytes(artifact.stdout)
1737
+ path: `${record.artifactDir}/stdout.txt`,
1738
+ bytes: utf8Bytes(record.stdout)
1765
1739
  },
1766
1740
  stderr: {
1767
- path: stderrPath,
1768
- bytes: utf8Bytes(artifact.stderr)
1741
+ path: `${record.artifactDir}/stderr.txt`,
1742
+ bytes: utf8Bytes(record.stderr)
1769
1743
  },
1770
- ...artifact.stdoutBinary ? { stdoutBinary: {
1771
- path: `/@/commands/${artifact.commandId}/stdout.bin`,
1772
- bytes: artifact.stdoutBinary.totalBytes,
1773
- truncated: artifact.stdoutBinary.truncated
1744
+ ...record.binaryStdout ? { stdoutBinary: {
1745
+ path: `${record.artifactDir}/stdout.bin`,
1746
+ bytes: record.binaryStdout.totalBytes
1774
1747
  } } : {}
1775
1748
  };
1776
1749
  }
1777
- function isCommandArtifact(value) {
1778
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
1779
- const record = value;
1780
- return (record.status === "running" || record.status === "exited" || record.status === "aborted") && typeof record.shellId === "string" && typeof record.commandId === "string" && typeof record.startedAt === "number" && typeof record.lastOutputAt === "number" && (typeof record.exitCode === "number" || record.exitCode === null) && typeof record.stdout === "string" && typeof record.stderr === "string";
1781
- }
1782
1750
  function tailOutputText(chunks) {
1783
1751
  const maxChars = 4096;
1784
1752
  let text = "";
@@ -1806,4 +1774,4 @@ function heredocDelimiter(body) {
1806
1774
  return delimiter;
1807
1775
  }
1808
1776
  //#endregion
1809
- export { AgentSessionCommandStorage, BashEnvironment, COMMAND_HELP_DEFAULTS, CommandRegistry, DEMI_PORTABLE_COMMANDS, HostBackedFileSystem, MAX_TIMEOUT_MS, RESERVED_COMMAND_NAMES, emptyStdin, heredocDelimiter, parseCommandInput, renderCommandHelp, runRegisteredCommand, shellQuote, virtualDirectory, virtualFile };
1777
+ 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 };
@@ -1,4 +1,4 @@
1
- import { t as Host, u as HostStore } from "./host-DIg4RxcL.mjs";
1
+ import { t as Host, u as HostStore } from "./host-B-oKG7x5.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-CNMBL9_j.mjs";
1
+ import { t as AgentSessionCommandStorage } from "./storage-DYvEwSc9.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.14.2",
4
+ "version": "0.15.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.0.1-demi.5",
23
- "@demicodes/utils": "^0.14.2",
22
+ "@demicodes/just-bash": "^3.1.0-demi.3",
23
+ "@demicodes/utils": "^0.15.0",
24
24
  "zod": "^4.0.0"
25
25
  },
26
26
  "license": "Apache-2.0",