loom-agent 1.3.2 → 1.3.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.
package/CHANGELOG.md CHANGED
@@ -29,16 +29,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
29
29
  modal: (1) Docker check — if missing, shows the platform install link with
30
30
  an "open download page" action and remembers the pending install; (2) OS
31
31
  pick — Alpine (~250 MB, apk), Debian Slim (~600 MB, apt) or Ubuntu
32
- (~900 MB, apt); (3) live build with streamed logs. Setup pulls the chosen
33
- `node:20-*` image, creates the single shared `loom-sandbox` container with
34
- the project mounted at `/workspace`, installs essentials (git, bash, curl,
35
- ripgrep, coreutils) plus a headless Chromium where offered (best-effort).
36
- The container is global — every session and every agent routes commands
37
- through it — and `ensureProjectMount()` re-creates it cheaply when the
38
- active project changes. Isolation: project dir only, no host network, no
39
- `--privileged`. The bash tool routes through `docker exec` when enabled;
40
- `LOOM_SANDBOX=0` is a hard kill-switch. Saying "docker is ready" after
41
- installing reopens the wizard at the OS step.
32
+ (~900 MB, apt); (3) live build with a real progress UI: per-stage
33
+ checklist (pull → create → start → essentials → browser → ready), a
34
+ percentage bar driven by actual docker-pull layer bytes, and streamed
35
+ install logs. Setup is fully asynchronous — the TUI never freezes — and
36
+ the wizard can be esc'd while setup continues in the background. Setup
37
+ pulls the chosen `node:20-*` image, creates the single shared
38
+ `loom-sandbox` container with the project mounted at `/workspace`,
39
+ installs essentials (git, bash, curl, ripgrep, coreutils) plus a headless
40
+ Chromium where offered (best-effort). Isolation: project dir only, no host
41
+ network, no `--privileged`. The container idles in the background; the
42
+ dedicated **`sandbox` tool** runs one command inside it only when the
43
+ agent decides isolation is needed (see Changed). `LOOM_SANDBOX=0` is a
44
+ hard kill-switch. Saying "docker is ready" after installing reopens the
45
+ wizard at the OS step.
42
46
  - **Drive-to-completion harness (`src/core/session.js`).** When the model ends
43
47
  a turn with todos still pending/in_progress (the "stops between tasks"
44
48
  failure), the loop injects a continuation listing the unfinished items — up
@@ -82,6 +86,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
82
86
  todos are surfaced to the UI whenever the guard engaged.
83
87
 
84
88
  ### Changed
89
+ - **Sandbox is now opt-in per command, not a global router.** Enabling the
90
+ sandbox no longer hijacks the bash tool — every host command keeps running
91
+ on your machine (Windows included) exactly as before. Instead, a dedicated
92
+ **`sandbox` tool** appears; the agent calls it only when isolation is
93
+ genuinely needed (you asked for the sandbox, the command is risky, or a
94
+ Linux-only environment is required). Each call revives the container if it
95
+ stopped (e.g. after a Docker Desktop restart) and re-mounts the current
96
+ project, then runs just that one command via `docker exec`. The container
97
+ idles in the background between calls; `/sandbox off` disables the tool
98
+ (not the host), `/sandbox on` re-enables it. The setup wizard's build step
99
+ also became fully non-blocking: docker pull streams per-layer progress
100
+ into a live percentage bar, package installs stream their output, and the
101
+ TUI stays responsive throughout (esc lets setup finish in the background).
85
102
  - **Slash popup decluttered.** The autocomplete popup and command palette
86
103
  now show a short one-line description per command — argument syntax is no
87
104
  longer appended (it lives in `/help` and the README table). Duplicate
