loom-agent 1.3.2 → 1.5.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,4 +1,9 @@
1
- // Docker sandbox — a lightweight, isolated OS for the agent's shell commands.
1
+ // Docker sandbox — a lightweight, isolated Linux OS the agent can use ON
2
+ // DEMAND. It is NOT a routing layer: host commands (bash tool) keep running
3
+ // on the user's machine exactly as before. When the agent needs isolation
4
+ // (user asked for the sandbox, a risky command, a Linux-only tool), it calls
5
+ // the dedicated `sandbox` tool, which runs that ONE command via `docker exec`
6
+ // into a shared idle-running container.
2
7
  //
3
8
  // Flow (guided in the TUI via the /sandbox modal):
4
9
  // 1. Step 1 checks Docker. If missing, the modal shows the download link
@@ -8,13 +13,12 @@
8
13
  // node images, ~250–900 MB). Step 3 pulls it, creates the single
9
14
  // GLOBAL loom-sandbox container with the PROJECT mounted at /workspace,
10
15
  // installs essentials (+ headless chromium when offered, best-effort)
11
- // and keeps the container idle-running.
12
- // 3. With sandbox.enabled, the bash tool routes every command through
13
- // `docker exec` into that container instead of the host shell. File
16
+ // and keeps the container idle-running in the background.
17
+ // 3. With sandbox.enabled, the sandbox TOOL becomes available. Each call
18
+ // revives the container if it stopped (e.g. after a Docker restart) and
19
+ // re-mounts the current project, then runs just that command. File
14
20
  // tools stay on the host — the mount is the same directory, so reads
15
- // and writes are shared while side effects of commands stay contained.
16
- // ensureProjectMount() re-creates the container when the active project
17
- // changes, so every session/agent shares one sandbox.
21
+ // and writes are shared while side effects of the command stay contained.
18
22
  //
19
23
  // Security posture: no --privileged, no host network, only the project dir
20
24
  // is mounted (rw), container runs as the image's default non-root user when
@@ -70,6 +74,28 @@ function detectDocker(runner) {
70
74
  return { state: 'ready', version };
71
75
  }
72
76
 
