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.
- package/CHANGELOG.md +128 -0
- package/README.md +38 -56
- package/bin/loom-bun.js +2 -10
- package/bin/loom.js +0 -10
- package/package.json +126 -126
- package/src/connectors/convex.js +356 -0
- package/src/connectors/deploy.js +390 -0
- package/src/connectors/index.js +23 -0
- package/src/connectors/supabase.js +279 -0
- package/src/core/agents.js +32 -2
- package/src/core/cli.js +30 -5
- package/src/core/permissions.js +12 -1
- package/src/core/plugin-cmd.js +1 -0
- package/src/core/sandbox.js +400 -0
- package/src/core/session-store.js +245 -167
- package/src/core/session.js +157 -11
- package/src/providers/anthropic.js +10 -5
- package/src/providers/openai-compat.js +12 -4
- package/src/tools/browser.js +204 -0
- package/src/tools/index.js +35 -1
- package/src/tui/App.tsx +103 -8
- package/src/tui/components/BreadcrumbBar.tsx +10 -9
- package/src/tui/components/ChatArea.tsx +3 -3
- package/src/tui/components/InputBar.tsx +22 -11
- package/src/tui/components/MdText.tsx +2 -1
- package/src/tui/components/Modals.tsx +191 -17
- package/src/tui/components/PermissionPopup.tsx +15 -12
- package/src/tui/components/Sidebar.tsx +6 -2
- package/src/tui/components/SplashScreen.tsx +17 -24
- package/src/tui/components/SubagentPanel.tsx +1 -1
- package/src/tui/components/ToastOverlay.tsx +23 -12
- package/src/tui/store.ts +38 -35
- package/src/tui/theme.ts +45 -30
- package/src/tui/tool-display.ts +13 -2
- package/docs/web.md +0 -205
- package/src/web/attach.js +0 -242
- package/src/web/graph-view.html +0 -262
- package/src/web/index.html +0 -824
- package/src/web/web-server.js +0 -535
package/src/core/agents.js
CHANGED
|
@@ -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: '
|
|
63
|
+
description: 'Main orchestrator agent: full development work with all tools, drives tasks to completion.',
|
|
64
64
|
tools: ['*'],
|
|
65
|
-
prompt:
|
|
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
|
|
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
|
-
|
|
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
|
|
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) {
|
package/src/core/permissions.js
CHANGED
|
@@ -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
|
}
|
package/src/core/plugin-cmd.js
CHANGED
|
@@ -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
|
+
};
|