package/README.md CHANGED
@@ -163,7 +163,7 @@ self-test client (`node scripts/acp-smoke.js`) live in **[docs/acp.md](docs/acp.
163
163
  | `/tasks` | — | Background tasks |
164
164
  | `/share` | — | Export chat as HTML |
165
165
  | `/worktree` | `<name>` | New git worktree |
166
- | `/sandbox` | `setup\|status\|on\|off\|rm` | Docker sandbox |
166
+ | `/sandbox` | `setup\|status\|on\|off\|rm` | Docker sandbox (opt-in `sandbox` tool; host untouched) |
167
167
  | `/style` | `[preset\|text]` | Output style preset |
168
168
  | `/vim` | — | Toggle vim editing |
169
169
  | `/remember` | `<fact>` | Save a fact to LOOM.md |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loom-agent",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/toshalkumbhar8979-design/loomcode.git"
@@ -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
@@ -179,20 +183,22 @@ function toSandboxPath(hostDir, projectRoot) {
179
183
  }
180
184
 
181
185
  /**
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 }.
186
+ * Run ONE shell command inside the sandbox container (used by the sandbox
187
+ * tool — host commands stay on the bash tool). Revives the container if it
188
+ * stopped and re-mounts the current project, then streams output via
189
+ * onChunk('out'|'err'); resolves with { output, code } or { error }.
185
190
  * @param {string} command
186
191
  * @param {{ workdir?: string, projectRoot?: string, timeoutMs?: number, onChunk?: (text: string, stream: 'out'|'err') => void }} [opts]
187
192
  * @returns {Promise<{ output: string, code: number }|{ error: string }>}
188
193
  */
189
- function runInSandbox(command, opts) {
194
+ async function runInSandbox(command, opts) {
190
195
  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
- }
196
+ // Bring the container back if the daemon restarted, then make sure it
197
+ // mounts the CURRENT project (cheap no-op when both already match).
198
+ const alive = await ensureContainerAlive();
199
+ if (!alive.ok) return { error: alive.error };
200
+ const remount = await ensureProjectMount(o.projectRoot);
201
+ if (!remount.ok) return { error: 'sandbox re-mount failed: ' + remount.error };
196
202
  const wd = toSandboxPath(o.workdir, o.projectRoot);
197
203
  const timeoutMs = o.timeoutMs || 120000;
198
204
  return new Promise((resolve) => {
@@ -234,20 +240,114 @@ function runInSandbox(command, opts) {
234
240
  });
235
241
  }
236
242
 
243
+ /**
244
+ * Spawn a host command, line-buffer its output, never block the event loop.
245
+ * Used by setupSandbox so the TUI keeps rendering (and the progress UI keeps
246
+ * updating) while docker pull / package installs run.
247
+ * @param {string} cmd
248
+ * @param {string[]} args
249
+ * @param {{ timeoutMs?: number, onLine?: (line: string, stream: 'out'|'err') => void }} [opts]
250
+ * @returns {Promise<{ code: number, out: string }>}
251
+ */
252
+ function spawnAsync(cmd, args, opts) {
253
+ const o = opts || {};
254
+ return new Promise((resolve) => {
255
+ let child;
256
+ try {
257
+ child = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
258
+ } catch (e) {
259
+ resolve({ code: -1, out: (e && e.message) || String(e) });
260
+ return;
261
+ }
262
+ const timer = o.timeoutMs
263
+ ? setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, o.timeoutMs)
264
+ : null;
265
+ let out = '';
266
+ const MAX_OUT = 4 * 1024 * 1024;
267
+ const feed = (/** @type {Buffer} */ d, /** @type {'out'|'err'} */ stream) => {
268
+ const t = d.toString();
269
+ if (out.length < MAX_OUT) out += t;
270
+ if (!o.onLine) return;
271
+ // Line-buffer: docker writes progress with \r on TTYs and \n otherwise;
272
+ // treat both as separators so the UI gets fresh rows, not one blob.
273
+ const lines = t.split(/\r?\n/);
274
+ for (const line of lines) {
275
+ if (!line) continue;
276
+ try { o.onLine(line.replace(/\x1b\[[0-9;]*m/g, ''), stream); } catch {}
277
+ }
278
+ };
279
+ child.stdout.on('data', (d) => feed(d, 'out'));
280
+ child.stderr.on('data', (d) => feed(d, 'err'));
281
+ child.on('error', (err) => {
282
+ if (timer) clearTimeout(timer);
283
+ resolve({ code: -1, out: (out ? out + '\n' : '') + err.message });
284
+ });
285
+ child.on('close', (code) => {
286
+ if (timer) clearTimeout(timer);
287
+ resolve({ code: code == null ? 1 : code, out });
288
+ });
289
+ });
290
+ }
291
+
292
+ // "12.34MB" / "951kB" / "1.2GB" → bytes (docker prints SI-style units).
293
+ function parseBytes(str, unit) {
294
+ const n = parseFloat(str);
295
+ if (!isFinite(n)) return 0;
296
+ const u = String(unit || '').toLowerCase();
297
+ if (u === 'gb') return n * 1e9;
298
+ if (u === 'mb') return n * 1e6;
299
+ if (u === 'kb') return n * 1e3;
300
+ return n;
301
+ }
302
+
303
+ /**
304
+ * Fold one docker-pull stdout line into the per-layer progress map. Layers
305
+ * report "Downloading [bar] X/Y", "Extracting [bar] X/Y", then "Pull complete".
306
+ * @param {string} line
307
+ * @param {Map<string, number>} layers layer-id → fraction 0..1
308
+ */
309
+ function trackPullLine(line, layers) {
310
+ const done = /^([0-9a-f]{8,64}):\s+(?:Download complete|Pull complete)\s*$/i.exec(line);
311
+ if (done) { layers.set(done[1], 1); return; }
312
+ // "Waiting" = queued layer, no bytes yet.
313
+ const waiting = /^([0-9a-f]{8,64}):\s+Waiting\s*$/i.exec(line);
314
+ if (waiting) { layers.set(waiting[1], 0); return; }
315
+ const active = /^([0-9a-f]{8,64}):\s+(?:Downloading|Extracting)\s+(?:\[[^\]]*\]\s+)?([\d.]+)([kmgt]?b)(?:\/([\d.]+)([kmgt]?b))?/i.exec(line);
316
+ if (active) {
317
+ const id = active[1];
318
+ if (active[4]) {
319
+ const total = parseBytes(active[4], active[5]);
320
+ const cur = parseBytes(active[2], active[3]);
321
+ if (total > 0) layers.set(id, Math.min(1, cur / total));
322
+ } else if (!layers.has(id)) {
323
+ layers.set(id, 0);
324
+ }
325
+ }
326
+ }
327
+
328
+ function pullPercent(layers) {
329
+ if (!layers.size) return null;
330
+ let sum = 0;
331
+ for (const frac of layers.values()) sum += frac;
332
+ return Math.min(99, Math.round((sum / layers.size) * 100));
333
+ }
334
+
237
335
  /**
238
336
  * Build the sandbox: pull the chosen OS image, (re)create the container with
239
337
  * 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
338
+ * offers it). Fully ASYNC — every docker call streams, so the TUI never
339
+ * freezes and onProgress/onLog deliver live updates. The container is a
340
+ * single GLOBAL resource (~/.loom/config.json records it), so every session and
242
341
  * every agent shares the same sandbox; only the mounted project follows the
243
342
  * working directory via ensureProjectMount().
244
- * @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void }} [opts]
343
+ * @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void, onProgress?: (p: { stage: string, pct: number|null, step: number, total: number }) => void }} [opts]
245
344
  * @returns {Promise<{ ok: true, container: string }|{ ok: false, error: string }>}
246
345
  */
247
346
  async function setupSandbox(opts) {
248
347
  const o = opts || {};
249
348
  const root = path.resolve(o.projectRoot || process.cwd());
250
349
  const log = o.onLog || (() => {});
350
+ const prog = o.onProgress || (() => {});
251
351
  const { state, version } = detectDocker();
252
352
  if (state === 'not_installed') return { ok: false, error: 'Docker is not installed. Run /sandbox guide for setup steps.' };
253
353
  if (state === 'no_daemon') {
@@ -256,77 +356,124 @@ async function setupSandbox(opts) {
256
356
  const osEntry = OS_CATALOG.find((x) => x.id === o.osId) || OS_CATALOG[0];
257
357
  const image = osEntry.image;
258
358
  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).');
359
+ // Stages reported to onProgress: pull → create → start → essentials → browser → done.
360
+ const stages = ['pull', 'create', 'start', 'essentials'].concat(osEntry.browser ? ['browser'] : [], ['done']);
361
+ const stageIdx = (/** @type {string} */ s) => { const i = stages.indexOf(s); return i < 0 ? 0 : i; };
362
+ prog({ stage: 'pull', pct: 0, step: stageIdx('pull'), total: stages.length });
363
+
364
+ // ── Stage 1: pull the image (real per-layer progress, async + streamed) ──
365
+ log('Pulling ' + image + ' (first run only)…');
366
+ const layers = new Map();
367
+ let lastPct = -1;
368
+ const pull = await spawnAsync('docker', ['pull', image], {
369
+ timeoutMs: 600000,
370
+ onLine: (line) => {
371
+ trackPullLine(line, layers);
372
+ const pct = pullPercent(layers);
373
+ if (pct != null && pct !== lastPct) {
374
+ lastPct = pct;
375
+ prog({ stage: 'pull', pct, step: stageIdx('pull'), total: stages.length });
293
376
  }
294
- }
295
- const cfg = loadConfig();
296
- cfg.sandbox = Object.assign({}, cfg.sandbox, {
297
- enabled: true, pending: false, image, osId: osEntry.id, projectRoot: root,
377
+ if (/^status:|^digest:|up to date/i.test(line)) log(line);
378
+ },
379
+ });
380
+ if (pull.code !== 0) {
381
+ return { ok: false, error: 'docker pull failed (exit ' + pull.code + '): ' + pull.out.slice(-400) };
382
+ }
383
+ prog({ stage: 'pull', pct: 100, step: stageIdx('pull'), total: stages.length });
384
+
385
+ // ── Stage 2: recreate the container from scratch (image updates + mount) ──
386
+ prog({ stage: 'create', pct: null, step: stageIdx('create'), total: stages.length });
387
+ await spawnAsync('docker', ['rm', '-f', CONTAINER], { timeoutMs: 30000 });
388
+ log('Creating container ' + CONTAINER + ' with ' + root + ' mounted at /workspace…');
389
+ const create = await spawnAsync('docker', [
390
+ 'create',
391
+ '--name', CONTAINER,
392
+ // Isolation: project dir only, no host network, no extra privileges.
393
+ '-v', root + ':/workspace',
394
+ '-w', '/workspace',
395
+ // Idle-forever main process; every agent command is a docker exec.
396
+ image, 'sleep', 'infinity',
397
+ ], { timeoutMs: 60000 });
398
+ if (create.code !== 0) {
399
+ return { ok: false, error: 'docker create failed (exit ' + create.code + '): ' + create.out.slice(-300) };
400
+ }
401
+
402
+ // ── Stage 3: start it (stays idle in the background until the sandbox
403
+ // tool is explicitly invoked) ──
404
+ prog({ stage: 'start', pct: null, step: stageIdx('start'), total: stages.length });
405
+ const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
406
+ if (start.code !== 0) {
407
+ return { ok: false, error: 'docker start failed (exit ' + start.code + '): ' + start.out.slice(-300) };
408
+ }
409
+
410
+ // ── Stage 4: install essentials (streamed so the UI shows life) ──
411
+ prog({ stage: 'essentials', pct: null, step: stageIdx('essentials'), total: stages.length });
412
+ const pkgCmd = osEntry.pkg === 'apk'
413
+ ? 'apk add --no-cache ' + BASE_PACKAGES
414
+ : 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends ' + BASE_PACKAGES + ' && rm -rf /var/lib/apt/lists/*';
415
+ log('Installing essentials (' + BASE_PACKAGES + ')…');
416
+ const install = await spawnAsync('docker', ['exec', CONTAINER, 'sh', '-lc', pkgCmd], {
417
+ timeoutMs: 600000,
418
+ onLine: (line) => { if (!/^\s*$/.test(line)) log(line); },
419
+ });
420
+ if (install.code !== 0) {
421
+ return { ok: false, error: 'package install failed (exit ' + install.code + '): ' + install.out.slice(-400) };
422
+ }
423
+
424
+ // ── Stage 5: headless browser when the OS offers one — best-effort, so a
425
+ // missing chromium package never fails the whole setup. ──
426
+ if (osEntry.browser) {
427
+ prog({ stage: 'browser', pct: null, step: stageIdx('browser'), total: stages.length });
428
+ const browserCmd = osEntry.pkg === 'apk'
429
+ ? 'apk add --no-cache chromium'
430
+ : 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium && rm -rf /var/lib/apt/lists/*';
431
+ log('Installing a headless Chromium (best-effort)…');
432
+ const browser = await spawnAsync('docker', ['exec', CONTAINER, 'sh', '-lc', browserCmd], {
433
+ timeoutMs: 600000,
434
+ onLine: (line) => { if (!/^\s*$/.test(line)) log(line); },
298
435
  });
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)) };
436
+ if (browser.code !== 0) {
437
+ log('Chromium unavailable in this image — browser tooling will be limited (sandbox continues).');
438
+ }
305
439
  }
440
+
441
+ prog({ stage: 'done', pct: 100, step: stageIdx('done'), total: stages.length });
442
+ const cfg = loadConfig();
443
+ cfg.sandbox = Object.assign({}, cfg.sandbox, {
444
+ enabled: true, pending: false, image, osId: osEntry.id, projectRoot: root,
445
+ });
446
+ saveConfig(cfg);
447
+ log('Sandbox ready' + (osEntry.browser ? ' — including a headless Chromium for the browser tool' : '') + '.');
448
+ log('Container ' + CONTAINER + ' idles in the background; the sandbox tool uses it only when the agent needs a Linux environment.');
449
+ return { ok: true, container: CONTAINER };
306
450
  }
307
451
 
308
452
  /**
309
453
  * Make sure the shared container mounts the CURRENT project. Sessions in
310
454
  * different directories share one sandbox — when the active project changes
311
455
  * we just re-create the container with the new mount (image stays cached).
312
- * Cheap no-op when the mount already matches.
456
+ * Cheap no-op when the mount already matches. Fully async (never blocks the
457
+ * TUI event loop).
313
458
  * @param {string} [projectRoot]
314
- * @returns {{ ok: true }|{ ok: false, error: string }}
459
+ * @returns {Promise<{ ok: true }|{ ok: false, error: string }>}
315
460
  */
316
- function ensureProjectMount(projectRoot) {
461
+ async function ensureProjectMount(projectRoot) {
317
462
  const root = path.resolve(projectRoot || process.cwd());
318
463
  const cfg = loadConfig();
319
464
  if (!cfg.sandbox || !cfg.sandbox.enabled) return { ok: false, error: 'sandbox disabled' };
320
465
  if (cfg.sandbox.projectRoot === root) return { ok: true };
321
466
  const image = cfg.sandbox.image || DEFAULT_IMAGE;
322
467
  try {
323
- try { execFileSync('docker', ['rm', '-f', CONTAINER], { windowsHide: true, timeout: 30000, stdio: 'ignore' }); } catch {}
324
- execFileSync('docker', [
468
+ await spawnAsync('docker', ['rm', '-f', CONTAINER], { timeoutMs: 30000 });
469
+ const create = await spawnAsync('docker', [
325
470
  'create', '--name', CONTAINER,
326
471
  '-v', root + ':/workspace', '-w', '/workspace',
327
472
  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' });
473
+ ], { timeoutMs: 60000 });
474
+ if (create.code !== 0) return { ok: false, error: 're-mount create failed: ' + create.out.slice(-200) };
475
+ const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
476
+ if (start.code !== 0) return { ok: false, error: 're-mount start failed: ' + start.out.slice(-200) };
330
477
  cfg.sandbox.projectRoot = root;
331
478
  saveConfig(cfg);
332
479
  return { ok: true };
@@ -336,9 +483,35 @@ function ensureProjectMount(projectRoot) {
336
483
  }
337
484
 
338
485
  /**
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.
486
+ * Make sure the container exists and is RUNNING before a sandbox-tool call.
487
+ * Revives a stopped container (Docker Desktop restart, reboot) with a quick
488
+ * `docker start`; never recreates (image/mount are setup-time concerns).
489
+ * @returns {Promise<{ ok: true }|{ ok: false, error: string }>}
490
+ */
491
+ async function ensureContainerAlive() {
492
+ const cfg = loadConfig();
493
+ if (!cfg.sandbox || !cfg.sandbox.enabled) {
494
+ return { ok: false, error: 'Sandbox is not set up. Run /sandbox (or /sandbox setup) first — it only takes a minute and is needed once.' };
495
+ }
496
+ if (process.env.LOOM_SANDBOX === '0') {
497
+ return { ok: false, error: 'Sandbox disabled via LOOM_SANDBOX=0.' };
498
+ }
499
+ const inspect = await spawnAsync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], { timeoutMs: 15000 });
500
+ if (inspect.code !== 0 || !inspect.out.trim()) {
501
+ return { ok: false, error: 'Sandbox container is missing (deleted?). Run /sandbox setup again.' };
502
+ }
503
+ if (inspect.out.trim() === 'true') return { ok: true };
504
+ const start = await spawnAsync('docker', ['start', CONTAINER], { timeoutMs: 60000 });
505
+ if (start.code !== 0) {
506
+ 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.' };
507
+ }
508
+ return { ok: true };
509
+ }
510
+
511
+ /**
512
+ * Is the sandbox TOOL available (set up and not kill-switched)? Used by the
513
+ * sandbox tool to decide between running and a helpful "run /sandbox first"
514
+ * error — NOT to route ordinary bash commands (those stay on the host).
342
515
  * @returns {boolean}
343
516
  */
344
517
  function shouldUseSandbox() {
@@ -348,7 +521,7 @@ function shouldUseSandbox() {
348
521
  }
349
522
 
350
523
  /**
351
- * Toggle sandbox command routing (container keeps running either way).
524
+ * Toggle sandbox availability (the container idles either way).
352
525
  * @param {boolean} on
353
526
  */
354
527
  function setSandboxEnabled(on) {
@@ -393,7 +566,10 @@ module.exports = {
393
566
  runInSandbox,
394
567
  setupSandbox,
395
568
  ensureProjectMount,
569
+ ensureContainerAlive,
396
570
  shouldUseSandbox,
571
+ trackPullLine,
572
+ pullPercent,
397
573
  setSandboxEnabled,
398
574
  markInstallPending,
399
575
  isReadySignal,
@@ -263,6 +263,7 @@ ${this.loadSkills()}
263
263
  ## Mode
264
264
  You are in ${this.mode === 'build' ? 'BUILD MODE (full agent)' : this.mode === 'plan' ? 'PLAN MODE (read-only analysis)' : 'CHAT MODE (conversation only)'}.
265
265
  ${this.mode === 'build' ? `- You have all tools available: read, write, edit, bash, glob, grep, webfetch, and MCP tools.
266
+ - bash runs on the user's machine. Only use the sandbox tool when isolation is genuinely needed (the user asked for it, or the command is risky/Linux-only); never for ordinary commands.
266
267
  - Inspect, edit, and verify the result yourself end-to-end.
267
268
  - When done, summarize in 1-2 sentences.` : ''}${this.mode === 'plan' ? `- Read-only tools only: read, glob, grep, webfetch, todowrite. No MCP tools.
268
269
  - Investigate thoroughly, then output a "## Plan" with ordered steps (exact file path + what changes each step makes). No narration.` : ''}${this.mode === 'chat' ? `- No tools available; answer conversationally.
@@ -186,31 +186,13 @@ const TOOLS = {
186
186
  },
187
187
  bash: {
188
188
  name: 'bash',
189
- description: 'Execute a shell command.',
189
+ description: 'Execute a shell command on the user\'s machine.',
190
190
  parameters: {
191
191
  command: { type: 'string', required: true, description: 'The command to execute' },
192
192
  workdir: { type: 'string', required: false, description: 'Working directory' },
193
193
  background: { type: 'boolean', required: false, description: 'Run without waiting (dev servers, long builds). Returns a task id immediately; check with the /tasks panel.' },
194
194
  },
195
195
  async execute(params, ctx) {
196
- // Docker sandbox: when enabled (/sandbox setup), every command runs
197
- // inside the loom-sandbox container instead of the host shell.
198
- // Background tasks stay on the host (they outlive tool timeouts).
199
- const sandbox = require('../core/sandbox');
200
- if (!params.background && sandbox.shouldUseSandbox()) {
201
- if (!pathAllowed(params.workdir || cwd)) return { error: sandboxDenied('bash', params.workdir || cwd) };
202
- const risk = params._approved ? null : commandRiskLabel(params.command);
203
- if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
204
- const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
205
- const res = await sandbox.runInSandbox(params.command, {
206
- workdir: params.workdir || cwd,
207
- onChunk: stream ? (text, which) => stream(text, which) : undefined,
208
- });
209
- if ('error' in res) return { error: res.error };
210
- let out = res.output;
211
- if (res.code !== 0) out += `\n[exit code: ${res.code}]`;
212
- return out || 'command completed';
213
- }
214
196
  // Background mode: fire-and-forget via the task registry.
215
197
  if (params.background) {
216
198
  const bt = require('../core/background-tasks');
@@ -283,6 +265,32 @@ const TOOLS = {
283
265
  });
284
266
  }
285
267
  },
268
+ sandbox: {
269
+ name: 'sandbox',
270
+ description: 'Run a shell command INSIDE the Docker sandbox (isolated Linux container, project mounted at /workspace). Use ONLY when isolation is needed: the user asked for the sandbox, the command is risky (installs, deletions, untrusted scripts), or a Linux-only environment is required. For ordinary commands use bash (host). One command per call; the container idles in the background between calls.',
271
+ parameters: {
272
+ command: { type: 'string', required: true, description: 'The Linux shell command to run inside the container' },
273
+ workdir: { type: 'string', required: false, description: 'Working directory inside the container (host paths under the project map to /workspace/...)' },
274
+ timeoutMs: { type: 'number', required: false, description: 'Kill the command after this many ms (default 120000)' },
275
+ },
276
+ async execute(params, ctx) {
277
+ // Opt-in isolation path: nothing is routed here automatically. The
278
+ // container is revived/re-mounted on demand, then torn back to idle.
279
+ const sandbox = require('../core/sandbox');
280
+ const risk = params._approved ? null : commandRiskLabel(params.command);
281
+ if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
282
+ const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
283
+ const res = await sandbox.runInSandbox(params.command, {
284
+ workdir: params.workdir || cwd,
285
+ timeoutMs: typeof params.timeoutMs === 'number' && params.timeoutMs > 0 ? params.timeoutMs : undefined,
286
+ onChunk: stream ? (text, which) => stream(text, which) : undefined,
287
+ });
288
+ if ('error' in res) return { error: res.error };
289
+ let out = res.output;
290
+ if (res.code !== 0) out += `\n[exit code: ${res.code}]`;
291
+ return out || 'command completed';
292
+ }
293
+ },
286
294
  grep: {
287
295
  name: 'grep',
288
296
  description: 'Search file contents using regular expressions.',
package/src/tui/App.tsx CHANGED
@@ -1096,13 +1096,13 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1096
1096
  if (ssub === "guide") { appendMessage({ role: "system", content: sandbox.installGuide() }); return; }
1097
1097
  if (ssub === "off") {
1098
1098
  sandbox.setSandboxEnabled(false);
1099
- appendMessage({ role: "system", content: "Sandbox off — agent commands run on the host again. The container is kept (run /sandbox on to re-enable, /sandbox rm to delete it)." });
1099
+ appendMessage({ role: "system", content: "Sandbox off — the sandbox tool is disabled; the agent works on your machine as usual. The container is kept (run /sandbox on to re-enable, /sandbox rm to delete it)." });
1100
1100
  return;
1101
1101
  }
1102
1102
  if (ssub === "on") {
1103
1103
  if (st.container !== "running") { appendMessage({ role: "system", content: "Sandbox container is not running. Run /sandbox setup first." }); return; }
1104
1104
  sandbox.setSandboxEnabled(true);
1105
- appendMessage({ role: "system", content: "Sandbox on — agent commands now run inside " + sandbox.CONTAINER + "." });
1105
+ appendMessage({ role: "system", content: "Sandbox on — the agent can use the sandbox tool for isolated commands inside " + sandbox.CONTAINER + " when needed. Everything else stays on your machine." });
1106
1106
  return;
1107
1107
  }
1108
1108
  if (ssub === "rm") {
@@ -1117,7 +1117,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1117
1117
  var line = "Sandbox: ";
1118
1118
  if (st.docker === "not_installed") line += "Docker not installed";
1119
1119
  else if (st.docker === "no_daemon") line += "Docker installed, daemon not running";
1120
- else line += "Docker " + st.dockerVersion + " \u00b7 container " + st.container + (st.enabled ? " \u00b7 routing ON" : " \u00b7 routing off");
1120
+ else line += "Docker " + st.dockerVersion + " \u00b7 container " + st.container + (st.enabled ? " \u00b7 sandbox tool on" : " \u00b7 sandbox tool off");
1121
1121
  if (st.pending) line += "\nWaiting for you to install Docker \u2014 when it's done say \"docker is ready\" (or run /sandbox).";
1122
1122
  else if (st.docker === "not_installed") line += "\nRun /sandbox for the setup wizard.";
1123
1123
  else if (st.container === "absent") line += "\nRun /sandbox to build it.";
@@ -1,5 +1,5 @@
1
1
  // Modals -- provider picker, model picker, key input, base URL, settings, palette.
2
- import { createSignal, createMemo, onMount } from "solid-js";
2
+ import { createSignal, createMemo, onMount, onCleanup } from "solid-js";
3
3
  import { useKeyboard, usePaste } from "@opentui/solid";
4
4
  import { palette } from "../theme.ts";
5
5
  import * as kbs from "../keybinds.ts";
@@ -663,8 +663,9 @@ export function ConnectorsModal() {
663
663
 
664
664
  // ── Sandbox setup wizard ──
665
665
  // Three steps: docker check (with install link when missing) → OS pick →
666
- // live setup. The container is global — once built, every session and every
667
- // agent routes commands through it.
666
+ // live setup with a real progress bar (setup streams from the core layer —
667
+ // the TUI never freezes). The container is global and idles in the
668
+ // background; the agent's sandbox tool uses it on demand.
668
669
  export function SandboxModal() {
669
670
  const sb = require("../../core/sandbox.js");
670
671
  const [step, setStep] = createSignal<"docker" | "os" | "setup">("docker");
@@ -673,6 +674,30 @@ export function SandboxModal() {
673
674
  const [logs, setLogs] = createSignal<string[]>([]);
674
675
  const [busy, setBusy] = createSignal(false);
675
676
  const [err, setErr] = createSignal<string | null>(null);
677
+ const [prog, setProg] = createSignal<{ stage: string; pct: number | null; step: number; total: number } | null>(null);
678
+ const [spin, setSpin] = createSignal(0);
679
+ let spinTimer: ReturnType<typeof setInterval> | null = null;
680
+ onCleanup(() => { if (spinTimer) clearInterval(spinTimer); });
681
+
682
+ // Spinner frames while setup is busy (drives the "working…" indicator).
683
+ function startSpinner() {
684
+ if (spinTimer) clearInterval(spinTimer);
685
+ spinTimer = setInterval(() => setSpin((s) => (s + 1) % 10), 120);
686
+ }
687
+ function stopSpinner() {
688
+ if (spinTimer) clearInterval(spinTimer);
689
+ spinTimer = null;
690
+ }
691
+ const SPIN_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
692
+
693
+ const STAGE_LABELS: Record<string, string> = {
694
+ pull: "Pulling image",
695
+ create: "Creating container",
696
+ start: "Starting container",
697
+ essentials: "Installing essentials",
698
+ browser: "Installing browser",
699
+ done: "Ready",
700
+ };
676
701
 
677
702
  // Docker already installed → advance straight to OS selection (with a
678
703
  // brief "installed" flash so the user sees the check happened).
@@ -693,14 +718,18 @@ export function SandboxModal() {
693
718
  setStep("setup");
694
719
  setBusy(true);
695
720
  setErr(null);
721
+ setProg(null);
722
+ startSpinner();
696
723
  setLogs(["Preparing " + os.label + " (" + os.image + ")…"]);
697
724
  sb.setupSandbox({
698
725
  osId: os.id,
699
726
  onLog: (/** @type {string} */ l) => setLogs((xs) => [...xs, l]),
727
+ onProgress: (/** @type {{ stage: string; pct: number | null; step: number; total: number }} */ p) => setProg(p),
700
728
  }).then((/** @type {{ ok: boolean, error?: string }} */ res) => {
701
729
  setBusy(false);
730
+ stopSpinner();
702
731
  if (res.ok) {
703
- appendMessage({ role: "system", content: "Sandbox is live (" + os.label + "). Agent commands now run inside Docker; your project is mounted at /workspace. The sandbox is shared: every session and agent uses it." });
732
+ appendMessage({ role: "system", content: "Sandbox is live (" + os.label + "). The container idles in the background; the agent will use it via the sandbox tool whenever isolation is needed. Your project is mounted at /workspace." });
704
733
  showToast("Sandbox ready — " + os.label, "ok");
705
734
  closeModal();
706
735
  } else {
@@ -805,9 +834,39 @@ export function SandboxModal() {
805
834
  </box>
806
835
  ) : null}
807
836
  {step() === "setup" ? (
808
- <box flexDirection="column" maxWidth={64}>
809
- {logs().slice(-8).map((l) => <text fg={ui.fgMuted}>{"▸ " + l}</text>)}
810
- {busy() ? <text fg={ui.primary}>…</text> : null}
837
+ <box flexDirection="column" maxWidth={72}>
838
+ {/* Stage checklist with a live pull-progress bar (pct from core). */}
839
+ {prog() ? (() => {
840
+ const p = prog()!;
841
+ const os = sb.OS_CATALOG[osSel()];
842
+ // Same stage order the core reports (browser only when offered).
843
+ const keys = ["pull", "create", "start", "essentials"]
844
+ .concat(os.browser ? ["browser"] : [], ["done"])
845
+ .slice(0, p.total);
846
+ return (
847
+ <box flexDirection="column" marginBottom={1}>
848
+ {keys.map((key, i) => {
849
+ const done = i < p.step || p.stage === "done";
850
+ const active = i === p.step && p.stage !== "done";
851
+ const mark = done ? "✓" : active ? SPIN_FRAMES[spin()] : "○";
852
+ const color = done ? ui.success : active ? ui.primary : ui.fgDim;
853
+ let bar = "";
854
+ if (active && p.pct != null) {
855
+ const width = 20;
856
+ const filled = Math.round((p.pct / 100) * width);
857
+ bar = " [" + "█".repeat(filled) + "░".repeat(width - filled) + "] " + p.pct + "%";
858
+ } else if (active) {
859
+ bar = " …";
860
+ }
861
+ return (
862
+ <text fg={color}>{mark + " " + STAGE_LABELS[key] + bar}</text>
863
+ );
864
+ })}
865
+ </box>
866
+ );
867
+ })() : <text fg={ui.fgMuted}>Starting…</text>}
868
+ {logs().slice(-6).map((l) => <text fg={ui.fgMuted}>{"▸ " + l.slice(0, 68)}</text>)}
869
+ {busy() ? <text fg={ui.primary}>{SPIN_FRAMES[spin()] + " working — you can esc; setup continues in the background"}</text> : null}
811
870
  {err() ? (
812
871
  <box flexDirection="column" marginTop={1}>
813
872
  <text fg={ui.error}>{err()}</text>