loom-agent 1.2.36 → 1.3.2

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +38 -56
  3. package/bin/loom-bun.js +2 -10
  4. package/bin/loom.js +0 -10
  5. package/package.json +126 -126
  6. package/src/connectors/convex.js +356 -0
  7. package/src/connectors/deploy.js +390 -0
  8. package/src/connectors/index.js +23 -0
  9. package/src/connectors/supabase.js +279 -0
  10. package/src/core/agents.js +32 -2
  11. package/src/core/cli.js +30 -5
  12. package/src/core/permissions.js +12 -1
  13. package/src/core/plugin-cmd.js +1 -0
  14. package/src/core/sandbox.js +400 -0
  15. package/src/core/session-store.js +245 -167
  16. package/src/core/session.js +157 -11
  17. package/src/providers/anthropic.js +10 -5
  18. package/src/providers/openai-compat.js +12 -4
  19. package/src/tools/browser.js +204 -0
  20. package/src/tools/index.js +35 -1
  21. package/src/tui/App.tsx +103 -8
  22. package/src/tui/components/BreadcrumbBar.tsx +10 -9
  23. package/src/tui/components/ChatArea.tsx +3 -3
  24. package/src/tui/components/InputBar.tsx +22 -11
  25. package/src/tui/components/MdText.tsx +2 -1
  26. package/src/tui/components/Modals.tsx +191 -17
  27. package/src/tui/components/PermissionPopup.tsx +15 -12
  28. package/src/tui/components/Sidebar.tsx +6 -2
  29. package/src/tui/components/SplashScreen.tsx +17 -24
  30. package/src/tui/components/SubagentPanel.tsx +1 -1
  31. package/src/tui/components/ToastOverlay.tsx +23 -12
  32. package/src/tui/store.ts +38 -35
  33. package/src/tui/theme.ts +45 -30
  34. package/src/tui/tool-display.ts +13 -2
  35. package/docs/web.md +0 -205
  36. package/src/web/attach.js +0 -242
  37. package/src/web/graph-view.html +0 -262
  38. package/src/web/index.html +0 -824
  39. package/src/web/web-server.js +0 -535
