@demicodes/shell 0.10.2 → 0.10.3

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,5 +1,6 @@
1
1
  import { t as Host } from "./host-DIg4RxcL.mjs";
2
2
  import { BufferEncoding, CpOptions, DirentEntry, FileContent, FsStat, IFileSystem, MkdirOptions, ReadFileOptions, RmOptions, WriteFileOptions } from "@demicodes/just-bash/fs/interface";
3
+
3
4
  //#region src/host-fs.d.ts
4
5
  type VirtualFileSystemNode = {
5
6
  kind: 'file';
package/dist/host-fs.mjs CHANGED
@@ -166,13 +166,13 @@ function encodingFrom(options) {
166
166
  function encodeContent(content, encoding) {
167
167
  if (content instanceof Uint8Array) return content;
168
168
  if (encoding === "binary" || encoding === "latin1") return latin1ToBytes(content);
169
- if (encoding === "base64") return base64ToBytes$1(content);
169
+ if (encoding === "base64") return base64ToBytes(content);
170
170
  if (encoding === "hex") return hexToBytes(content);
171
171
  return encodeUtf8(content);
172
172
  }
173
173
  function decodeBytes(bytes, encoding) {
174
174
  if (encoding === "binary" || encoding === "latin1") return bytesToLatin1(bytes);
175
- if (encoding === "base64") return bytesToBase64$1(bytes);
175
+ if (encoding === "base64") return bytesToBase64(bytes);
176
176
  if (encoding === "hex") return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
177
177
  return decodeUtf8(bytes);
178
178
  }
@@ -186,10 +186,10 @@ function bytesToLatin1(bytes) {
186
186
  for (let index = 0; index < bytes.length; index += 8192) result += String.fromCharCode(...bytes.subarray(index, index + 8192));
187
187
  return result;
188
188
  }
189
- function base64ToBytes$1(content) {
189
+ function base64ToBytes(content) {
190
190
  return latin1ToBytes(atob(content));
191
191
  }
192
- function bytesToBase64$1(bytes) {
192
+ function bytesToBase64(bytes) {
193
193
  return btoa(bytesToLatin1(bytes));
194
194
  }
195
195
  function hexToBytes(content) {
package/dist/index.d.mts CHANGED
@@ -1,20 +1,21 @@
1
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
2
  import { HostBackedFileSystem, 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-DB9hjZ2G.mjs";
3
+ import { _ as runRegisteredCommand, a as CommandExecutionContext, c as CommandOutputSpec, d as CommandRunResult, f as CommandStdin, g as renderCommandPrompt, h as parseCommandInput, i as CommandAsset, l as CommandRegistry, m as ParsedCommandInput, n as COMMAND_PROMPT_DEFAULTS, o as CommandIO, p as CommandStorage, r as Command, s as CommandInputSpec, t as AgentSessionCommandStorage, u as CommandRunContext } from "./storage-D7EhVuLY.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";
7
+
7
8
  //#region src/environment-state.d.ts
8
9
  interface ExecAccumulator {
9
10
  stdout: string;
10
11
  stderr: string;
11
12
  audit: BashAuditEvent[];
12
13
  commandMetadata: CommandMetadataRecord[];
14
+ assets: CommandAsset[];
13
15
  }
14
16
  interface ShellSession {
15
17
  id: string;
16
- agentSessionId: string | null;
17
- commandStorageId: string;
18
+ commandScopeId: string;
18
19
  state: InterpreterState;
19
20
  fs: HostBackedFileSystem;
20
21
  interpreter: Interpreter;
@@ -56,8 +57,6 @@ interface ForegroundProcess {
56
57
  /** Everything the process wrote, including redirected output — this is what
57
58
  * the interpreter observes as the command's stdout/stderr. */
58
59
  rawStdoutBuffer: string;
59
- /** Raw stdout byte chunks for byte-clean pipeline continuation. */
60
- rawStdoutBytes: Uint8Array[];
61
60
  rawStderrBuffer: string;
62
61
  /** Output routed to the visible sinks only (redirections excluded) — this is
63
62
  * what command records and model previews show. */
@@ -91,21 +90,15 @@ interface BashEnvironmentOptions {
91
90
  shellIdFactory?: () => string;
92
91
  commandIdFactory?: () => string;
93
92
  initialEnv?: Record<string, string>;
94
- /** Cap on a command's text stdout. Guards against log floods. */
95
- maxOutputBytes?: number;
96
93
  /**
97
- * Cap on a final stdout stream that is raw bytes rather than text.
98
- *
99
- * Separate from `maxOutputBytes` because the two guard different risks. Text
100
- * costs context roughly in proportion to its bytes, so a tight cap is what
101
- * stops a stray `cat` of a log file from flooding a window. Raw bytes are
102
- * carried to be looked at — a picture, a clip — and there a megabyte buys far
103
- * more than a megabyte of text does, so the same number would forbid the
104
- * ordinary case. This layer deliberately stops at that distinction: which
105
- * modality the bytes are, and what a given model should be shown, is decided
106
- * above, where models are known.
94
+ * Per-exec env injection for agent-owned shells. Evaluated on every exec with
95
+ * the owning agent session id; returned vars are set and exported, so both
96
+ * registered commands (ctx.env) and spawned external processes observe them.
97
+ * Lets a product harness expose session-scoped context (identity, routing)
98
+ * that changes between execs, which static initialEnv cannot express.
107
99
  */
108
- maxBinaryBytes?: number;
100
+ execEnv?: (agentSessionId: string) => Record<string, string>;
101
+ maxOutputBytes?: number;
109
102
  }
110
103
  interface ShellExecInput {
111
104
  script: string;
@@ -114,18 +107,6 @@ interface ShellExecInput {
114
107
  timeoutMs?: number;
115
108
  maxOutputBytes?: number;
116
109
  signal?: AbortSignal;
117
- /**
118
- * Run in a dedicated one-shot shell instead of the session default shell, so
119
- * cd/env side effects never leak into other execs sharing the session. The
120
- * caller owns the shell and should `disposeShell(snapshot.shellId)` when done.
121
- * Mutually exclusive with `shellId`.
122
- */
123
- ephemeral?: boolean;
124
- /**
125
- * Initial working directory of the shell this exec creates. Requires
126
- * `ephemeral` — a persistent shell owns its cwd (that is what `cd` is for).
127
- */
128
- cwd?: string;
129
110
  }
130
111
  interface ShellStatusInput {
131
112
  commandId: string;
@@ -144,7 +125,7 @@ interface ShellAbortInput {
144
125
  commandId: string;
145
126
  maxOutputBytes?: number;
146
127
  }
147
- interface ShellStreamView {
128
+ interface StreamArtifact {
148
129
  path: string;
149
130
  offset: number;
150
131
  delta: string;
@@ -160,7 +141,7 @@ interface ShellOutputRecordChunk extends ShellOutputChunk {
160
141
  offset: number;
161
142
  bytes: number;
162
143
  }
163
- interface ShellOutputView {
144
+ interface ShellOutputArtifact {
164
145
  path: string;
165
146
  offset: number;
166
147
  text: string;
@@ -193,46 +174,35 @@ interface CommandMetadataRecord {
193
174
  args: string[];
194
175
  metadata: unknown;
195
176
  }
196
- /** Final stdout stream that is not valid UTF-8: raw bytes for the boundary above. */
197
- interface BinaryStdout {
198
- data: Uint8Array;
199
- /** True when the stream exceeded the applicable cap; data is capped. */
200
- truncated: boolean;
201
- /** Total byte count of the un-capped stream. */
202
- totalBytes: number;
203
- /** The byte ceiling that applied, so the boundary above can name it. */
204
- limitBytes: number;
205
- }
206
- type ShellCommandStatus = {
177
+ type ShellCommandSnapshot = {
207
178
  status: 'exited';
208
179
  shellId: string;
209
180
  commandId: string;
210
181
  exitCode: number;
211
- stdout: ShellStreamView;
212
- stderr: ShellStreamView;
213
- output: ShellOutputView;
182
+ stdout: StreamArtifact;
183
+ stderr: StreamArtifact;
184
+ output: ShellOutputArtifact;
214
185
  runningMs: number;
215
186
  idleMs: number;
216
187
  audit: BashAuditEvent[];
217
188
  commandMetadata?: CommandMetadataRecord[];
218
- /** Present when the final stream was binary (bytes that are not valid UTF-8). */
219
- binaryStdout?: BinaryStdout;
189
+ assets?: CommandAsset[];
220
190
  } | {
221
191
  status: 'running';
222
192
  shellId: string;
223
193
  commandId: string;
224
- stdout: ShellStreamView;
225
- stderr: ShellStreamView;
226
- output: ShellOutputView;
194
+ stdout: StreamArtifact;
195
+ stderr: StreamArtifact;
196
+ output: ShellOutputArtifact;
227
197
  runningMs: number;
228
198
  idleMs: number;
229
199
  } | {
230
200
  status: 'aborted';
231
201
  shellId: string;
232
202
  commandId: string;
233
- stdout: ShellStreamView;
234
- stderr: ShellStreamView;
235
- output: ShellOutputView;
203
+ stdout: StreamArtifact;
204
+ stderr: StreamArtifact;
205
+ output: ShellOutputArtifact;
236
206
  runningMs: number;
237
207
  idleMs: number;
238
208
  };
@@ -244,21 +214,20 @@ declare class BashEnvironment {
244
214
  private readonly shellIdFactory;
245
215
  private readonly commandIdFactory;
246
216
  private readonly initialEnv;
217
+ private readonly execEnv?;
247
218
  private readonly defaultOutputLimitBytes;
248
- private readonly defaultBinaryLimitBytes;
249
219
  private readonly shells;
250
220
  private readonly defaultShellByAgentSessionId;
251
221
  private readonly commandsById;
252
222
  private readonly artifacts;
253
223
  constructor(options: BashEnvironmentOptions);
254
224
  getShell(shellId: string): ShellSession | null;
255
- hasCommand(commandId: string): boolean;
256
225
  registerCommand(command: Command): void;
257
226
  registeredCommands(): Command[];
258
- exec(input: ShellExecInput): Promise<ShellCommandStatus>;
259
- status(input: ShellStatusInput): Promise<ShellCommandStatus>;
260
- write(input: ShellWriteInput): Promise<ShellCommandStatus>;
261
- abort(input: ShellAbortInput): Promise<ShellCommandStatus>;
227
+ exec(input: ShellExecInput): Promise<ShellCommandSnapshot>;
228
+ status(input: ShellStatusInput): Promise<ShellCommandSnapshot>;
229
+ write(input: ShellWriteInput): Promise<ShellCommandSnapshot>;
230
+ abort(input: ShellAbortInput): Promise<ShellCommandSnapshot>;
262
231
  releaseCommand(commandId: string): Promise<boolean>;
263
232
  disposeShell(shellId: string): Promise<boolean>;
264
233
  disposeAllShells(): Promise<void>;
@@ -282,7 +251,7 @@ declare class BashEnvironment {
282
251
  private finishExited;
283
252
  private collectAborted;
284
253
  private collectAbortedWithoutForeground;
285
- private commandStatus;
254
+ private snapshotCommand;
286
255
  private lookupVirtualArtifact;
287
256
  private commandArtifactIds;
288
257
  private commandArtifact;
@@ -321,4 +290,4 @@ declare function shellQuote(value: string): string;
321
290
  /** Picks a heredoc delimiter that does not collide with any line already in `body`. */
322
291
  declare function heredocDelimiter(body: string): string;
323
292
  //#endregion
324
- 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, 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 };
293
+ export { AgentSessionCommandStorage, BashAuditEvent, BashEnvironment, BashEnvironmentOptions, COMMAND_PROMPT_DEFAULTS, Command, CommandAsset, CommandExecutionContext, CommandIO, CommandInputSpec, CommandMetadataRecord, CommandOutputSpec, CommandRegistry, CommandRunContext, CommandRunResult, CommandStdin, CommandStorage, DEMI_PORTABLE_COMMANDS, Host, HostBackedFileSystem, HostDirent, HostFileStat, HostFileSystem, HostProcess, HostProcessOutputChunk, HostSpawnExit, HostSpawnHandle, HostSpawnParams, HostStore, MAX_TIMEOUT_MS, ParsedCommandInput, RESERVED_COMMAND_NAMES, ShellAbortInput, ShellCommandSnapshot, ShellExecInput, ShellOutputArtifact, ShellOutputChunk, ShellOutputRecordChunk, ShellStatusInput, ShellWriteInput, StreamArtifact, VirtualFileSystemNode, VirtualFileSystemProvider, heredocDelimiter, parseCommandInput, renderCommandPrompt, runRegisteredCommand, shellQuote, virtualDirectory, virtualFile };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { HostBackedFileSystem, virtualDirectory, virtualFile } 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, decodeUtf8, 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";
@@ -10,19 +10,26 @@ import { LexerError } from "@demicodes/just-bash/parser/lexer";
10
10
  import { resolveLimits } from "@demicodes/just-bash/limits";
11
11
  //#region src/portable-commands.ts
12
12
  /**
13
- * Deliberately routed to real OS processes (Host.process.spawn) even though
14
- * just-bash ships portable versions. This is a semantics decision, not a
15
- * missing feature:
13
+ * Individually excluded even though just-bash calls them portable: each
14
+ * regresses a real, already-passing test elsewhere in this monorepo when
15
+ * dispatched through this package's fork-command wrapping specifically
16
+ * (isolated just-bash unit tests for these same commands pass fine, so the
17
+ * break is in how @demicodes/shell wraps them, not in just-bash itself):
16
18
  *
17
- * - `bash`, `sh`: scripts in real repositories expect a real interpreter
18
- * (full bash semantics, real coreutils, real subprocess behavior). Routing
19
- * them into just-bash's simulated interpreter would silently downgrade
20
- * every `bash script.sh` a model runs. (The fork's nested-interpreter
21
- * stdout also does not forward through this package's wrapping but even
22
- * with that fixed, real spawn stays the right routing.)
23
- * - `sleep`: must be a real, abortable OS process so foreground abort and
24
- * timeout semantics (`env.abort()` while `running`) genuinely interrupt
25
- * it; just-bash's version is a plain in-process timer.
19
+ * - `bash`, `sh`: nested "run a script string through the interpreter again".
20
+ * Their stdout never reaches the caller here `sh -c "printf hi"` and
21
+ * `bash -c "echo hi"` both exit 0 with empty output, while the exact same
22
+ * scripts run un-nested work fine. This package's own `AgentServer` test
23
+ * spawns `sh -c` for real and asserts on the real output.
24
+ * - `sleep`: just-bash's version is a plain timer, not a real backgroundable,
25
+ * abortable OS process. This package's own environment tests use `sleep 10`
26
+ * specifically as a long-running foreground process to exercise abort/
27
+ * timeout handling (`env.abort()` / status-while-`running`), which needs a
28
+ * real process to abort.
29
+ *
30
+ * Keeping these three off the list preserves their pre-existing
31
+ * fall-through to Host.process.spawn, same as before this list started
32
+ * tracking just-bash's registry.
26
33
  */
27
34
  const REAL_SPAWN_DEPENDENT_COMMANDS = /* @__PURE__ */ new Set([
28
35
  "bash",
@@ -108,12 +115,6 @@ const RESERVED_COMMAND_NAMES = /* @__PURE__ */ new Set([
108
115
  ]);
109
116
  //#endregion
110
117
  //#region src/command.ts
111
- function emptyStdin() {
112
- return {
113
- text: "",
114
- bytes: /* @__PURE__ */ new Uint8Array(0)
115
- };
116
- }
117
118
  const EXECUTION_ONLY_FIELDS = [
118
119
  "effects",
119
120
  "successOutput",
@@ -124,7 +125,6 @@ const EXECUTION_ONLY_FIELDS = [
124
125
  "output",
125
126
  "examples"
126
127
  ];
127
- const COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
128
128
  var CommandRegistry = class {
129
129
  commands = /* @__PURE__ */ new Map();
130
130
  register(command) {
@@ -139,14 +139,14 @@ var CommandRegistry = class {
139
139
  list() {
140
140
  return [...this.commands.values()];
141
141
  }
142
- renderHelp() {
143
- const rendered = this.list().map((command) => renderCommandHelp(command)).join("\n\n");
142
+ renderPrompt() {
143
+ const rendered = this.list().map((command) => renderCommandPrompt(command)).join("\n\n");
144
144
  if (!rendered) return rendered;
145
- return `${COMMAND_HELP_DEFAULTS}\n\n${rendered}`;
145
+ return `${COMMAND_PROMPT_DEFAULTS}\n\n${rendered}`;
146
146
  }
147
147
  };
148
- const COMMAND_HELP_DEFAULTS = "Unless a command states otherwise: success prints raw text on stdout, failure writes an error message to stderr and exits non-zero. Pass --help at any level to print a command's documentation.";
149
- function parseCommandInput(root, argv, stdin = emptyStdin()) {
148
+ const COMMAND_PROMPT_DEFAULTS = "Unless a command states otherwise: success prints raw text on stdout, failure writes an error message to stderr and exits non-zero.";
149
+ function parseCommandInput(root, argv, stdin = { text: "" }) {
150
150
  if (argv[0] !== root.name) throw new Error(`Expected command "${root.name}", received "${argv[0] ?? ""}"`);
151
151
  let node = root;
152
152
  const path = [root.name];
@@ -157,7 +157,7 @@ function parseCommandInput(root, argv, stdin = emptyStdin()) {
157
157
  throw new Error(`Command "${path.join(" ")}" requires a subcommand`);
158
158
  }
159
159
  const token = argv[index];
160
- if (token === "--help") return {
160
+ if (token === "prompt") return {
161
161
  path: [...path],
162
162
  help: true,
163
163
  values: {},
@@ -182,12 +182,6 @@ function parseArgs(command, path, argv, startIndex, stdin) {
182
182
  let positionalIndex = 0;
183
183
  for (let i = startIndex; i < argv.length; i += 1) {
184
184
  const token = argv[i];
185
- if (token === "--help") return {
186
- path: [...path],
187
- help: true,
188
- values: {},
189
- json: false
190
- };
191
185
  if (token === "--json") {
192
186
  json = true;
193
187
  continue;
@@ -229,13 +223,13 @@ function resolveCommand(root, path) {
229
223
  return node;
230
224
  }
231
225
  async function runRegisteredCommand(root, ctx) {
232
- const stdin = ctx.stdin ?? emptyStdin();
226
+ const stdin = ctx.stdin ?? { text: "" };
233
227
  const parsed = parseCommandInput(root, ctx.argv, stdin);
234
228
  const displayPath = parsed.path.join(" ");
235
229
  if (parsed.help) {
236
230
  const node = resolveCommand(root, parsed.path);
237
231
  const parentPath = parsed.path.length > 1 ? parsed.path.slice(0, -1).join(" ") : "";
238
- await ctx.io.stdout(`${renderCommandHelp(node, parentPath)}\n`);
232
+ await ctx.io.stdout(`${renderCommandPrompt(node, parentPath)}\n`);
239
233
  return { exitCode: 0 };
240
234
  }
241
235
  const command = resolveCommand(root, parsed.path);
@@ -249,8 +243,7 @@ async function runRegisteredCommand(root, ctx) {
249
243
  env: ctx.env,
250
244
  cwd: ctx.cwd,
251
245
  io: parsed.json ? capture : ctx.io,
252
- storage: ctx.storage,
253
- host: ctx.host
246
+ storage: ctx.storage
254
247
  });
255
248
  if (parsed.json && result.exitCode === 0) {
256
249
  const raw = capture.stdoutText();
@@ -269,7 +262,7 @@ async function runRegisteredCommand(root, ctx) {
269
262
  }
270
263
  return result;
271
264
  }
272
- function renderCommandHelp(command, parentPath = "") {
265
+ function renderCommandPrompt(command, parentPath = "") {
273
266
  const path = parentPath ? `${parentPath} ${command.name}` : command.name;
274
267
  const blocks = [];
275
268
  const lines = [`${path}: ${command.summary}`];
@@ -304,11 +297,10 @@ function renderCommandHelp(command, parentPath = "") {
304
297
  for (const child of children) lines.push(` ${path} ${child.name} — ${child.summary}`);
305
298
  }
306
299
  blocks.push(lines.join("\n"));
307
- for (const child of children) blocks.push(renderCommandHelp(child, path));
300
+ for (const child of children) blocks.push(renderCommandPrompt(child, path));
308
301
  return blocks.join("\n\n");
309
302
  }
310
303
  function validateCommandTree(command, path) {
311
- if (!COMMAND_NAME_PATTERN.test(command.name)) throw new Error(`CommandRegistry: "${path}" has invalid name "${command.name}"; use letters, numbers, underscores, and hyphens`);
312
304
  const hasRun = typeof command.run === "function";
313
305
  const children = command.subcommands ?? [];
314
306
  if (!hasRun && children.length === 0) throw new Error(`CommandRegistry: "${path}" must have run() and/or subcommands`);
@@ -322,6 +314,7 @@ function validateCommandTree(command, path) {
322
314
  }
323
315
  const seen = /* @__PURE__ */ new Set();
324
316
  for (const child of children) {
317
+ if (child.name === "prompt") throw new Error(`CommandRegistry: "${path} prompt" is reserved for the help pseudo-subcommand`);
325
318
  if (seen.has(child.name)) throw new Error(`CommandRegistry: duplicate subcommand "${path} ${child.name}"`);
326
319
  seen.add(child.name);
327
320
  validateCommandTree(child, `${path} ${child.name}`);
@@ -405,6 +398,9 @@ var CapturingIO = class {
405
398
  async stderr(data) {
406
399
  await this.target.stderr(data);
407
400
  }
401
+ async asset(asset) {
402
+ await this.target.asset(asset);
403
+ }
408
404
  stdoutText() {
409
405
  return decodeUtf8(concatBytes(this.chunks));
410
406
  }
@@ -516,10 +512,8 @@ function createOutputSinks(fs, cwd, redirections) {
516
512
  function recordForegroundChunk(foreground, sourceFd, chunk) {
517
513
  const text = decodeUtf8(chunk);
518
514
  foreground.lastOutputAt = Date.now();
519
- if (sourceFd === 1) {
520
- foreground.rawStdoutBuffer += text;
521
- foreground.rawStdoutBytes.push(chunk);
522
- } else foreground.rawStderrBuffer += text;
515
+ if (sourceFd === 1) foreground.rawStdoutBuffer += text;
516
+ else foreground.rawStderrBuffer += text;
523
517
  const sink = foreground.outputSinks[sourceFd];
524
518
  if (sink.kind === "file" || sink.kind === "null") {
525
519
  sink.bytes.push(chunk);
@@ -621,63 +615,62 @@ function appendVisibleChunk(foreground, targetFd, text, byteLength) {
621
615
  //#endregion
622
616
  //#region src/command-artifact-store.ts
623
617
  /**
624
- * Owns persistence of shell command artifacts: a per-storage-id cache plus the
618
+ * Owns persistence of shell command artifacts: a per-scope storage cache plus the
625
619
  * set of released (tombstoned) commands. `BashEnvironment` delegates the storage
626
620
  * and release-tracking side of the `/@` virtual filesystem here, keeping only the
627
621
  * in-memory record lookups that need live command state.
628
622
  */
629
623
  var CommandArtifactStore = class {
630
624
  store;
631
- storageById = /* @__PURE__ */ new Map();
625
+ storageByScope = /* @__PURE__ */ new Map();
632
626
  released = /* @__PURE__ */ new Set();
633
627
  constructor(store) {
634
628
  this.store = store;
635
629
  }
636
- /** The artifact storage for one agent session or anonymous shell. */
637
- storageFor(commandStorageId) {
638
- const existing = this.storageById.get(commandStorageId);
630
+ /** The artifact storage for a command scope, created (and cached) on first use. */
631
+ storageFor(scopeId) {
632
+ const existing = this.storageByScope.get(scopeId);
639
633
  if (existing) return existing;
640
- const storage = new AgentSessionCommandStorage(this.store, commandStorageId);
641
- this.storageById.set(commandStorageId, storage);
634
+ const storage = new AgentSessionCommandStorage(this.store, scopeId);
635
+ this.storageByScope.set(scopeId, storage);
642
636
  return storage;
643
637
  }
644
638
  /** Whether a command's artifact has been released (tombstoned). */
645
- isReleased(commandStorageId, commandId) {
646
- return this.released.has(this.key(commandStorageId, commandId));
639
+ isReleased(scopeId, commandId) {
640
+ return this.released.has(this.key(scopeId, commandId));
647
641
  }
648
642
  /** Persists an artifact unless its command has already been released. */
649
- persist(commandStorageId, commandId, artifact) {
650
- if (this.isReleased(commandStorageId, commandId)) return;
651
- this.storageFor(commandStorageId).writeJson(`commands/${commandId}/artifact.json`, artifact).catch(() => {});
643
+ persist(scopeId, commandId, artifact) {
644
+ if (this.isReleased(scopeId, commandId)) return;
645
+ this.storageFor(scopeId).writeJson(`commands/${commandId}/artifact.json`, artifact).catch(() => {});
652
646
  }
653
647
  /** Tombstones a command and removes its persisted artifact. */
654
- async release(commandStorageId, commandId) {
655
- this.released.add(this.key(commandStorageId, commandId));
656
- await this.storageFor(commandStorageId).delete(`commands/${commandId}/artifact.json`).catch(() => {});
648
+ async release(scopeId, commandId) {
649
+ this.released.add(this.key(scopeId, commandId));
650
+ await this.storageFor(scopeId).delete(`commands/${commandId}/artifact.json`).catch(() => {});
657
651
  }
658
- key(commandStorageId, commandId) {
659
- return `${commandStorageId}\0${commandId}`;
652
+ key(scopeId, commandId) {
653
+ return `${scopeId}\0${commandId}`;
660
654
  }
661
655
  };
662
656
  //#endregion
663
657
  //#region src/registered-command-adapter.ts
664
- function commandToForkCommand(session, command, storage, host) {
658
+ function commandToForkCommand(session, command, storage) {
665
659
  return {
666
660
  name: command.name,
667
661
  consumesStdin: treeConsumesStdin(command),
668
662
  execute: async (args, ctx) => {
669
- const stdin = decodeForkStdin(ctx.stdin);
663
+ const stdinText = decodeForkStdin(ctx.stdin);
670
664
  const io = createForwardingIO();
671
665
  const argv = [command.name, ...args];
672
666
  try {
673
667
  const result = await runRegisteredCommand(command, {
674
668
  argv,
675
- stdin,
669
+ stdin: { text: stdinText },
676
670
  env: mapToRecord(ctx.env),
677
671
  cwd: ctx.cwd,
678
672
  io,
679
- storage,
680
- host
673
+ storage
681
674
  });
682
675
  session.accumulator.audit.push({
683
676
  kind: "registered-command",
@@ -692,8 +685,7 @@ function commandToForkCommand(session, command, storage, host) {
692
685
  metadata: result.metadata
693
686
  });
694
687
  return {
695
- stdout: io.stdoutLatin1(),
696
- stdoutKind: "bytes",
688
+ stdout: io.stdoutText(),
697
689
  stderr: io.stderrText(),
698
690
  exitCode: result.exitCode
699
691
  };
@@ -706,31 +698,38 @@ function commandToForkCommand(session, command, storage, host) {
706
698
  exitCode: 1
707
699
  });
708
700
  return {
709
- stdout: io.stdoutLatin1(),
710
- stdoutKind: "bytes",
701
+ stdout: io.stdoutText(),
711
702
  stderr: `${io.stderrText()}${command.name}: ${message}\n`,
712
703
  exitCode: 1
713
704
  };
705
+ } finally {
706
+ if (io.assets().length > 0) session.accumulator.assets.push(...io.assets());
714
707
  }
715
708
  }
716
709
  };
717
710
  }
718
- /** Collects command output as raw bytes; stdout stays byte-clean for the pipe. */
719
711
  var ForwardingIO = class {
720
712
  stdoutChunks = [];
721
713
  stderrChunks = [];
714
+ assetItems = [];
722
715
  async stdout(data) {
723
716
  this.stdoutChunks.push(typeof data === "string" ? encodeUtf8(data) : data);
724
717
  }
725
718
  async stderr(data) {
726
719
  this.stderrChunks.push(typeof data === "string" ? encodeUtf8(data) : data);
727
720
  }
728
- stdoutLatin1() {
729
- return decodeLatin1(concatBytes(this.stdoutChunks));
721
+ asset(asset) {
722
+ this.assetItems.push(asset);
723
+ }
724
+ stdoutText() {
725
+ return decodeUtf8(concatBytes(this.stdoutChunks));
730
726
  }
731
727
  stderrText() {
732
728
  return decodeUtf8(concatBytes(this.stderrChunks));
733
729
  }
730
+ assets() {
731
+ return this.assetItems;
732
+ }
734
733
  };
735
734
  function createForwardingIO() {
736
735
  return new ForwardingIO();
@@ -744,52 +743,26 @@ function mapToRecord(map) {
744
743
  for (const [key, value] of map) record[key] = value;
745
744
  return record;
746
745
  }
747
- /** Pipes hand stdin over as a latin1-packed byte string; expose bytes and a UTF-8 text view. */
748
746
  function decodeForkStdin(stdin) {
749
- if (!stdin) return {
750
- text: "",
751
- bytes: /* @__PURE__ */ new Uint8Array(0)
752
- };
753
- if (stdin instanceof Uint8Array) return {
754
- text: decodeUtf8(stdin),
755
- bytes: stdin
756
- };
747
+ if (!stdin) return "";
748
+ if (stdin instanceof Uint8Array) return decodeUtf8(stdin);
757
749
  const latin1 = stdin;
758
- if (!latin1) return {
759
- text: "",
760
- bytes: /* @__PURE__ */ new Uint8Array(0)
761
- };
762
- const bytes = encodeLatin1(latin1);
750
+ if (!latin1) return "";
763
751
  let hasHighByte = false;
764
- let hasWideChar = false;
765
752
  for (let i = 0; i < latin1.length; i += 1) {
766
753
  const code = latin1.charCodeAt(i);
767
- if (code > 255) hasWideChar = true;
768
- else if (code > 127) hasHighByte = true;
754
+ if (code > 255) return latin1;
755
+ if (code > 127) hasHighByte = true;
769
756
  }
770
- if (hasWideChar) return {
771
- text: latin1,
772
- bytes: encodeUtf8(latin1)
773
- };
774
- if (!hasHighByte) return {
775
- text: latin1,
776
- bytes
777
- };
778
- return {
779
- text: decodeUtf8(bytes),
780
- bytes
781
- };
757
+ if (!hasHighByte) return latin1;
758
+ const bytes = new Uint8Array(latin1.length);
759
+ for (let i = 0; i < latin1.length; i += 1) bytes[i] = latin1.charCodeAt(i);
760
+ return decodeUtf8(bytes);
782
761
  }
783
762
  //#endregion
784
763
  //#region src/environment.ts
785
764
  const DEFAULT_TIMEOUT_MS = 1e4;
786
765
  const DEFAULT_OUTPUT_LIMIT_BYTES = 1024 * 1024;
787
- /**
788
- * Ceiling for a raw-byte final stream. Sized so an ordinary viewing-grade clip
789
- * survives the shell and reaches the layer that decides what to do with it,
790
- * while still bounding a runaway producer.
791
- */
792
- const DEFAULT_BINARY_LIMIT_BYTES = 16 * 1024 * 1024;
793
766
  /** Upper bound for a single exec observation window (also the command-bridge wait ceiling). */
794
767
  const MAX_TIMEOUT_MS = 6e5;
795
768
  var BashEnvironment = class {
@@ -798,8 +771,8 @@ var BashEnvironment = class {
798
771
  shellIdFactory;
799
772
  commandIdFactory;
800
773
  initialEnv;
774
+ execEnv;
801
775
  defaultOutputLimitBytes;
802
- defaultBinaryLimitBytes;
803
776
  shells = /* @__PURE__ */ new Map();
804
777
  defaultShellByAgentSessionId = /* @__PURE__ */ new Map();
805
778
  commandsById = /* @__PURE__ */ new Map();
@@ -811,15 +784,12 @@ var BashEnvironment = class {
811
784
  this.shellIdFactory = options.shellIdFactory ?? (() => globalThis.crypto.randomUUID());
812
785
  this.commandIdFactory = options.commandIdFactory ?? (() => globalThis.crypto.randomUUID());
813
786
  this.initialEnv = options.initialEnv ?? {};
787
+ this.execEnv = options.execEnv;
814
788
  this.defaultOutputLimitBytes = options.maxOutputBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES;
815
- this.defaultBinaryLimitBytes = options.maxBinaryBytes ?? DEFAULT_BINARY_LIMIT_BYTES;
816
789
  }
817
790
  getShell(shellId) {
818
791
  return this.shells.get(shellId) ?? null;
819
792
  }
820
- hasCommand(commandId) {
821
- return this.commandsById.has(commandId);
822
- }
823
793
  registerCommand(command) {
824
794
  if (this.commands.get(command.name)) return;
825
795
  this.commands.register(command);
@@ -829,13 +799,20 @@ var BashEnvironment = class {
829
799
  }
830
800
  async exec(input) {
831
801
  const timeoutMs = normalizeTimeoutMs(input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
832
- if (input.shellId && input.ephemeral) throw new Error("ShellExecInput: \"shellId\" and \"ephemeral\" are mutually exclusive");
833
- if (input.cwd !== void 0 && !input.ephemeral) throw new Error("ShellExecInput: \"cwd\" requires \"ephemeral\"; a persistent shell owns its cwd");
834
- if (input.cwd !== void 0) {
835
- if (!(await this.host.fs.stat(input.cwd).catch(() => null))?.isDirectory) throw new Error(`Shell exec cwd is not a directory: ${input.cwd}`);
836
- }
837
- const session = input.shellId ? this.requireShell(input.shellId) : input.ephemeral ? this.createShell(input.agentSessionId, input.cwd) : this.availableDefaultShell(input.agentSessionId);
802
+ const session = input.shellId ? this.requireShell(input.shellId) : this.availableDefaultShell(input.agentSessionId);
838
803
  if (session.exited) throw new Error(`Shell session "${session.id}" has exited`);
804
+ if (input.agentSessionId) {
805
+ session.state.env.set("DEMI_AGENT_SESSION_ID", input.agentSessionId);
806
+ const extraEnv = this.execEnv?.(input.agentSessionId);
807
+ if (extraEnv) {
808
+ const exported = session.state.exportedVars ?? /* @__PURE__ */ new Set();
809
+ session.state.exportedVars = exported;
810
+ for (const [key, value] of Object.entries(extraEnv)) {
811
+ session.state.env.set(key, value);
812
+ exported.add(key);
813
+ }
814
+ }
815
+ }
839
816
  if (session.pendingExec || session.foreground) {
840
817
  const commandId = session.activeCommandId ?? session.foreground?.commandId ?? "unknown";
841
818
  throw new Error(`Shell session "${session.id}" is already running command "${commandId}"`);
@@ -847,7 +824,7 @@ var BashEnvironment = class {
847
824
  }
848
825
  async status(input) {
849
826
  const record = this.requireCommand(input.commandId);
850
- return this.commandStatus(record, input);
827
+ return this.snapshotCommand(record, input);
851
828
  }
852
829
  async write(input) {
853
830
  const record = this.requireCommand(input.commandId);
@@ -857,11 +834,11 @@ var BashEnvironment = class {
857
834
  const data = typeof input.stdin === "string" ? encodeUtf8(input.stdin) : input.stdin;
858
835
  if (data.byteLength === 0) throw new Error("shell_write field \"stdin\" must not be empty; use shell_status to poll");
859
836
  await foreground.handle.writeStdin(data);
860
- return this.commandStatus(record, input);
837
+ return this.snapshotCommand(record, input);
861
838
  }
862
839
  async abort(input) {
863
840
  const record = this.requireCommand(input.commandId);
864
- if (record.status !== "running") return this.commandStatus(record, input);
841
+ if (record.status !== "running") return this.snapshotCommand(record, input);
865
842
  const session = this.requireShell(record.shellId);
866
843
  const foreground = this.requireForegroundCommand(session, record.id);
867
844
  foreground.abortController.abort();
@@ -873,7 +850,7 @@ var BashEnvironment = class {
873
850
  const record = this.commandsById.get(commandId);
874
851
  if (!record || record.status === "running") return false;
875
852
  this.commandsById.delete(commandId);
876
- await this.artifacts.release(record.commandStorageId, commandId);
853
+ await this.artifacts.release(record.commandScopeId, commandId);
877
854
  return true;
878
855
  }
879
856
  async disposeShell(shellId) {
@@ -939,22 +916,25 @@ var BashEnvironment = class {
939
916
  if (!agentSessionId || shell.exited || !shell.pendingExec && !shell.foreground) return shell;
940
917
  return this.createShell(agentSessionId);
941
918
  }
942
- createShell(agentSessionId, initialCwd) {
919
+ createShell(agentSessionId) {
943
920
  const id = this.shellIdFactory();
944
- const commandStorageId = agentSessionId ?? id;
945
- const cwd = initialCwd ?? this.host.defaultCwd;
946
- const fs = new HostBackedFileSystem(this.host, { lookup: (path) => this.lookupVirtualArtifact(commandStorageId, path) });
921
+ const commandScopeId = agentSessionId ?? id;
922
+ const cwd = this.host.defaultCwd;
923
+ const fs = new HostBackedFileSystem(this.host, { lookup: (path) => this.lookupVirtualArtifact(commandScopeId, path) });
947
924
  const env = /* @__PURE__ */ new Map();
948
925
  for (const [key, value] of Object.entries(this.initialEnv)) env.set(key, value);
949
926
  env.set("PWD", cwd);
950
- if (agentSessionId) env.set("DEMI_SESSION_ID", agentSessionId);
927
+ env.set("DEMI_SESSION_ID", commandScopeId);
951
928
  env.set("DEMI_SHELL_ID", id);
952
929
  if (!env.has("IFS")) env.set("IFS", " \n");
953
930
  if (!env.has("PS1")) env.set("PS1", "");
954
931
  if (!env.has("PS2")) env.set("PS2", "> ");
955
932
  if (!env.has("SHLVL")) env.set("SHLVL", "1");
956
- const exportedVars = /* @__PURE__ */ new Set(["PWD", "DEMI_SHELL_ID"]);
957
- if (agentSessionId) exportedVars.add("DEMI_SESSION_ID");
933
+ const exportedVars = /* @__PURE__ */ new Set([
934
+ "PWD",
935
+ "DEMI_SESSION_ID",
936
+ "DEMI_SHELL_ID"
937
+ ]);
958
938
  for (const key of env.keys()) if (key !== key.toLowerCase()) exportedVars.add(key);
959
939
  for (const key of Object.keys(this.initialEnv)) exportedVars.add(key);
960
940
  const state = {
@@ -1015,8 +995,7 @@ var BashEnvironment = class {
1015
995
  const forkCommands = /* @__PURE__ */ new Map();
1016
996
  const session = {
1017
997
  id,
1018
- agentSessionId: agentSessionId ?? null,
1019
- commandStorageId,
998
+ commandScopeId,
1020
999
  state,
1021
1000
  fs,
1022
1001
  interpreter: void 0,
@@ -1025,7 +1004,8 @@ var BashEnvironment = class {
1025
1004
  stdout: "",
1026
1005
  stderr: "",
1027
1006
  audit: [],
1028
- commandMetadata: []
1007
+ commandMetadata: [],
1008
+ assets: []
1029
1009
  },
1030
1010
  foregroundWaiters: /* @__PURE__ */ new Set(),
1031
1011
  backgroundJobs: /* @__PURE__ */ new Map(),
@@ -1033,8 +1013,8 @@ var BashEnvironment = class {
1033
1013
  exited: false
1034
1014
  };
1035
1015
  for (const command of createPortableCommands(session)) forkCommands.set(command.name, command);
1036
- const storage = new AgentSessionCommandStorage(this.host.store, commandStorageId);
1037
- for (const command of this.commands.list()) forkCommands.set(command.name, commandToForkCommand(session, command, storage, this.host));
1016
+ const storage = new AgentSessionCommandStorage(this.host.store, commandScopeId);
1017
+ for (const command of this.commands.list()) forkCommands.set(command.name, commandToForkCommand(session, command, storage));
1038
1018
  session.abortController = new AbortController();
1039
1019
  session.interpreter = new Interpreter({
1040
1020
  fs,
@@ -1064,7 +1044,6 @@ var BashEnvironment = class {
1064
1044
  }
1065
1045
  async runScript(session, script, input) {
1066
1046
  const record = this.createCommandRecord(session, script);
1067
- record.outputLimitBytes = input.maxOutputBytes ?? this.defaultOutputLimitBytes;
1068
1047
  let ast;
1069
1048
  try {
1070
1049
  ast = parse(script);
@@ -1076,7 +1055,7 @@ var BashEnvironment = class {
1076
1055
  record.exitCode = 2;
1077
1056
  session.state.lastExitCode = 2;
1078
1057
  session.activeCommandId = void 0;
1079
- return this.commandStatus(record, input);
1058
+ return this.snapshotCommand(record, input);
1080
1059
  }
1081
1060
  throw error;
1082
1061
  }
@@ -1084,7 +1063,8 @@ var BashEnvironment = class {
1084
1063
  stdout: "",
1085
1064
  stderr: "",
1086
1065
  audit: [],
1087
- commandMetadata: []
1066
+ commandMetadata: [],
1067
+ assets: []
1088
1068
  };
1089
1069
  session.abortController = new AbortController();
1090
1070
  session.activeCommandId = record.id;
@@ -1107,7 +1087,7 @@ var BashEnvironment = class {
1107
1087
  const record = {
1108
1088
  id,
1109
1089
  shellId: session.id,
1110
- commandStorageId: session.commandStorageId,
1090
+ commandScopeId: session.commandScopeId,
1111
1091
  script,
1112
1092
  startedAt: now,
1113
1093
  lastOutputAt: now,
@@ -1120,7 +1100,7 @@ var BashEnvironment = class {
1120
1100
  outputOffset: 0,
1121
1101
  audit: [],
1122
1102
  commandMetadata: [],
1123
- outputLimitBytes: this.defaultOutputLimitBytes
1103
+ assets: []
1124
1104
  };
1125
1105
  this.commandsById.set(id, record);
1126
1106
  return record;
@@ -1241,7 +1221,7 @@ var BashEnvironment = class {
1241
1221
  foreground = outcome.foreground;
1242
1222
  continue;
1243
1223
  }
1244
- if (outcome.kind === "timeout") return this.commandStatus(record, input);
1224
+ if (outcome.kind === "timeout") return this.snapshotCommand(record, input);
1245
1225
  if (outcome.kind === "aborted") {
1246
1226
  const activeForeground = foreground ?? session.foreground;
1247
1227
  if (!activeForeground) return this.collectAbortedWithoutForeground(session, record, input);
@@ -1326,7 +1306,6 @@ var BashEnvironment = class {
1326
1306
  startedAt,
1327
1307
  lastOutputAt: startedAt,
1328
1308
  rawStdoutBuffer: "",
1329
- rawStdoutBytes: [],
1330
1309
  rawStderrBuffer: "",
1331
1310
  stdoutBuffer: "",
1332
1311
  stderrBuffer: "",
@@ -1347,7 +1326,7 @@ var BashEnvironment = class {
1347
1326
  };
1348
1327
  session.foreground = foreground;
1349
1328
  notifyForegroundWaiters(session.foregroundWaiters, foreground);
1350
- if (opts.stdin && opts.stdin.length > 0) await handle.writeStdin(encodeLatin1(opts.stdin));
1329
+ if (opts.stdin && opts.stdin.length > 0) await handle.writeStdin(encodeUtf8(opts.stdin));
1351
1330
  if (opts.stdinProvided) await handle.closeStdin();
1352
1331
  if (handle.output) {
1353
1332
  foreground.stdoutPump = pumpOutputStream(handle.output, (chunk) => {
@@ -1360,7 +1339,7 @@ var BashEnvironment = class {
1360
1339
  }
1361
1340
  const exit = await foreground.exitPromise;
1362
1341
  await Promise.allSettled([foreground.stdoutPump, foreground.stderrPump]);
1363
- const stdout = decodeLatin1(concatBytes(foreground.rawStdoutBytes));
1342
+ const stdout = foreground.rawStdoutBuffer;
1364
1343
  const exitCode = exit.exitCode ?? 127;
1365
1344
  const stderr = exit.exitCode === null && foreground.rawStderrBuffer.length === 0 ? `${command}: ${exit.signal ?? "command not found"}\n` : foreground.rawStderrBuffer;
1366
1345
  foreground.audit[0] = {
@@ -1381,13 +1360,12 @@ var BashEnvironment = class {
1381
1360
  session.foreground = void 0;
1382
1361
  return {
1383
1362
  stdout,
1384
- stdoutKind: "bytes",
1385
1363
  stderr,
1386
1364
  exitCode
1387
1365
  };
1388
1366
  }
1389
1367
  collectExited(session, record, resultOrError, foreground, input = {}) {
1390
- if (record.status !== "running") return this.commandStatus(record, input);
1368
+ if (record.status !== "running") return this.snapshotCommand(record, input);
1391
1369
  if (resultOrError instanceof Error) {
1392
1370
  if (resultOrError instanceof ExitError) {
1393
1371
  session.exited = true;
@@ -1425,36 +1403,12 @@ var BashEnvironment = class {
1425
1403
  appendRecordOutput(record, "stderr", text);
1426
1404
  return this.finishExited(session, record, 1, input);
1427
1405
  }
1428
- const raw = resultOrError.stdout;
1429
- let stdoutText;
1430
- let binary;
1431
- if (hasWideChar(raw)) stdoutText = raw;
1432
- else {
1433
- const bytes = encodeLatin1(raw);
1434
- const strict = decodeUtf8Strict(bytes);
1435
- if (strict !== null) stdoutText = strict;
1436
- else {
1437
- const cap = this.defaultBinaryLimitBytes;
1438
- const truncated = bytes.length > cap;
1439
- binary = {
1440
- data: truncated ? bytes.slice(0, cap) : bytes,
1441
- truncated,
1442
- totalBytes: bytes.length,
1443
- limitBytes: cap
1444
- };
1445
- stdoutText = `<binary stdout: ${bytes.length} bytes${truncated ? `, exceeds the ${cap}-byte binary limit` : ""}; raw bytes at /@/commands/${record.id}/stdout.bin>\n`;
1446
- }
1447
- }
1406
+ const stdoutText = foreground ? resultOrError.stdout : decodeBytesToUtf8(unsafeBytesFromLatin1(resultOrError.stdout));
1448
1407
  const stderrText = foreground ? resultOrError.stderr : decodeBytesToUtf8(unsafeBytesFromLatin1(resultOrError.stderr));
1449
1408
  session.accumulator.stdout += stdoutText;
1450
1409
  session.accumulator.stderr += stderrText;
1451
- if (binary) record.binaryStdout = binary;
1452
- if (foreground && !binary) record.outputChunks = [...foreground.outputChunks];
1453
- else if (binary) {
1454
- record.outputChunks = [];
1455
- appendRecordOutput(record, "stdout", stdoutText);
1456
- appendRecordOutput(record, "stderr", stderrText);
1457
- } else if (record.outputChunks.length === 0) {
1410
+ if (foreground) record.outputChunks = [...foreground.outputChunks];
1411
+ else if (record.outputChunks.length === 0) {
1458
1412
  appendRecordOutput(record, "stdout", stdoutText);
1459
1413
  appendRecordOutput(record, "stderr", stderrText);
1460
1414
  }
@@ -1473,12 +1427,13 @@ var BashEnvironment = class {
1473
1427
  record.exitCode = exitCode;
1474
1428
  record.audit = [...session.accumulator.audit];
1475
1429
  record.commandMetadata = [...session.accumulator.commandMetadata];
1430
+ record.assets = [...session.accumulator.assets];
1476
1431
  session.pendingExec = void 0;
1477
1432
  if (session.activeCommandId === record.id) session.activeCommandId = void 0;
1478
- return this.commandStatus(record, input);
1433
+ return this.snapshotCommand(record, input);
1479
1434
  }
1480
1435
  async collectAborted(session, record, foreground, input = {}) {
1481
- if (record.status !== "running") return this.commandStatus(record, input);
1436
+ if (record.status !== "running") return this.snapshotCommand(record, input);
1482
1437
  foreground.abortController.abort();
1483
1438
  foreground.handle.kill("SIGTERM").catch(() => {});
1484
1439
  await flushForegroundSinks(session, foreground);
@@ -1490,10 +1445,10 @@ var BashEnvironment = class {
1490
1445
  session.foreground = void 0;
1491
1446
  session.pendingExec = void 0;
1492
1447
  if (session.activeCommandId === record.id) session.activeCommandId = void 0;
1493
- return this.commandStatus(record, input);
1448
+ return this.snapshotCommand(record, input);
1494
1449
  }
1495
1450
  collectAbortedWithoutForeground(session, record, input = {}) {
1496
- if (record.status !== "running") return this.commandStatus(record, input);
1451
+ if (record.status !== "running") return this.snapshotCommand(record, input);
1497
1452
  session.abortController?.abort();
1498
1453
  session.pendingExec = void 0;
1499
1454
  if (session.activeCommandId === record.id) session.activeCommandId = void 0;
@@ -1505,9 +1460,9 @@ var BashEnvironment = class {
1505
1460
  }
1506
1461
  record.lastOutputAt = Date.now();
1507
1462
  record.status = "aborted";
1508
- return this.commandStatus(record, input);
1463
+ return this.snapshotCommand(record, input);
1509
1464
  }
1510
- commandStatus(record, input = {}) {
1465
+ snapshotCommand(record, input = {}) {
1511
1466
  const foreground = this.shells.get(record.shellId)?.foreground;
1512
1467
  if (record.status === "running" && foreground?.commandId === record.id) {
1513
1468
  record.stdout = foreground.stdoutBuffer;
@@ -1516,9 +1471,9 @@ var BashEnvironment = class {
1516
1471
  record.lastOutputAt = foreground.lastOutputAt;
1517
1472
  }
1518
1473
  const maxOutputBytes = input.maxOutputBytes ?? this.defaultOutputLimitBytes;
1519
- const stdout = streamView(record, "stdout", input.stdoutOffset, maxOutputBytes);
1520
- const stderr = streamView(record, "stderr", input.stderrOffset, maxOutputBytes);
1521
- const output = mergedOutputView(record, input.outputOffset, maxOutputBytes);
1474
+ const stdout = streamArtifact(record, "stdout", input.stdoutOffset, maxOutputBytes);
1475
+ const stderr = streamArtifact(record, "stderr", input.stderrOffset, maxOutputBytes);
1476
+ const output = streamOutputArtifact(record, input.outputOffset, maxOutputBytes);
1522
1477
  const base = {
1523
1478
  shellId: record.shellId,
1524
1479
  commandId: record.id,
@@ -1537,7 +1492,7 @@ var BashEnvironment = class {
1537
1492
  audit: record.audit
1538
1493
  };
1539
1494
  if (record.commandMetadata.length > 0) result.commandMetadata = record.commandMetadata;
1540
- if (record.binaryStdout) result.binaryStdout = record.binaryStdout;
1495
+ if (record.assets.length > 0) result.assets = record.assets;
1541
1496
  return result;
1542
1497
  }
1543
1498
  if (record.status === "aborted") return {
@@ -1549,56 +1504,52 @@ var BashEnvironment = class {
1549
1504
  status: "running"
1550
1505
  };
1551
1506
  }
1552
- async lookupVirtualArtifact(commandStorageId, path) {
1507
+ async lookupVirtualArtifact(scopeId, path) {
1553
1508
  const parts = path.split("/").filter(Boolean);
1554
1509
  if (parts.length === 1 && parts[0] === "@") return virtualDirectory(["commands"]);
1555
- if (parts.length === 2 && parts[0] === "@" && parts[1] === "commands") return virtualDirectory(await this.commandArtifactIds(commandStorageId));
1510
+ if (parts.length === 2 && parts[0] === "@" && parts[1] === "commands") return virtualDirectory(await this.commandArtifactIds(scopeId));
1556
1511
  if (parts.length === 3 && parts[0] === "@" && parts[1] === "commands") {
1557
- const artifact = await this.commandArtifact(commandStorageId, parts[2]);
1558
- if (!artifact) return null;
1559
- const entries = [
1512
+ if (!await this.commandArtifact(scopeId, parts[2])) return null;
1513
+ return virtualDirectory([
1560
1514
  "meta.json",
1561
1515
  "stderr.txt",
1562
1516
  "stdout.txt"
1563
- ];
1564
- if (artifact.stdoutBinary) entries.push("stdout.bin");
1565
- return virtualDirectory(entries);
1517
+ ]);
1566
1518
  }
1567
1519
  if (parts.length !== 4 || parts[0] !== "@" || parts[1] !== "commands") return null;
1568
- const artifact = await this.commandArtifact(commandStorageId, parts[2]);
1520
+ const artifact = await this.commandArtifact(scopeId, parts[2]);
1569
1521
  if (!artifact) return null;
1570
1522
  const fileName = parts[3];
1571
1523
  if (fileName === "stdout.txt") return virtualFile(encodeUtf8(artifact.stdout));
1572
- if (fileName === "stdout.bin" && artifact.stdoutBinary) return virtualFile(base64ToBytes(artifact.stdoutBinary.base64));
1573
1524
  if (fileName === "stderr.txt") return virtualFile(encodeUtf8(artifact.stderr));
1574
1525
  if (fileName === "meta.json") return virtualFile(encodeUtf8(`${JSON.stringify(commandArtifactMeta(artifact), null, 2)}\n`));
1575
1526
  return null;
1576
1527
  }
1577
- async commandArtifactIds(commandStorageId) {
1528
+ async commandArtifactIds(scopeId) {
1578
1529
  const ids = /* @__PURE__ */ new Set();
1579
- for (const record of this.commandsById.values()) if (record.commandStorageId === commandStorageId && !this.artifacts.isReleased(commandStorageId, record.id)) ids.add(record.id);
1580
- const keys = await this.artifacts.storageFor(commandStorageId).list("commands").catch(() => []);
1530
+ for (const record of this.commandsById.values()) if (record.commandScopeId === scopeId && !this.artifacts.isReleased(scopeId, record.id)) ids.add(record.id);
1531
+ const keys = await this.artifacts.storageFor(scopeId).list("commands").catch(() => []);
1581
1532
  for (const key of keys) {
1582
1533
  const match = /^commands\/([^/]+)\/artifact\.json$/.exec(key);
1583
- if (match && !this.artifacts.isReleased(commandStorageId, match[1])) ids.add(match[1]);
1534
+ if (match && !this.artifacts.isReleased(scopeId, match[1])) ids.add(match[1]);
1584
1535
  }
1585
1536
  return [...ids];
1586
1537
  }
1587
- async commandArtifact(commandStorageId, commandId) {
1588
- if (this.artifacts.isReleased(commandStorageId, commandId)) return null;
1538
+ async commandArtifact(scopeId, commandId) {
1539
+ if (this.artifacts.isReleased(scopeId, commandId)) return null;
1589
1540
  const record = this.commandsById.get(commandId);
1590
- if (record?.commandStorageId === commandStorageId) {
1541
+ if (record?.commandScopeId === scopeId) {
1591
1542
  this.syncRunningRecord(record);
1592
- return commandArtifactFromRecord(record);
1543
+ return persistedArtifactFromRecord(record);
1593
1544
  }
1594
- const value = await this.artifacts.storageFor(commandStorageId).readJson(`commands/${commandId}/artifact.json`).catch(() => null);
1595
- return isCommandArtifact(value) ? value : null;
1545
+ const value = await this.artifacts.storageFor(scopeId).readJson(`commands/${commandId}/artifact.json`).catch(() => null);
1546
+ return isPersistedShellCommandArtifact(value) ? value : null;
1596
1547
  }
1597
1548
  persistCommandArtifact(record) {
1598
- const fingerprint = `${record.status}:${record.exitCode ?? ""}:${record.stdout.length}:${record.stderr.length}:${record.binaryStdout?.totalBytes ?? ""}`;
1549
+ const fingerprint = `${record.status}:${record.exitCode ?? ""}:${record.stdout.length}:${record.stderr.length}`;
1599
1550
  if (record.persistedFingerprint === fingerprint) return;
1600
1551
  record.persistedFingerprint = fingerprint;
1601
- this.artifacts.persist(record.commandStorageId, record.id, commandArtifactFromRecord(record));
1552
+ this.artifacts.persist(record.commandScopeId, record.id, persistedArtifactFromRecord(record));
1602
1553
  }
1603
1554
  syncRunningRecord(record) {
1604
1555
  const foreground = this.shells.get(record.shellId)?.foreground;
@@ -1626,16 +1577,11 @@ function createPortableCommands(session) {
1626
1577
  }
1627
1578
  }));
1628
1579
  }
1629
- /** True when the string contains a char > 0xFF, i.e. already-decoded Unicode text. */
1630
- function hasWideChar(value) {
1631
- for (let i = 0; i < value.length; i += 1) if (value.charCodeAt(i) > 255) return true;
1632
- return false;
1633
- }
1634
1580
  function normalizeTimeoutMs(value) {
1635
1581
  if (!Number.isFinite(value) || value < 1 || value > 6e5) throw new Error(`timeoutMs must be between 1 and ${MAX_TIMEOUT_MS}`);
1636
1582
  return Math.floor(value);
1637
1583
  }
1638
- function streamView(record, stream, explicitOffset, maxOutputBytes) {
1584
+ function streamArtifact(record, stream, explicitOffset, maxOutputBytes) {
1639
1585
  const text = stream === "stdout" ? record.stdout : record.stderr;
1640
1586
  const totalBytes = utf8Bytes(text);
1641
1587
  const boundedOffset = clampOffset(explicitOffset ?? (stream === "stdout" ? record.stdoutOffset : record.stderrOffset), totalBytes);
@@ -1655,7 +1601,7 @@ function streamView(record, stream, explicitOffset, maxOutputBytes) {
1655
1601
  truncated
1656
1602
  };
1657
1603
  }
1658
- function mergedOutputView(record, explicitOffset, maxOutputBytes) {
1604
+ function streamOutputArtifact(record, explicitOffset, maxOutputBytes) {
1659
1605
  const totalBytes = record.outputChunks.reduce((total, chunk) => total + chunk.bytes, 0);
1660
1606
  const offset = clampOffset(explicitOffset ?? record.outputOffset, totalBytes);
1661
1607
  const byteLimit = Math.max(0, Math.floor(maxOutputBytes));
@@ -1709,7 +1655,7 @@ function ensureRecordOutputCoverage(record) {
1709
1655
  appendRecordOutput(record, "stdout", record.stdout);
1710
1656
  appendRecordOutput(record, "stderr", record.stderr);
1711
1657
  }
1712
- function commandArtifactFromRecord(record) {
1658
+ function persistedArtifactFromRecord(record) {
1713
1659
  return {
1714
1660
  status: record.status,
1715
1661
  shellId: record.shellId,
@@ -1718,12 +1664,7 @@ function commandArtifactFromRecord(record) {
1718
1664
  lastOutputAt: record.lastOutputAt,
1719
1665
  exitCode: record.exitCode ?? null,
1720
1666
  stdout: record.stdout,
1721
- stderr: record.stderr,
1722
- ...record.binaryStdout ? { stdoutBinary: {
1723
- base64: bytesToBase64(record.binaryStdout.data),
1724
- truncated: record.binaryStdout.truncated,
1725
- totalBytes: record.binaryStdout.totalBytes
1726
- } } : {}
1667
+ stderr: record.stderr
1727
1668
  };
1728
1669
  }
1729
1670
  function commandArtifactMeta(artifact) {
@@ -1745,15 +1686,10 @@ function commandArtifactMeta(artifact) {
1745
1686
  stderr: {
1746
1687
  path: stderrPath,
1747
1688
  bytes: utf8Bytes(artifact.stderr)
1748
- },
1749
- ...artifact.stdoutBinary ? { stdoutBinary: {
1750
- path: `/@/commands/${artifact.commandId}/stdout.bin`,
1751
- bytes: artifact.stdoutBinary.totalBytes,
1752
- truncated: artifact.stdoutBinary.truncated
1753
- } } : {}
1689
+ }
1754
1690
  };
1755
1691
  }
1756
- function isCommandArtifact(value) {
1692
+ function isPersistedShellCommandArtifact(value) {
1757
1693
  if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
1758
1694
  const record = value;
1759
1695
  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";
@@ -1785,4 +1721,4 @@ function heredocDelimiter(body) {
1785
1721
  return delimiter;
1786
1722
  }
1787
1723
  //#endregion
1788
- 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 };
1724
+ export { AgentSessionCommandStorage, BashEnvironment, COMMAND_PROMPT_DEFAULTS, CommandRegistry, DEMI_PORTABLE_COMMANDS, HostBackedFileSystem, MAX_TIMEOUT_MS, RESERVED_COMMAND_NAMES, heredocDelimiter, parseCommandInput, renderCommandPrompt, runRegisteredCommand, shellQuote, virtualDirectory, virtualFile };
@@ -1,5 +1,6 @@
1
- import { t as Host, u as HostStore } from "./host-DIg4RxcL.mjs";
1
+ import { u as HostStore } from "./host-DIg4RxcL.mjs";
2
2
  import { z } from "zod";
3
+
3
4
  //#region src/command.d.ts
4
5
  type CommandInputSpec = Record<string, z.ZodType>;
5
6
  interface CommandOutputSpec {
@@ -26,7 +27,7 @@ interface Command {
26
27
  positionals?: string[];
27
28
  stdinField?: string;
28
29
  output?: CommandOutputSpec;
29
- /** Required when `run` is set; an empty array renders no examples section. */
30
+ /** Required when `run` is set (may be empty for fixtures). */
30
31
  examples?: string[];
31
32
  }
32
33
  interface ParsedCommandInput {
@@ -35,7 +36,7 @@ interface ParsedCommandInput {
35
36
  * For help: path of the node help was requested for.
36
37
  */
37
38
  path: string[];
38
- /** True when the invocation requested `--help`. */
39
+ /** True when the invocation was `<path…> prompt`. */
39
40
  help: boolean;
40
41
  values: Record<string, unknown>;
41
42
  json: boolean;
@@ -48,23 +49,24 @@ interface CommandRunContext {
48
49
  cwd: string;
49
50
  io: CommandIO;
50
51
  storage: CommandStorage;
51
- /** Host of the BashEnvironment executing this command. */
52
- host: Host;
53
52
  }
54
53
  interface CommandRunResult {
55
54
  exitCode: number;
56
55
  metadata?: unknown;
57
56
  }
58
57
  interface CommandStdin {
59
- /** Stdin decoded as UTF-8 text (lossy for non-text input). */
60
58
  text: string;
61
- /** Raw stdin bytes, byte-identical to what the pipe delivered. */
62
- bytes: Uint8Array;
63
59
  }
64
- declare function emptyStdin(): CommandStdin;
60
+ /** A non-text content item a command emits to the model, peer to stdout text. */
61
+ type CommandAsset = {
62
+ type: 'image';
63
+ mediaType: string;
64
+ data: string;
65
+ };
65
66
  interface CommandIO {
66
67
  stdout(data: string | Uint8Array): Promise<void> | void;
67
68
  stderr(data: string | Uint8Array): Promise<void> | void;
69
+ asset(asset: CommandAsset): Promise<void> | void;
68
70
  }
69
71
  interface CommandStorage {
70
72
  readJson<T>(key: string): Promise<T | null>;
@@ -79,19 +81,18 @@ interface CommandExecutionContext {
79
81
  cwd: string;
80
82
  io: CommandIO;
81
83
  storage: CommandStorage;
82
- host: Host;
83
84
  }
84
85
  declare class CommandRegistry {
85
86
  private readonly commands;
86
87
  register(command: Command): void;
87
88
  get(name: string): Command | null;
88
89
  list(): Command[];
89
- renderHelp(): string;
90
+ renderPrompt(): string;
90
91
  }
91
- declare const COMMAND_HELP_DEFAULTS = "Unless a command states otherwise: success prints raw text on stdout, failure writes an error message to stderr and exits non-zero. Pass --help at any level to print a command's documentation.";
92
+ declare const COMMAND_PROMPT_DEFAULTS = "Unless a command states otherwise: success prints raw text on stdout, failure writes an error message to stderr and exits non-zero.";
92
93
  declare function parseCommandInput(root: Command, argv: string[], stdin?: CommandStdin): ParsedCommandInput;
93
94
  declare function runRegisteredCommand(root: Command, ctx: CommandExecutionContext): Promise<CommandRunResult>;
94
- declare function renderCommandHelp(command: Command, parentPath?: string): string;
95
+ declare function renderCommandPrompt(command: Command, parentPath?: string): string;
95
96
  //#endregion
96
97
  //#region src/storage.d.ts
97
98
  declare class AgentSessionCommandStorage implements CommandStorage {
@@ -105,4 +106,4 @@ declare class AgentSessionCommandStorage implements CommandStorage {
105
106
  private key;
106
107
  }
107
108
  //#endregion
108
- export { runRegisteredCommand as _, CommandIO as a, CommandRegistry as c, CommandStdin as d, CommandStorage as f, renderCommandHelp as g, parseCommandInput as h, CommandExecutionContext as i, CommandRunContext as l, emptyStdin as m, COMMAND_HELP_DEFAULTS as n, CommandInputSpec as o, ParsedCommandInput as p, Command as r, CommandOutputSpec as s, AgentSessionCommandStorage as t, CommandRunResult as u };
109
+ export { runRegisteredCommand as _, CommandExecutionContext as a, CommandOutputSpec as c, CommandRunResult as d, CommandStdin as f, renderCommandPrompt as g, parseCommandInput as h, CommandAsset as i, CommandRegistry as l, ParsedCommandInput as m, COMMAND_PROMPT_DEFAULTS as n, CommandIO as o, CommandStorage as p, Command as r, CommandInputSpec as s, AgentSessionCommandStorage as t, CommandRunContext as u };
@@ -1,2 +1,2 @@
1
- import { t as AgentSessionCommandStorage } from "./storage-DB9hjZ2G.mjs";
1
+ import { t as AgentSessionCommandStorage } from "./storage-D7EhVuLY.mjs";
2
2
  export { AgentSessionCommandStorage };
package/dist/storage.mjs CHANGED
@@ -5,7 +5,7 @@ var AgentSessionCommandStorage = class {
5
5
  constructor(store, agentSessionId) {
6
6
  this.store = store;
7
7
  validateAgentSessionId(agentSessionId);
8
- this.agentSessionPrefix = `agent-sessions/${agentSessionId}/`;
8
+ this.agentSessionPrefix = `${agentSessionId}/`;
9
9
  }
10
10
  readJson(key) {
11
11
  return this.store.readJson(this.key(key));
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.10.2",
4
+ "version": "0.10.3",
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.10.2",
22
+ "@demicodes/utils": "^0.10.3",
23
+ "@demicodes/just-bash": "^3.1.0-demi.2",
24
24
  "zod": "^4.0.0"
25
25
  },
26
26
  "license": "Apache-2.0",