77
+ /**
78
+ * NON-BLOCKING Docker detection (spawn-based twin of detectDocker). The TUI
79
+ * must never call the sync version — `docker version` can hang 10s when the
80
+ * daemon is starting, which froze the whole interface. Injectable async
81
+ * runner for tests: (cmd, args) => Promise<{code, out}>.
82
+ * @param {(cmd: string, args: string[]) => Promise<{ code: number, out: string }>} [runner]
83
+ * @returns {Promise<{ state: DockerState, version: string }>}
84
+ */
85
+ async function detectDockerAsync(runner) {
86
+ const run = runner || ((/** @type {string} */ cmd, /** @type {string[]} */ args) =>
87
+ spawnAsync(cmd, args, { timeoutMs: 10000 }));
88
+ const server = await run('docker', ['version', '--format', '{{.Server.Version}}']);
89
+ if (server.code === 0) {
90
+ const version = String(server.out || '').trim();
91
+ if (version) return { state: 'ready', version };
92
+ return { state: 'no_daemon', version: '' };
93
+ }
94
+ const cli = await run('docker', ['--version']);
95
+ if (cli.code === 0) return { state: 'no_daemon', version: '' };
96
+ return { state: 'not_installed', version: '' };
97
+ }
98
+
73
99
  /**
74
100
  * Platform-appropriate Docker install guide shown before the user leaves to
75
101
  * install. Mentions the restart when the installer requires one, and the
@@ -161,6 +187,41 @@ function sandboxStatus() {
161
187
  };
162
188
  }
163
189
 
190
+ /**
191
+ * NON-BLOCKING twin of sandboxStatus (spawn-based docker checks). Use this
192
+ * from the TUI — the sync version freezes the interface while docker answers.
193
+ * @returns {Promise<ReturnType<typeof sandboxStatus>>}
194
+ */
195
+ async function sandboxStatusAsync() {
196
+ const cfg = loadConfig();
197
+ const sandboxCfg = cfg.sandbox || {};
198
+ const { state, version } = await detectDockerAsync();
199
+ let container = /** @type {'running'|'stopped'|'absent'} */ ('absent');
200
+ if (state === 'ready') {
201
+ const insp = await spawnAsync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], { timeoutMs: 10000 });
202
+ if (insp.code === 0) container = insp.out.trim() === 'true' ? 'running' : 'stopped';
203
+ }
204
+ return {
205
+ docker: state,
206
+ dockerVersion: version,
207
+ enabled: !!sandboxCfg.enabled && container === 'running',
208
+ pending: !!sandboxCfg.pending,
209
+ container,
210
+ image: sandboxCfg.image || DEFAULT_IMAGE,
211
+ osId: sandboxCfg.osId || 'alpine',
212
+ projectRoot: sandboxCfg.projectRoot || '',
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Config-only pending check (no docker involved — zero cost, never blocks).
218
+ * @returns {boolean}
219
+ */
220
+ function sandboxPending() {
221
+ const cfg = loadConfig();
222
+ return !!(cfg.sandbox && cfg.sandbox.pending);
223
+ }
224
+
164
225
  /**
165
226
  * Map a host working directory to its path inside the container. The project
166
227
  * root is mounted at /workspace; subdirectories map underneath it. Anything
@@ -179,20 +240,22 @@ function toSandboxPath(hostDir, projectRoot) {
179
240
  }
180
241
 
181
242
  /**
182
- * Run a shell command inside the sandbox container. Streams output via
183
- * onChunk('out'|'err') while running; resolves with { output, code } or
184
- * { error }.
243
+ * Run ONE shell command inside the sandbox container (used by the sandbox
244
+ * tool — host commands stay on the bash tool). Revives the container if it
245
+ * stopped and re-mounts the current project, then streams output via
246
+ * onChunk('out'|'err'); resolves with { output, code } or { error }.
185
247
  * @param {string} command
186
248
  * @param {{ workdir?: string, projectRoot?: string, timeoutMs?: number, onChunk?: (text: string, stream: 'out'|'err') => void }} [opts]
187
249
  * @returns {Promise<{ output: string, code: number }|{ error: string }>}
188
250
  */
189
- function runInSandbox(command, opts) {
251
+ async function runInSandbox(command, opts) {
190
252
  const o = opts || {};
191
- // Shared container follows the active project (no-op when unchanged).
192
- const remount = ensureProjectMount(o.projectRoot);
193
- if (!remount.ok && remount.error !== 'sandbox disabled') {
194
- return Promise.resolve({ error: 'sandbox re-mount failed: ' + remount.error });
195
- }
253
+ // Bring the container back if the daemon restarted, then make sure it
254
+ // mounts the CURRENT project (cheap no-op when both already match).
255
+ const alive = await ensureContainerAlive();
256
+ if (!alive.ok) return { error: alive.error };
257
+ const remount = await ensureProjectMount(o.projectRoot);
258
+ if (!remount.ok) return { error: 'sandbox re-mount failed: ' + remount.error };
196
259
  const wd = toSandboxPath(o.workdir, o.projectRoot);
197
260
  const timeoutMs = o.timeoutMs || 120000;
198
261
  return new Promise((resolve) => {
@@ -234,21 +297,115 @@ function runInSandbox(command, opts) {
234
297
  });
235
298
  }
236
299
 
300
+ /**
301
+ * Spawn a host command, line-buffer its output, never block the event loop.
302
+ * Used by setupSandbox so the TUI keeps rendering (and the progress UI keeps
303
+ * updating) while docker pull / package installs run.
304
+ * @param {string} cmd
305
+ * @param {string[]} args
306
+ * @param {{ timeoutMs?: number, onLine?: (line: string, stream: 'out'|'err') => void }} [opts]
307
+ * @returns {Promise<{ code: number, out: string }>}
308
+ */
309
+ function spawnAsync(cmd, args, opts) {
310
+ const o = opts || {};
311
+ return new Promise((resolve) => {
312
+ let child;
313
+ try {
314
+ child = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
315
+ } catch (e) {
316
+ resolve({ code: -1, out: (e && e.message) || String(e) });
317
+ return;
318
+ }
319
+ const timer = o.timeoutMs
320
+ ? setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, o.timeoutMs)
321
+ : null;
322
+ let out = '';
323
+ const MAX_OUT = 4 * 1024 * 1024;
324
+ const feed = (/** @type {Buffer} */ d, /** @type {'out'|'err'} */ stream) => {
325
+ const t = d.toString();
326
+ if (out.length < MAX_OUT) out += t;
327
+ if (!o.onLine) return;
328
+ // Line-buffer: docker writes progress with \r on TTYs and \n otherwise;
329
+ // treat both as separators so the UI gets fresh rows, not one blob.
330
+ const lines = t.split(/\r?\n/);
331
+ for (const line of lines) {
332
+ if (!line) continue;
333
+ try { o.onLine(line.replace(/\x1b\[[0-9;]*m/g, ''), stream); } catch {}
334
+ }
335
+ };
336
+ child.stdout.on('data', (d) => feed(d, 'out'));
337
+ child.stderr.on('data', (d) => feed(d, 'err'));
338
+ child.on('error', (err) => {
339
+ if (timer) clearTimeout(timer);
340
+ resolve({ code: -1, out: (out ? out + '\n' : '') + err.message });
341
+ });
342
+ child.on('close', (code) => {
343
+ if (timer) clearTimeout(timer);
344
+ resolve({ code: code == null ? 1 : code, out });
345
+ });
346
+ });
347
+ }
348
+
349
+ // "12.34MB" / "951kB" / "1.2GB" → bytes (docker prints SI-style units).
350
+ function parseBytes(str, unit) {
351
+ const n = parseFloat(str);
352
+ if (!isFinite(n)) return 0;
353
+ const u = String(unit || '').toLowerCase();
354
+ if (u === 'gb') return n * 1e9;
355
+ if (u === 'mb') return n * 1e6;
356
+ if (u === 'kb') return n * 1e3;
357
+ return n;
358
+ }
359
+
360
+ /**
361
+ * Fold one docker-pull stdout line into the per-layer progress map. Layers
362
+ * report "Downloading [bar] X/Y", "Extracting [bar] X/Y", then "Pull complete".
363
+ * @param {string} line
364
+ * @param {Map<string, number>} layers layer-id → fraction 0..1
365
+ */
366
+ function trackPullLine(line, layers) {
367
+ const done = /^([0-9a-f]{8,64}):\s+(?:Download complete|Pull complete)\s*$/i.exec(line);
368
+ if (done) { layers.set(done[1], 1); return; }
369
+ // "Waiting" = queued layer, no bytes yet.
370
+ const waiting = /^([0-9a-f]{8,64}):\s+Waiting\s*$/i.exec(line);
371
+ if (waiting) { layers.set(waiting[1], 0); return; }
372
+ const active = /^([0-9a-f]{8,64}):\s+(?:Downloading|Extracting)\s+(?:\[[^\]]*\]\s+)?([\d.]+)([kmgt]?b)(?:\/([\d.]+)([kmgt]?b))?/i.exec(line);
373
+ if (active) {
374
+ const id = active[1];
375
+ if (active[4]) {
376
+ const total = parseBytes(active[4], active[5]);
377
+ const cur = parseBytes(active[2], active[3]);
378
+ if (total > 0) layers.set(id, Math.min(1, cur / total));
379
+ } else if (!layers.has(id)) {
380
+ layers.set(id, 0);
381
+ }
382
+ }
383
+ }
384
+
385
+ function pullPercent(layers) {
386
+ if (!layers.size) return null;
387
+ let sum = 0;
388
+ for (const frac of layers.values()) sum += frac;
389
+ return Math.min(99, Math.round((sum / layers.size) * 100));
390
+ }
391
+
237
392
  /**
238
393
  * Build the sandbox: pull the chosen OS image, (re)create the container with
239
394
  * the project mounted, install essentials (+ headless chromium when the OS
240
- * offers it). Idempotent — re-running refreshes. The container is a single
241
- * GLOBAL resource (~/.loom/config.json records it), so every session and
395
+ * offers it). Fully ASYNC — every docker call streams, so the TUI never
396
+ * freezes and onProgress/onLog deliver live updates. The container is a
397
+ * single GLOBAL resource (~/.loom/config.json records it), so every session and
242
398
  * every agent shares the same sandbox; only the mounted project follows the
243
399
  * working directory via ensureProjectMount().
244
- * @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void }} [opts]
400
+ * @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void, onProgress?: (p: { stage: string, pct: number|null, step: number, total: number }) => void }} [opts]
245
401
  * @returns {Promise<{ ok: true, container: string }|{ ok: false, error: string }>}
246
402
  */
247
403
  async function setupSandbox(opts) {
248
404
  const o = opts || {};
249
405
  const root = path.resolve(o.projectRoot || process.cwd());
250
406
  const log = o.onLog || (() => {});
251
- const { state, version } = detectDocker();
407
+ const prog = o.onProgress || (() => {});
408
+ const { state, version } = await detectDockerAsync();
252
409
  if (state === 'not_installed') return { ok: false, error: 'Docker is not installed. Run /sandbox guide for setup steps.' };
253
410
  if (state === 'no_daemon') {
254
411
  return { ok: false, error: 'Docker is installed but the daemon is not running. Start Docker Desktop (or `sudo systemctl start docker`), then run /sandbox setup again.' };
@@ -256,77 +413,124 @@ async function setupSandbox(opts) {
256
413
  const osEntry = OS_CATALOG.find((x) => x.id === o.osId) || OS_CATALOG[0];
257
414
  const image = osEntry.image;
258
415
  log('Docker ' + version + ' detected — using ' + osEntry.label + ' (' + image + ').');
259
- try {
260
- log('Pulling ' + image + ' (first run only)…');
261
- execFileSync('docker', ['pull', image], { encoding: 'utf8', windowsHide: true, timeout: 600000, stdio: ['ignore', 'pipe', 'pipe'] });
262
- // Recreate from scratch so image updates + mount changes always apply.
263
- try { execFileSync('docker', ['rm', '-f', CONTAINER], { windowsHide: true, timeout: 30000, stdio: 'ignore' }); } catch {}
264
- log('Creating container ' + CONTAINER + ' with ' + root + ' mounted at /workspace…');
265
- execFileSync('docker', [
266
- 'create',
267
- '--name', CONTAINER,
268
- // Isolation: project dir only, no host network, no extra privileges.
269
- '-v', root + ':/workspace',
270
- '-w', '/workspace',
271
- // Idle-forever main process; every agent command is a docker exec.
272
- image, 'sleep', 'infinity',
273
- ], { encoding: 'utf8', windowsHide: true, timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] });
274
- execFileSync('docker', ['start', CONTAINER], { windowsHide: true, timeout: 60000, stdio: 'ignore' });
275
- const pkgCmd = osEntry.pkg === 'apk'
276
- ? 'apk add --no-cache ' + BASE_PACKAGES
277
- : 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends ' + BASE_PACKAGES + ' && rm -rf /var/lib/apt/lists/*';
278
- log('Installing essentials (' + BASE_PACKAGES + ')…');
279
- execFileSync('docker', ['exec', CONTAINER, 'sh', '-lc', pkgCmd],
280
- { encoding: 'utf8', windowsHide: true, timeout: 600000, stdio: ['ignore', 'pipe', 'pipe'] });
281
- // Headless browser, when the chosen OS offers one — best-effort so a
282
- // missing chromium package never fails the whole setup.
283
- if (osEntry.browser) {
284
- const browserCmd = osEntry.pkg === 'apk'
285
- ? 'apk add --no-cache chromium'
286
- : 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium && rm -rf /var/lib/apt/lists/*';
287
- log('Installing a headless Chromium (best-effort)…');
288
- try {
289
- execFileSync('docker', ['exec', CONTAINER, 'sh', '-lc', browserCmd],
290
- { encoding: 'utf8', windowsHide: true, timeout: 600000, stdio: ['ignore', 'pipe', 'pipe'] });
291
- } catch {
292
- log('Chromium unavailable in this image — browser tooling will be limited (sandbox continues).');
416
+ // Stages reported to onProgress: pull → create → start → essentials → browser → done.
417
+ const stages = ['pull', 'create', 'start', 'essentials'].concat(osEntry.browser ? ['browser'] : [], ['done']);
418
+ const stageIdx = (/** @type {string} */ s) => { const i = stages.indexOf(s); return i < 0 ? 0 : i; };
419
+ prog({ stage: 'pull', pct: 0, step: stageIdx('pull'), total: stages.length });
420
+
421
+ // ── Stage 1: pull the image (real per-layer progress, async + streamed) ──
422
+ log('Pulling ' + image + ' (first run only)…');
423
+ const layers = new Map();
424
+ let lastPct = -1;
425
+ const pull = await spawnAsync('docker', ['pull', image], {
426
+ timeoutMs: 600000,
427
+ onLine: (line) => {
428
+ trackPullLine(line, layers);
429
+ const pct = pullPercent(layers);
430
+ if (pct != null && pct !== lastPct) {
431
+ lastPct = pct;
432
+ prog({ stage: 'pull', pct, step: stageIdx('pull'), total: stages.length });
293
433
  }
294
- }
295
- const cfg = loadConfig();
296
- cfg.sandbox = Object.assign({}, cfg.sandbox, {
297
- enabled: true, pending: false, image, osId: osEntry.id, projectRoot: root,
434
+ if (/^status:|^digest:|up to date/i.test(line)) log(line);
435
+ },
436
+ });
437
+ if (pull.code !== 0) {
438
+ return { ok: false, error: 'docker pull failed (exit ' + pull.code + '): ' + pull.out.slice(-400) };
439
+ }
440
+ prog({ stage: 'pull', pct: 100, step: stageIdx('pull'), total: stages.length });
441
+
442
+ // ── Stage 2: recreate the container from scratch (image updates + mount) ──
443
+ prog({ stage: 'create', pct: null, step: stageIdx('create'), total: stages.length });
444
+ await spawnAsync('docker', ['rm', '-f', CONTAINER], { timeoutMs: 30000 });
445
+ log('Creating container ' + CONTAINER + ' with ' + root + ' mounted at /workspace…');
446
+ const create = await spawnAsync('docker', [
447
+ 'create',
448
+ '--name', CONTAINER,
449
+ // Isolation: project dir only, no host network, no extra privileges.
450
+ '-v', root + ':/workspace',
451
+ '-w', '/workspace',
452
+ // Idle-forever main process; every agent command is a docker exec.
453
+ image, 'sleep', 'infinity',
454
+ ], { timeoutMs: 60000 });
455
+ if (create.code !== 0) {
456
+ return { ok: false, error: 'docker create failed (exit ' + create.code + '): ' + create.out.slice(-300) };
457
+ }
458
+
459
+ // ── Stage 3: start it (stays idle in the background until the sandbox
460
+ // tool is explicitly invoked) ──
461
+ prog({ stage: 'start', pct: null, step: stageIdx('start'), total: stages.length });
462
+ const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
463
+ if (start.code !== 0) {
464
+ return { ok: false, error: 'docker start failed (exit ' + start.code + '): ' + start.out.slice(-300) };
465
+ }
466
+
467
+ // ── Stage 4: install essentials (streamed so the UI shows life) ──
468
+ prog({ stage: 'essentials', pct: null, step: stageIdx('essentials'), total: stages.length });
469
+ const pkgCmd = osEntry.pkg === 'apk'
470
+ ? 'apk add --no-cache ' + BASE_PACKAGES
471
+ : 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends ' + BASE_PACKAGES + ' && rm -rf /var/lib/apt/lists/*';
472
+ log('Installing essentials (' + BASE_PACKAGES + ')…');
473
+ const install = await spawnAsync('docker', ['exec', CONTAINER, 'sh', '-lc', pkgCmd], {
474
+ timeoutMs: 600000,
475
+ onLine: (line) => { if (!/^\s*$/.test(line)) log(line); },
476
+ });
477
+ if (install.code !== 0) {
478
+ return { ok: false, error: 'package install failed (exit ' + install.code + '): ' + install.out.slice(-400) };
479
+ }
480
+
481
+ // ── Stage 5: headless browser when the OS offers one — best-effort, so a
482
+ // missing chromium package never fails the whole setup. ──
483
+ if (osEntry.browser) {
484
+ prog({ stage: 'browser', pct: null, step: stageIdx('browser'), total: stages.length });
485
+ const browserCmd = osEntry.pkg === 'apk'
486
+ ? 'apk add --no-cache chromium'
487
+ : 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium && rm -rf /var/lib/apt/lists/*';
488
+ log('Installing a headless Chromium (best-effort)…');
489
+ const browser = await spawnAsync('docker', ['exec', CONTAINER, 'sh', '-lc', browserCmd], {
490
+ timeoutMs: 600000,
491
+ onLine: (line) => { if (!/^\s*$/.test(line)) log(line); },
298
492
  });
299
- saveConfig(cfg);
300
- log('Sandbox ready' + (osEntry.browser ? ' — including a headless Chromium for the browser tool' : '') + '.');
301
- log('Agent commands now run inside ' + CONTAINER + ' (/workspace = this project). Every session and agent shares it.');
302
- return { ok: true, container: CONTAINER };
303
- } catch (e) {
304
- return { ok: false, error: 'Sandbox setup failed: ' + (e && e.message ? e.message : String(e)) };
493
+ if (browser.code !== 0) {
494
+ log('Chromium unavailable in this image — browser tooling will be limited (sandbox continues).');
495
+ }
305
496
  }
497
+
498
+ prog({ stage: 'done', pct: 100, step: stageIdx('done'), total: stages.length });
499
+ const cfg = loadConfig();
500
+ cfg.sandbox = Object.assign({}, cfg.sandbox, {
501
+ enabled: true, pending: false, image, osId: osEntry.id, projectRoot: root,
502
+ });
503
+ saveConfig(cfg);
504
+ log('Sandbox ready' + (osEntry.browser ? ' — including a headless Chromium for the browser tool' : '') + '.');
505
+ log('Container ' + CONTAINER + ' idles in the background; the sandbox tool uses it only when the agent needs a Linux environment.');
506
+ return { ok: true, container: CONTAINER };
306
507
  }
307
508
 
308
509
  /**
309
510
  * Make sure the shared container mounts the CURRENT project. Sessions in
310
511
  * different directories share one sandbox — when the active project changes
311
512
  * we just re-create the container with the new mount (image stays cached).
312
- * Cheap no-op when the mount already matches.
513
+ * Cheap no-op when the mount already matches. Fully async (never blocks the
514
+ * TUI event loop).
313
515
  * @param {string} [projectRoot]
314
- * @returns {{ ok: true }|{ ok: false, error: string }}
516
+ * @returns {Promise<{ ok: true }|{ ok: false, error: string }>}
315
517
  */
316
- function ensureProjectMount(projectRoot) {
518
+ async function ensureProjectMount(projectRoot) {
317
519
  const root = path.resolve(projectRoot || process.cwd());
318
520
  const cfg = loadConfig();
319
521
  if (!cfg.sandbox || !cfg.sandbox.enabled) return { ok: false, error: 'sandbox disabled' };
320
522
  if (cfg.sandbox.projectRoot === root) return { ok: true };
321
523
  const image = cfg.sandbox.image || DEFAULT_IMAGE;
322
524
  try {
323
- try { execFileSync('docker', ['rm', '-f', CONTAINER], { windowsHide: true, timeout: 30000, stdio: 'ignore' }); } catch {}
324
- execFileSync('docker', [
525
+ await spawnAsync('docker', ['rm', '-f', CONTAINER], { timeoutMs: 30000 });
526
+ const create = await spawnAsync('docker', [
325
527
  'create', '--name', CONTAINER,
326
528
  '-v', root + ':/workspace', '-w', '/workspace',
327
529
  image, 'sleep', 'infinity',
328
- ], { encoding: 'utf8', windowsHide: true, timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] });
329
- execFileSync('docker', ['start', CONTAINER], { windowsHide: true, timeout: 60000, stdio: 'ignore' });
530
+ ], { timeoutMs: 60000 });
531
+ if (create.code !== 0) return { ok: false, error: 're-mount create failed: ' + create.out.slice(-200) };
532
+ const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
533
+ if (start.code !== 0) return { ok: false, error: 're-mount start failed: ' + start.out.slice(-200) };
330
534
  cfg.sandbox.projectRoot = root;
331
535
  saveConfig(cfg);
332
536
  return { ok: true };
@@ -336,9 +540,35 @@ function ensureProjectMount(projectRoot) {
336
540
  }
337
541
 
338
542
  /**
339
- * Should the bash tool route this command through the sandbox? Cheap config
340
- * check — container liveness is verified at setup time, and exec errors
341
- * surface per command.
543
+ * Make sure the container exists and is RUNNING before a sandbox-tool call.
544
+ * Revives a stopped container (Docker Desktop restart, reboot) with a quick
545
+ * `docker start`; never recreates (image/mount are setup-time concerns).
546
+ * @returns {Promise<{ ok: true }|{ ok: false, error: string }>}
547
+ */
548
+ async function ensureContainerAlive() {
549
+ const cfg = loadConfig();
550
+ if (!cfg.sandbox || !cfg.sandbox.enabled) {
551
+ return { ok: false, error: 'Sandbox is not set up. Run /sandbox (or /sandbox setup) first — it only takes a minute and is needed once.' };
552
+ }
553
+ if (process.env.LOOM_SANDBOX === '0') {
554
+ return { ok: false, error: 'Sandbox disabled via LOOM_SANDBOX=0.' };
555
+ }
556
+ const inspect = await spawnAsync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], { timeoutMs: 15000 });
557
+ if (inspect.code !== 0 || !inspect.out.trim()) {
558
+ return { ok: false, error: 'Sandbox container is missing (deleted?). Run /sandbox setup again.' };
559
+ }
560
+ if (inspect.out.trim() === 'true') return { ok: true };
561
+ const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
562
+ if (start.code !== 0) {
563
+ return { ok: false, error: 'Sandbox container exists but could not be started: ' + start.out.slice(-200) + '. If Docker was restarted, start it and try again.' };
564
+ }
565
+ return { ok: true };
566
+ }
567
+
568
+ /**
569
+ * Is the sandbox TOOL available (set up and not kill-switched)? Used by the
570
+ * sandbox tool to decide between running and a helpful "run /sandbox first"
571
+ * error — NOT to route ordinary bash commands (those stay on the host).
342
572
  * @returns {boolean}
343
573
  */
344
574
  function shouldUseSandbox() {
@@ -348,7 +578,7 @@ function shouldUseSandbox() {
348
578
  }
349
579
 
350
580
  /**
351
- * Toggle sandbox command routing (container keeps running either way).
581
+ * Toggle sandbox availability (the container idles either way).
352
582
  * @param {boolean} on
353
583
  */
354
584
  function setSandboxEnabled(on) {
@@ -385,15 +615,21 @@ module.exports = {
385
615
  DEFAULT_IMAGE,
386
616
  OS_CATALOG,
387
617
  detectDocker,
618
+ detectDockerAsync,
388
619
  installGuide,
389
620
  installLink,
390
621
  openInstallPage,
391
622
  sandboxStatus,
623
+ sandboxStatusAsync,
624
+ sandboxPending,
392
625
  toSandboxPath,
393
626
  runInSandbox,
394
627
  setupSandbox,
395
628
  ensureProjectMount,
629
+ ensureContainerAlive,
396
630
  shouldUseSandbox,
631
+ trackPullLine,
632
+ pullPercent,
397
633
  setSandboxEnabled,
398
634
  markInstallPending,
399
635
  isReadySignal,