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