@@ -60,9 +60,9 @@ function escapeRegExp(s) {
60
60
  const BUILTIN_AGENTS = {
61
61
  build: {
62
62
  id: 'build', name: 'Build', mode: 'primary',
63
- description: 'Full development work with all tools enabled.',
63
+ description: 'Main orchestrator agent: full development work with all tools, drives tasks to completion.',
64
64
  tools: ['*'],
65
- prompt: null,
65
+ prompt: 'You are the MAIN ORCHESTRATOR agent. For non-trivial, multi-step work: (1) capture the plan as a todowrite list up front, (2) keep working until every item is completed or explicitly cancelled — never stop mid-task with a summary while todos remain, (3) delegate independent/parallelizable packages via the task tool (research for deep investigation, coder for isolated implementation, reviewer for review, tester for tests, web for browser work, explore for fast code search), (4) verify your work (run tests/typecheck/lint the project actually uses) before declaring done, (5) finish with a terse summary of what changed.',
66
66
  },
67
67
  plan: {
68
68
  id: 'plan', name: 'Plan', mode: 'primary',
@@ -95,6 +95,36 @@ const BUILTIN_AGENTS = {
95
95
  tools: ['read', 'glob', 'grep', 'webfetch'],
96
96
  prompt: null,
97
97
  },
98
+ research: {
99
+ id: 'research', name: 'Research', mode: 'subagent',
100
+ description: 'Deep multi-source research: combines web search/fetch with local code and docs, cross-checks facts, and returns a structured findings report with sources and confidence levels.',
101
+ tools: ['read', 'glob', 'grep', 'webfetch', 'websearch', 'todowrite'],
102
+ prompt: 'You are the Research agent. Investigate the topic from multiple independent sources (websearch/webfetch AND local files when relevant). Never state a fact you have not verified in a source. Track your investigation with todowrite when it has many steps. Return a structured report: "## Findings" (numbered facts, each with its source: URL or file:line), "## Gaps" (what you could not verify), "## Recommendation".',
103
+ },
104
+ coder: {
105
+ id: 'coder', name: 'Coder', mode: 'subagent',
106
+ description: 'Implementation specialist: writes and edits code, runs commands, verifies results. Use for well-scoped feature/bugfix packages that can run independently.',
107
+ tools: ['*', '!task'],
108
+ prompt: 'You are the Coder agent. Implement exactly what was delegated — no scope creep. Follow the existing code conventions of the files you touch (imports, naming, JSDoc/typing style). Verify your work (run the relevant tests/typecheck) before reporting. Final report: files changed with line deltas, commands run and their outcomes, and anything left undone.',
109
+ },
110
+ reviewer: {
111
+ id: 'reviewer', name: 'Reviewer', mode: 'subagent',
112
+ description: 'Code review specialist: reads diffs and files, runs read-only git/inspection commands, returns severity-ranked findings with file:line references.',
113
+ tools: ['read', 'glob', 'grep', 'bash', 'webfetch'],
114
+ prompt: 'You are a strict code reviewer. Hunt for: correctness bugs, security issues, data loss risks, broken edge cases, missing tests, and convention violations. Inspect the actual diff/files (git diff, git log, read) — never review code you have not read. Findings first, ranked by severity (critical/major/minor), each with file:line and a concrete suggested fix. If there are no issues, say so explicitly and note what you checked.',
115
+ },
116
+ tester: {
117
+ id: 'tester', name: 'Tester', mode: 'subagent',
118
+ description: 'Test engineer: writes tests that reproduce the issue, runs them, iterates until green. Use for test creation, bug reproduction, and verification work.',
119
+ tools: ['read', 'write', 'edit', 'glob', 'grep', 'bash', 'todowrite'],
120
+ prompt: 'You are the Tester agent. First reproduce the behavior (write a failing test when possible), run it, then iterate until the outcome is deterministic and understood. Use the project\'s real test runner (inspect package.json/bunfig — never invent commands). Report: exact commands run, pass/fail counts, and what the tests prove.',
121
+ },
122
+ web: {
123
+ id: 'web', name: 'Web', mode: 'subagent',
124
+ description: 'Browser specialist: drives a real headless browser (navigate, click, fill, read DOM, screenshot) plus webfetch/websearch. Use for live-site inspection, rendered-page research, and end-to-end UI verification.',
125
+ tools: ['read', 'glob', 'grep', 'webfetch', 'websearch', 'browser', 'todowrite'],
126
+ prompt: 'You are the Web agent with a real headless browser (the browser tool). Prefer the browser over webfetch whenever the page needs JavaScript rendering, clicking, or form interaction. Verify claims by reading the ACTUAL DOM (browser text/eval/links) — never guess page content. Use screenshots to confirm visual state when relevant. Stay on http/https pages; do not use the browser to access local files.',
127
+ },
98
128
  };
99
129
 
100
130
  function readOnlyTools() {
package/src/core/cli.js CHANGED
@@ -9,6 +9,10 @@ const { ProviderRouter, PROVIDERS } = require('../providers');
9
9
  const { saveSession, loadSession } = require('./session-store');
10
10
  const { defaultMcpInstall } = require('./plugin-cmd');
11
11
 
12
+ // Single source of truth for every version string the CLI prints (banner,
13
+ // --version). package.json is the only place to bump.
14
+ const VERSION = require('../../package.json').version;
15
+
12
16
  const LOOM_BASE = `
13
17
  ██╗ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ███████╗
14
18
  ██║ ██╔═══██╗██╔═══██╗████╗ ████║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
@@ -38,7 +42,7 @@ class LoomCLI {
38
42
  terminal: true,
39
43
  });
40
44
  console.log(LOOM_BASE);
41
- console.log(`\n Loom Code v1.2.28 — AI Coding Agent for the terminal`);
45
+ console.log(`\n Loom Code v${VERSION} — AI Coding Agent for the terminal`);
42
46
  console.log(` Press Ctrl+C or ESC to interrupt | /help for commands\n`);
43
47
 
44
48
  process.stdin.on('keypress', (str, key) => {
@@ -391,7 +395,13 @@ class LoomCLI {
391
395
  */
392
396
  async openGraphBrowser(graph) {
393
397
  const http = require('http');
394
- const template = fs.readFileSync(path.join(__dirname, '..', 'web', 'graph-view.html'), 'utf8');
398
+ let template;
399
+ try {
400
+ template = fs.readFileSync(path.join(__dirname, '..', 'web', 'graph-view.html'), 'utf8');
401
+ } catch {
402
+ console.log('Browser graph view is not available (the web UI has been removed).');
403
+ return;
404
+ }
395
405
  const html = template.replace('window.__GRAPH__ || { nodes: [], edges: [] }', JSON.stringify(graph).replace(/<\/script/gi, '<\\/script'));
396
406
  const server = http.createServer((req, res) => {
397
407
  const p = req.url || '/';
@@ -509,7 +519,6 @@ async function main() {
509
519
  const cli = new LoomCLI();
510
520
 
511
521
  const args = process.argv.slice(2);
512
- const VERSION = require('../../package.json').version;
513
522
 
514
523
  const printIdx = args.indexOf('-p');
515
524
  const printLongIdx = args.indexOf('--print');
@@ -530,8 +539,7 @@ if (args.includes('--help') || args.includes('-h')) {
530
539
  console.log(' loom --basic Use basic line-mode REPL (no TUI)');
531
540
  console.log(' loom --auto Auto-approve permission prompts');
532
541
  console.log(' loom acp ACP server (JSON-RPC over stdio, for editor plugins)');
533
- console.log(' loom web Browser interface (HTTP server, opens the browser)');
534
- console.log(' loom attach <url> Attach the terminal to a running `loom web` server');
542
+ console.log(' loom add mcp <spec> Add an MCP server (same syntax as /mcp add)');
535
543
  console.log(' loom -s <session-id> Resume a saved session');
536
544
  console.log(' loom --version Show version');
537
545
  console.log(' loom --help Show this help');
@@ -551,6 +559,23 @@ if (args.includes('--help') || args.includes('-h')) {
551
559
  return;
552
560
  }
553
561
 
562
+ // loom add mcp <name> [--] <command> [args...] — register an MCP server from
563
+ // the shell with the same one-liner syntax as /mcp add (quote paths with
564
+ // spaces; -e KEY=V for env). Exits 0 on success.
565
+ if (args[0] === 'add' && args[1] === 'mcp') {
566
+ const spec = args.slice(2);
567
+ if (!spec.length) {
568
+ console.log('Usage: loom add mcp [-e KEY=V] <name> [--] <command> [args...]');
569
+ console.log('Example: loom add mcp fetch -- npx -y mcp-server-fetch');
570
+ process.exit(1);
571
+ }
572
+ const { mcpAddLineCmd } = require('./plugin-cmd');
573
+ const msg = mcpAddLineCmd(spec.join(' '));
574
+ const ok = typeof msg === 'string' && msg.startsWith('Added');
575
+ console.log(ok ? msg : 'Error: ' + msg);
576
+ process.exit(ok ? 0 : 1);
577
+ }
578
+
554
579
  if (sessionId && sIndex !== -1) {
555
580
  const data = loadSession(sessionId);
556
581
  if (data && data.messages && data.messages.length) {
@@ -34,7 +34,7 @@ const DANGER_PATTERNS = [
34
34
  // user config).
35
35
  const PERMISSION_KEYS = [
36
36
  'read', 'edit', 'glob', 'grep', 'bash', 'task', 'skill', 'lsp', 'question',
37
- 'webfetch', 'websearch',
37
+ 'webfetch', 'websearch', 'browser',
38
38
  'external_directory', 'doom_loop',
39
39
  ];
40
40
 
@@ -53,6 +53,15 @@ const DEFAULT_PERMISSIONS = {
53
53
  '*.env.*': 'deny',
54
54
  '*.env.example': 'allow',
55
55
  },
56
+ // Browser: passive reads of live pages are as safe as webfetch (allow);
57
+ // INTERACTIVE actions (click/fill/press) can submit forms and change remote
58
+ // state, so they prompt by default. Patterns match "action arg".
59
+ browser: {
60
+ '*': 'allow',
61
+ 'click*': 'ask',
62
+ 'fill*': 'ask',
63
+ 'press*': 'ask',
64
+ },
56
65
  };
57
66
 
58
67
  // Tools that write files map onto the "edit" permission (OpenCode semantics:
@@ -71,6 +80,7 @@ const TOOL_TO_PERMISSION = {
71
80
  ask: 'question',
72
81
  webfetch: 'webfetch',
73
82
  websearch: 'websearch',
83
+ browser: 'browser',
74
84
  todowrite: null, // never prompts
75
85
  mcp: null, // wired by the session (only the "add" action prompts)
76
86
  };
@@ -301,6 +311,7 @@ class PermissionManager {
301
311
  case 'ask': return String(inp.question ?? inp.text ?? '');
302
312
  case 'webfetch': return String(inp.url ?? '');
303
313
  case 'websearch': return String(inp.query ?? '');
314
+ case 'browser': return String(inp.action ?? '') + ' ' + String(inp.url ?? inp.selector ?? inp.key ?? inp.path ?? '');
304
315
  default: return String(inp.command ?? inp.filePath ?? inp.path ?? inp.pattern ?? inp.url ?? '');
305
316
  }
306
317
  }
@@ -123,6 +123,7 @@ function mcpHelp() {
123
123
  '',
124
124
  'Example (claude-compatible): /mcp add stm32 -- "C:\\stm32-mcp\\.venv\\Scripts\\python.exe" -m stm32_mcp.server',
125
125
  'Example with env: /mcp add -e BRAVE_API_KEY=x brave-search -- npx -y @modelcontextprotocol/server-brave-search',
126
+ 'From the shell (same syntax): loom add mcp [-e KEY=V] <name> [--] <command> [args...]',
126
127
  'The "--" separator is optional; quote any path with spaces. Env keys are usable as $KEY in args.',
127
128
  'In the /mcp and /connectors browsers, press A to add with the same one-liner syntax.',
128
129
  ].join('\n');
@@ -0,0 +1,400 @@
1
+ // Docker sandbox — a lightweight, isolated OS for the agent's shell commands.
2
+ //
3
+ // Flow (guided in the TUI via the /sandbox modal):
4
+ // 1. Step 1 checks Docker. If missing, the modal shows the download link
5
+ // (installLink) + "open download page", sets config.sandbox.pending,
6
+ // and the user can later say "docker is ready" or run /sandbox again.
7
+ // 2. Step 2 picks an OS from OS_CATALOG (alpine/debian/ubuntu — all
8
+ // node images, ~250–900 MB). Step 3 pulls it, creates the single
9
+ // GLOBAL loom-sandbox container with the PROJECT mounted at /workspace,
10
+ // 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
14
+ // 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.
18
+ //
19
+ // Security posture: no --privileged, no host network, only the project dir
20
+ // is mounted (rw), container runs as the image's default non-root user when
21
+ // possible. It is a blast-radius reducer, not a hostile-VM boundary.
22
+ const { spawn, execFileSync } = require('child_process');
23
+ const path = require('path');
24
+ const { loadConfig, saveConfig } = require('../config/settings');
25
+
26
+ const CONTAINER = 'loom-sandbox';
27
+ const DEFAULT_IMAGE = 'node:20-alpine';
28
+ // Essentials installed on first setup: a POSIX shell with the tools the
29
+ // agent actually reaches for. node is already in the image. Chromium is a
30
+ // separate best-effort install (see setupSandbox).
31
+ const BASE_PACKAGES = 'git bash curl ripgrep coreutils';
32
+
33
+ /**
34
+ * OS catalog offered by the /sandbox modal. All are lightweight, rootless-
35
+ * friendly Linux userlands; the agent gets a full shell + headless browser
36
+ * inside whichever it picks. `pkg` selects the package manager path.
37
+ * @type {Array<{ id: string, label: string, image: string, sizeNote: string, pkg: 'apk'|'apt', browser: boolean }>}
38
+ */
39
+ const OS_CATALOG = [
40
+ { id: 'alpine', label: 'Alpine Linux', image: 'node:20-alpine', sizeNote: 'smallest & fastest (~250 MB)', pkg: 'apk', browser: true },
41
+ { id: 'debian', label: 'Debian Slim', image: 'node:20-slim', sizeNote: 'broad binary compatibility (~600 MB)', pkg: 'apt', browser: true },
42
+ { id: 'ubuntu', label: 'Ubuntu', image: 'node:20', sizeNote: 'full userland (~900 MB)', pkg: 'apt', browser: false },
43
+ ];
44
+
45
+ /** @typedef {'not_installed'|'no_daemon'|'ready'} DockerState */
46
+
47
+ /**
48
+ * Detect the Docker CLI + daemon. Injectable runner for tests.
49
+ * @param {(cmd: string, args: string[]) => string} [runner]
50
+ * @returns {{ state: DockerState, version: string }}
51
+ */
52
+ function detectDocker(runner) {
53
+ const run = runner || ((cmd, args) => execFileSync(cmd, args, {
54
+ encoding: 'utf8', windowsHide: true, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'],
55
+ }).trim());
56
+ let version = '';
57
+ try {
58
+ version = run('docker', ['version', '--format', '{{.Server.Version}}']);
59
+ } catch {
60
+ // CLI missing entirely vs daemon down: `docker version` errors on both,
61
+ // but `docker --version` still succeeds when only the daemon is down.
62
+ try {
63
+ run('docker', ['--version']);
64
+ return { state: 'no_daemon', version: '' };
65
+ } catch {
66
+ return { state: 'not_installed', version: '' };
67
+ }
68
+ }
69
+ if (!version) return { state: 'no_daemon', version: '' };
70
+ return { state: 'ready', version };
71
+ }
72
+
73
+ /**
74
+ * Platform-appropriate Docker install guide shown before the user leaves to
75
+ * install. Mentions the restart when the installer requires one, and the
76
+ * exact phrase that resumes setup after they come back.
77
+ * @returns {string}
78
+ */
79
+ function installGuide() {
80
+ const plat = process.platform;
81
+ const link = installLink();
82
+ const steps = [];
83
+ if (plat === 'win32') {
84
+ steps.push('1. Download Docker Desktop for Windows: ' + link);
85
+ steps.push('2. Run the installer. If it asks to enable WSL 2 / restart Windows — accept and RESTART your computer when it finishes.');
86
+ steps.push('3. Start Docker Desktop and wait for it to say "running" in the tray (first start can take a minute).');
87
+ } else if (plat === 'darwin') {
88
+ steps.push('1. Download Docker Desktop for Mac: ' + link + ' (Apple chip or Intel — pick yours).');
89
+ steps.push('2. Drag it to Applications and open it. Accept the terms; if macOS asks for a restart of privileges, let it.');
90
+ steps.push('3. Wait for the whale icon to settle — Docker is ready when the dashboard shows "running".');
91
+ } else {
92
+ steps.push('1. Install Docker Engine: ' + link + ' (Ubuntu: `curl -fsSL https://get.docker.com | sh` then `sudo usermod -aG docker $USER` and log out/in).');
93
+ steps.push('2. Start it: `sudo systemctl enable --now docker`.');
94
+ }
95
+ steps.push('4. Come back to THIS session and say: docker is ready');
96
+ steps.push(' (or run /sandbox setup). Loom will build the sandbox automatically.');
97
+ return [
98
+ 'Docker is not set up yet — install it once and the sandbox becomes permanent.',
99
+ '',
100
+ ...steps,
101
+ '',
102
+ 'Your session is kept: nothing is lost while you install. Say "docker is ready" when done.',
103
+ ].join('\n');
104
+ }
105
+
106
+ /**
107
+ * Download page for the current platform (shown in the /sandbox modal).
108
+ * @returns {string}
109
+ */
110
+ function installLink() {
111
+ if (process.platform === 'win32') return 'https://docs.docker.com/desktop/setup/install/windows-install/';
112
+ if (process.platform === 'darwin') return 'https://docs.docker.com/desktop/setup/install/mac-install/';
113
+ return 'https://docs.docker.com/engine/install/';
114
+ }
115
+
116
+ /**
117
+ * Try to open the Docker download page in the user's default browser.
118
+ * Best-effort — the URL is always printed as a fallback.
119
+ * @returns {boolean} true if a browser spawn was attempted
120
+ */
121
+ function openInstallPage() {
122
+ const url = installLink();
123
+ const { spawn } = require('child_process');
124
+ try {
125
+ if (process.platform === 'win32') spawn('cmd', ['/c', 'start', '', url], { windowsHide: true, detached: true }).unref();
126
+ else if (process.platform === 'darwin') spawn('open', [url], { detached: true }).unref();
127
+ else spawn('xdg-open', [url], { detached: true }).unref();
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Current sandbox lifecycle state for display + gating.
136
+ * @returns {{ docker: DockerState, dockerVersion: string, enabled: boolean, pending: boolean, container: 'running'|'stopped'|'absent', image: string, osId: string, projectRoot: string }}
137
+ */
138
+ function sandboxStatus() {
139
+ const cfg = loadConfig();
140
+ const sandboxCfg = cfg.sandbox || {};
141
+ const { state, version } = detectDocker();
142
+ let container = /** @type {'running'|'stopped'|'absent'} */ ('absent');
143
+ if (state === 'ready') {
144
+ try {
145
+ const out = execFileSync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER],
146
+ { encoding: 'utf8', windowsHide: true, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
147
+ container = out === 'true' ? 'running' : 'stopped';
148
+ } catch {
149
+ container = 'absent';
150
+ }
151
+ }
152
+ return {
153
+ docker: state,
154
+ dockerVersion: version,
155
+ enabled: !!sandboxCfg.enabled && container === 'running',
156
+ pending: !!sandboxCfg.pending,
157
+ container,
158
+ image: sandboxCfg.image || DEFAULT_IMAGE,
159
+ osId: sandboxCfg.osId || 'alpine',
160
+ projectRoot: sandboxCfg.projectRoot || '',
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Map a host working directory to its path inside the container. The project
166
+ * root is mounted at /workspace; subdirectories map underneath it. Anything
167
+ * OUTSIDE the project falls back to /workspace (the container has no other
168
+ * mounts by design).
169
+ * @param {string} [hostDir]
170
+ * @param {string} [projectRoot]
171
+ * @returns {string}
172
+ */
173
+ function toSandboxPath(hostDir, projectRoot) {
174
+ const root = path.resolve(projectRoot || process.cwd());
175
+ const dir = path.resolve(hostDir || root);
176
+ const rel = path.relative(root, dir);
177
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '/workspace';
178
+ return '/workspace/' + rel.split(path.sep).join('/');
179
+ }
180
+
181
+ /**
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 }.
185
+ * @param {string} command
186
+ * @param {{ workdir?: string, projectRoot?: string, timeoutMs?: number, onChunk?: (text: string, stream: 'out'|'err') => void }} [opts]
187
+ * @returns {Promise<{ output: string, code: number }|{ error: string }>}
188
+ */
189
+ function runInSandbox(command, opts) {
190
+ 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
+ const wd = toSandboxPath(o.workdir, o.projectRoot);
197
+ const timeoutMs = o.timeoutMs || 120000;
198
+ return new Promise((resolve) => {
199
+ let child;
200
+ try {
201
+ // sh -lc keeps pipes/redirections working; -i is not needed because
202
+ // every exec is a fresh non-interactive shell.
203
+ child = spawn('docker', ['exec', '-w', wd, CONTAINER, 'sh', '-lc', String(command || '')],
204
+ { windowsHide: true });
205
+ } catch (e) {
206
+ resolve({ error: 'sandbox exec failed: ' + (e && e.message) });
207
+ return;
208
+ }
209
+ const timer = setTimeout(() => {
210
+ try { child.kill('SIGKILL'); } catch {}
211
+ }, timeoutMs);
212
+ let output = '';
213
+ const MAX_OUT = 10 * 1024 * 1024;
214
+ let truncated = false;
215
+ const push = (/** @type {Buffer} */ d, /** @type {'out'|'err'} */ stream) => {
216
+ const t = d.toString();
217
+ if (!truncated) {
218
+ if (output.length + t.length > MAX_OUT) { output = output.slice(0, MAX_OUT); truncated = true; }
219
+ else output += t;
220
+ }
221
+ if (o.onChunk) { try { o.onChunk(t, stream); } catch {} }
222
+ };
223
+ child.stdout.on('data', (d) => push(d, 'out'));
224
+ child.stderr.on('data', (d) => push(d, 'err'));
225
+ child.on('close', (code) => {
226
+ clearTimeout(timer);
227
+ if (truncated) output += '\n[output truncated at 10 MB]';
228
+ resolve({ output: output || 'command completed', code: code == null ? 1 : code });
229
+ });
230
+ child.on('error', (err) => {
231
+ clearTimeout(timer);
232
+ resolve({ error: 'sandbox exec failed: ' + err.message });
233
+ });
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Build the sandbox: pull the chosen OS image, (re)create the container with
239
+ * 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
242
+ * every agent shares the same sandbox; only the mounted project follows the
243
+ * working directory via ensureProjectMount().
244
+ * @param {{ osId?: string, projectRoot?: string, onLog?: (line: string) => void }} [opts]
245
+ * @returns {Promise<{ ok: true, container: string }|{ ok: false, error: string }>}
246
+ */
247
+ async function setupSandbox(opts) {
248
+ const o = opts || {};
249
+ const root = path.resolve(o.projectRoot || process.cwd());
250
+ const log = o.onLog || (() => {});
251
+ const { state, version } = detectDocker();
252
+ if (state === 'not_installed') return { ok: false, error: 'Docker is not installed. Run /sandbox guide for setup steps.' };
253
+ if (state === 'no_daemon') {
254
+ 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.' };
255
+ }
256
+ const osEntry = OS_CATALOG.find((x) => x.id === o.osId) || OS_CATALOG[0];
257
+ const image = osEntry.image;
258
+ 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).');
293
+ }
294
+ }
295
+ const cfg = loadConfig();
296
+ cfg.sandbox = Object.assign({}, cfg.sandbox, {
297
+ enabled: true, pending: false, image, osId: osEntry.id, projectRoot: root,
298
+ });
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)) };
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Make sure the shared container mounts the CURRENT project. Sessions in
310
+ * different directories share one sandbox — when the active project changes
311
+ * we just re-create the container with the new mount (image stays cached).
312
+ * Cheap no-op when the mount already matches.
313
+ * @param {string} [projectRoot]
314
+ * @returns {{ ok: true }|{ ok: false, error: string }}
315
+ */
316
+ function ensureProjectMount(projectRoot) {
317
+ const root = path.resolve(projectRoot || process.cwd());
318
+ const cfg = loadConfig();
319
+ if (!cfg.sandbox || !cfg.sandbox.enabled) return { ok: false, error: 'sandbox disabled' };
320
+ if (cfg.sandbox.projectRoot === root) return { ok: true };
321
+ const image = cfg.sandbox.image || DEFAULT_IMAGE;
322
+ try {
323
+ try { execFileSync('docker', ['rm', '-f', CONTAINER], { windowsHide: true, timeout: 30000, stdio: 'ignore' }); } catch {}
324
+ execFileSync('docker', [
325
+ 'create', '--name', CONTAINER,
326
+ '-v', root + ':/workspace', '-w', '/workspace',
327
+ 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' });
330
+ cfg.sandbox.projectRoot = root;
331
+ saveConfig(cfg);
332
+ return { ok: true };
333
+ } catch (e) {
334
+ return { ok: false, error: 're-mount failed: ' + (e && e.message ? e.message : String(e)) };
335
+ }
336
+ }
337
+
338
+ /**
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.
342
+ * @returns {boolean}
343
+ */
344
+ function shouldUseSandbox() {
345
+ if (process.env.LOOM_SANDBOX === '0') return false;
346
+ const cfg = loadConfig();
347
+ return !!(cfg.sandbox && cfg.sandbox.enabled);
348
+ }
349
+
350
+ /**
351
+ * Toggle sandbox command routing (container keeps running either way).
352
+ * @param {boolean} on
353
+ */
354
+ function setSandboxEnabled(on) {
355
+ const cfg = loadConfig();
356
+ cfg.sandbox = Object.assign({}, cfg.sandbox, { enabled: !!on });
357
+ saveConfig(cfg);
358
+ }
359
+
360
+ /**
361
+ * Arm the "waiting for Docker install" flow: the TUI shows the guide and
362
+ * tells the user to come back with "docker is ready".
363
+ */
364
+ function markInstallPending() {
365
+ const cfg = loadConfig();
366
+ cfg.sandbox = Object.assign({}, cfg.sandbox, { pending: true });
367
+ saveConfig(cfg);
368
+ }
369
+
370
+ /**
371
+ * True when the user's message is the "docker is ready" resume signal while
372
+ * an install is pending (or they explicitly ask to set up the sandbox).
373
+ * @param {string} text
374
+ * @returns {boolean}
375
+ */
376
+ function isReadySignal(text) {
377
+ const t = String(text || '').trim().toLowerCase();
378
+ if (!t || t.length > 120) return false;
379
+ if (t.endsWith('?')) return false; // a question ("is docker ready yet?") is not an announcement
380
+ return /\bdocker\b/.test(t) && /\b(ready|installed|done|running|set ?up|finished)\b/.test(t);
381
+ }
382
+
383
+ module.exports = {
384
+ CONTAINER,
385
+ DEFAULT_IMAGE,
386
+ OS_CATALOG,
387
+ detectDocker,
388
+ installGuide,
389
+ installLink,
390
+ openInstallPage,
391
+ sandboxStatus,
392
+ toSandboxPath,
393
+ runInSandbox,
394
+ setupSandbox,
395
+ ensureProjectMount,
396
+ shouldUseSandbox,
397
+ setSandboxEnabled,
398
+ markInstallPending,
399
+ isReadySignal,
400
+ };