loom-agent 1.3.3 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +236 -1
- package/README.md +9 -7
- package/package.json +2 -2
- package/src/config/settings.js +10 -5
- package/src/core/agents.js +14 -1
- package/src/core/cli.js +30 -2
- package/src/core/hooks.js +28 -14
- package/src/core/memory.js +54 -6
- package/src/core/permissions.js +17 -4
- package/src/core/plugin-cmd.js +88 -1
- package/src/core/sandbox.js +61 -1
- package/src/core/session.js +272 -37
- package/src/core/transcript-undo.js +74 -0
- package/src/core/update.js +116 -6
- package/src/mcp/connector-presets.js +105 -0
- package/src/providers/anthropic.js +127 -25
- package/src/providers/google.js +19 -11
- package/src/providers/nvidia.js +54 -33
- package/src/providers/openai-compat.js +59 -27
- package/src/providers/openai.js +45 -18
- package/src/providers/openrouter.js +56 -26
- package/src/providers/tokenrouter.js +4 -1
- package/src/tools/index.js +193 -30
- package/src/tui/App.tsx +249 -35
- package/src/tui/components/Modals.tsx +64 -19
- package/src/tui/components/SubagentPanel.tsx +1 -1
- package/src/tui/mcp-presets.ts +4 -65
- package/src/tui/store.ts +6 -1
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', 'browser',
|
|
37
|
+
'webfetch', 'websearch', 'browser', 'mcp',
|
|
38
38
|
'external_directory', 'doom_loop',
|
|
39
39
|
];
|
|
40
40
|
|
|
@@ -45,6 +45,11 @@ const DEFAULT_PERMISSIONS = {
|
|
|
45
45
|
task: 'ask',
|
|
46
46
|
skill: 'ask',
|
|
47
47
|
question: 'ask',
|
|
48
|
+
// MCP tools can do anything their server exposes (send emails, modify
|
|
49
|
+
// cloud infra, run queries) — prompt by default. Patterns match the full
|
|
50
|
+
// tool name (mcp__<server>__<tool>), so the user can allow one server or
|
|
51
|
+
// tool precisely: {"permission": {"mcp": {"mcp__github__*": "allow"}}}.
|
|
52
|
+
mcp: 'ask',
|
|
48
53
|
doom_loop: 'ask',
|
|
49
54
|
external_directory: 'ask',
|
|
50
55
|
read: {
|
|
@@ -73,6 +78,7 @@ const TOOL_TO_PERMISSION = {
|
|
|
73
78
|
glob: 'glob',
|
|
74
79
|
grep: 'grep',
|
|
75
80
|
bash: 'bash',
|
|
81
|
+
sandbox: 'bash', // same command gate as bash (danger heuristics apply)
|
|
76
82
|
task: 'task',
|
|
77
83
|
skill: 'skill',
|
|
78
84
|
lsp: 'lsp',
|
|
@@ -82,7 +88,9 @@ const TOOL_TO_PERMISSION = {
|
|
|
82
88
|
websearch: 'websearch',
|
|
83
89
|
browser: 'browser',
|
|
84
90
|
todowrite: null, // never prompts
|
|
85
|
-
|
|
91
|
+
todoread: 'read', // read-only todo inspection
|
|
92
|
+
tasks: null, // list/output/kill of the session's own background tasks
|
|
93
|
+
mcp: null, // wired by the session (mcp__* tools + the "add" action prompt)
|
|
86
94
|
};
|
|
87
95
|
|
|
88
96
|
function expandHome(p, home) {
|
|
@@ -213,7 +221,11 @@ class PermissionManager {
|
|
|
213
221
|
* 'allow' | 'ask' | 'deny'.
|
|
214
222
|
*/
|
|
215
223
|
resolve(toolName, arg) {
|
|
216
|
-
|
|
224
|
+
// mcp__<server>__<tool> names resolve through the 'mcp' permission key
|
|
225
|
+
// (patterns match the full tool name).
|
|
226
|
+
const key = (toolName && toolName.indexOf('mcp__') === 0)
|
|
227
|
+
? 'mcp'
|
|
228
|
+
: (TOOL_TO_PERMISSION[toolName] !== undefined ? TOOL_TO_PERMISSION[toolName] : toolName);
|
|
217
229
|
if (!key) return 'allow';
|
|
218
230
|
|
|
219
231
|
// Safety heuristics always run for dangerous shell commands — even when
|
|
@@ -298,7 +310,8 @@ class PermissionManager {
|
|
|
298
310
|
const cwd = cwdOverride || this.cwd || process.cwd();
|
|
299
311
|
const inp = input || {};
|
|
300
312
|
switch (toolName) {
|
|
301
|
-
case 'bash':
|
|
313
|
+
case 'bash':
|
|
314
|
+
case 'sandbox': return String(inp.command ?? inp.args ?? '');
|
|
302
315
|
case 'read': return normPathArg(inp.filePath || inp.path || '', cwd);
|
|
303
316
|
case 'edit':
|
|
304
317
|
case 'write': return normPathArg(inp.filePath || inp.path || '', cwd);
|
package/src/core/plugin-cmd.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { listSkills, installSkill, removeSkill } = require('../skills/skills-manager');
|
|
2
2
|
const { loadServers, addServer, parseMcpAddArgs, removeServer, listServers, toggleServer, seedDefaults } = require('../mcp/mcp-manager');
|
|
3
|
+
const { CONNECTOR_PRESETS } = require('../mcp/connector-presets');
|
|
3
4
|
const sessionStore = require('./session-store');
|
|
4
5
|
|
|
5
6
|
// Quote-aware CLI tokenizer for slash commands, so paths with spaces survive:
|
|
@@ -124,8 +125,9 @@ function mcpHelp() {
|
|
|
124
125
|
'Example (claude-compatible): /mcp add stm32 -- "C:\\stm32-mcp\\.venv\\Scripts\\python.exe" -m stm32_mcp.server',
|
|
125
126
|
'Example with env: /mcp add -e BRAVE_API_KEY=x brave-search -- npx -y @modelcontextprotocol/server-brave-search',
|
|
126
127
|
'From the shell (same syntax): loom add mcp [-e KEY=V] <name> [--] <command> [args...]',
|
|
128
|
+
'Connectors from the shell: loom add connector <preset> [-e KEY=V]... (or the same one-liner form)',
|
|
127
129
|
'The "--" separator is optional; quote any path with spaces. Env keys are usable as $KEY in args.',
|
|
128
|
-
'In the /mcp and /connectors browsers, press A to add with the same one-liner syntax.',
|
|
130
|
+
'In the /mcp and /connectors browsers, press A (or Enter on the "+ Add" row) to add with the same one-liner syntax.',
|
|
129
131
|
].join('\n');
|
|
130
132
|
}
|
|
131
133
|
|
|
@@ -172,6 +174,89 @@ function mcpAddLineCmd(line) {
|
|
|
172
174
|
return addServerFromArgv(argv);
|
|
173
175
|
}
|
|
174
176
|
|
|
177
|
+
function connectorUsage() {
|
|
178
|
+
return [
|
|
179
|
+
'Usage: /connectors add <preset> [-e KEY=V]... (presets: ' + CONNECTOR_PRESETS.map(p => p.id).join(', ') + ')',
|
|
180
|
+
' or: /connectors add [-e KEY=V] <name> [--] <command> [args...]',
|
|
181
|
+
'Example: /connectors add supabase -e SUPABASE_ACCESS_TOKEN=xxx',
|
|
182
|
+
].join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// /connectors add + `loom add connector` — preset-aware one-line add. A bare
|
|
186
|
+
// preset name expands to the full one-liner (env from -e flags anywhere, any
|
|
187
|
+
// leftover tokens appended as trailing args, e.g. --project-ref X); anything
|
|
188
|
+
// else falls through to the generic mcp one-liner. A connector IS an MCP
|
|
189
|
+
// server — only the wording differs. A missing required key is an error, not
|
|
190
|
+
// a silently broken server.
|
|
191
|
+
function connectorAddFromArgv(argvIn) {
|
|
192
|
+
let argv = argvIn.slice();
|
|
193
|
+
if (argv[0] === 'add') argv = argv.slice(1);
|
|
194
|
+
else if ((argv[0] === 'connector' || argv[0] === 'connectors') && argv[1] === 'add') argv = argv.slice(2);
|
|
195
|
+
else if (argv[0] === 'connector' || argv[0] === 'connectors') argv = argv.slice(1);
|
|
196
|
+
if (!argv.length) return connectorUsage();
|
|
197
|
+
|
|
198
|
+
// Collect -e/--env KEY=V pairs from the front while looking for a name.
|
|
199
|
+
const env = /** @type {Record<string, string>} */ ({});
|
|
200
|
+
let i = 0;
|
|
201
|
+
while (i + 1 < argv.length && (argv[i] === '-e' || argv[i] === '--env')) {
|
|
202
|
+
const kv = String(argv[i + 1]);
|
|
203
|
+
const eq = kv.indexOf('=');
|
|
204
|
+
if (eq > 0) env[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
205
|
+
i += 2;
|
|
206
|
+
}
|
|
207
|
+
const name = i < argv.length ? String(argv[i]) : '';
|
|
208
|
+
// Explicit "--" means the user wrote a full custom one-liner — never expand.
|
|
209
|
+
const preset = argv.indexOf('--') === -1 ? CONNECTOR_PRESETS.find(p => p.id === name) : undefined;
|
|
210
|
+
if (!preset) {
|
|
211
|
+
const msg = addServerFromArgv(argv);
|
|
212
|
+
return typeof msg === 'string' ? msg.replace('Added MCP server', 'Added connector') : msg;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Preset form: env flags anywhere, leftover tokens appended as extra args.
|
|
216
|
+
const extras = /** @type {string[]} */ ([]);
|
|
217
|
+
for (let j = i + 1; j < argv.length; j++) {
|
|
218
|
+
const a = argv[j];
|
|
219
|
+
if ((a === '-e' || a === '--env') && j + 1 < argv.length) {
|
|
220
|
+
const kv = String(argv[j + 1]);
|
|
221
|
+
const eq = kv.indexOf('=');
|
|
222
|
+
if (eq > 0) env[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
223
|
+
j++;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
extras.push(String(a));
|
|
227
|
+
}
|
|
228
|
+
// Required keys = every prompt the guided TUI flow would ask for.
|
|
229
|
+
const missing = (preset.prompts || []).map(p => p.key).filter(k => !env[k]);
|
|
230
|
+
if (missing.length) {
|
|
231
|
+
const hints = (preset.prompts || [])
|
|
232
|
+
.filter(p => missing.indexOf(p.key) !== -1)
|
|
233
|
+
.map(p => ' -e ' + p.key + '=... ' + p.label);
|
|
234
|
+
const firstKey = (preset.prompts.find(p => p.key === missing[0]) || { key: missing[0] }).key;
|
|
235
|
+
return 'Missing key for ' + preset.label + ':\n' + hints.join('\n') +
|
|
236
|
+
'\nExample: /connectors add ' + preset.id + ' -e ' + firstKey + '=...';
|
|
237
|
+
}
|
|
238
|
+
// Build the claude-style one-liner argv and reuse the shared engine
|
|
239
|
+
// ($KEY resolution, npx wrapping, persistence).
|
|
240
|
+
const out = /** @type {string[]} */ ([]);
|
|
241
|
+
for (const k of Object.keys(env)) out.push('-e', k + '=' + env[k]);
|
|
242
|
+
out.push(preset.id, '--');
|
|
243
|
+
if (preset.command) out.push(preset.command, ...(preset.args || []));
|
|
244
|
+
else out.push('npx', '-y', String(preset.package), ...(preset.args || []));
|
|
245
|
+
for (const x of extras) out.push(x);
|
|
246
|
+
const msg = addServerFromArgv(out);
|
|
247
|
+
return typeof msg === 'string' ? msg.replace('Added MCP server', 'Added connector') : msg;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// argv-array flavor for /connectors add (App.tsx slash args).
|
|
251
|
+
function connectorAddCmd(args) {
|
|
252
|
+
return connectorAddFromArgv(args);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Single-raw-line flavor for `loom add connector` and the one-line add input.
|
|
256
|
+
function connectorAddLineCmd(line) {
|
|
257
|
+
return connectorAddFromArgv(tokenizeCli(String(line || '').trim()));
|
|
258
|
+
}
|
|
259
|
+
|
|
175
260
|
function mcpRemoveCmd(args) {
|
|
176
261
|
const name = args[0];
|
|
177
262
|
if (!name) return 'Usage: /mcp remove <name>';
|
|
@@ -365,6 +450,8 @@ module.exports = {
|
|
|
365
450
|
listMcpText,
|
|
366
451
|
mcpAddCmd,
|
|
367
452
|
mcpAddLineCmd,
|
|
453
|
+
connectorAddCmd,
|
|
454
|
+
connectorAddLineCmd,
|
|
368
455
|
mcpRemoveCmd,
|
|
369
456
|
mcpToggleCmd,
|
|
370
457
|
diffCmd,
|
package/src/core/sandbox.js
CHANGED
|
@@ -74,6 +74,28 @@ function detectDocker(runner) {
|
|
|
74
74
|
return { state: 'ready', version };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* NON-BLOCKING Docker detection (spawn-based twin of detectDocker). The TUI
|
|
79
|
+
* must never call the sync version — `docker version` can hang 10s when the
|
|
80
|
+
* daemon is starting, which froze the whole interface. Injectable async
|
|
81
|
+
* runner for tests: (cmd, args) => Promise<{code, out}>.
|
|
82
|
+
* @param {(cmd: string, args: string[]) => Promise<{ code: number, out: string }>} [runner]
|
|
83
|
+
* @returns {Promise<{ state: DockerState, version: string }>}
|
|
84
|
+
*/
|
|
85
|
+
async function detectDockerAsync(runner) {
|
|
86
|
+
const run = runner || ((/** @type {string} */ cmd, /** @type {string[]} */ args) =>
|
|
87
|
+
spawnAsync(cmd, args, { timeoutMs: 10000 }));
|
|
88
|
+
const server = await run('docker', ['version', '--format', '{{.Server.Version}}']);
|
|
89
|
+
if (server.code === 0) {
|
|
90
|
+
const version = String(server.out || '').trim();
|
|
91
|
+
if (version) return { state: 'ready', version };
|
|
92
|
+
return { state: 'no_daemon', version: '' };
|
|
93
|
+
}
|
|
94
|
+
const cli = await run('docker', ['--version']);
|
|
95
|
+
if (cli.code === 0) return { state: 'no_daemon', version: '' };
|
|
96
|
+
return { state: 'not_installed', version: '' };
|
|
97
|
+
}
|
|
98
|
+
|
|
77
99
|
/**
|
|
78
100
|
* Platform-appropriate Docker install guide shown before the user leaves to
|
|
79
101
|
* install. Mentions the restart when the installer requires one, and the
|
|
@@ -165,6 +187,41 @@ function sandboxStatus() {
|
|
|
165
187
|
};
|
|
166
188
|
}
|
|
167
189
|
|
|
190
|
+
/**
|
|
191
|
+
* NON-BLOCKING twin of sandboxStatus (spawn-based docker checks). Use this
|
|
192
|
+
* from the TUI — the sync version freezes the interface while docker answers.
|
|
193
|
+
* @returns {Promise<ReturnType<typeof sandboxStatus>>}
|
|
194
|
+
*/
|
|
195
|
+
async function sandboxStatusAsync() {
|
|
196
|
+
const cfg = loadConfig();
|
|
197
|
+
const sandboxCfg = cfg.sandbox || {};
|
|
198
|
+
const { state, version } = await detectDockerAsync();
|
|
199
|
+
let container = /** @type {'running'|'stopped'|'absent'} */ ('absent');
|
|
200
|
+
if (state === 'ready') {
|
|
201
|
+
const insp = await spawnAsync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], { timeoutMs: 10000 });
|
|
202
|
+
if (insp.code === 0) container = insp.out.trim() === 'true' ? 'running' : 'stopped';
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
docker: state,
|
|
206
|
+
dockerVersion: version,
|
|
207
|
+
enabled: !!sandboxCfg.enabled && container === 'running',
|
|
208
|
+
pending: !!sandboxCfg.pending,
|
|
209
|
+
container,
|
|
210
|
+
image: sandboxCfg.image || DEFAULT_IMAGE,
|
|
211
|
+
osId: sandboxCfg.osId || 'alpine',
|
|
212
|
+
projectRoot: sandboxCfg.projectRoot || '',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Config-only pending check (no docker involved — zero cost, never blocks).
|
|
218
|
+
* @returns {boolean}
|
|
219
|
+
*/
|
|
220
|
+
function sandboxPending() {
|
|
221
|
+
const cfg = loadConfig();
|
|
222
|
+
return !!(cfg.sandbox && cfg.sandbox.pending);
|
|
223
|
+
}
|
|
224
|
+
|
|
168
225
|
/**
|
|
169
226
|
* Map a host working directory to its path inside the container. The project
|
|
170
227
|
* root is mounted at /workspace; subdirectories map underneath it. Anything
|
|
@@ -348,7 +405,7 @@ async function setupSandbox(opts) {
|
|
|
348
405
|
const root = path.resolve(o.projectRoot || process.cwd());
|
|
349
406
|
const log = o.onLog || (() => {});
|
|
350
407
|
const prog = o.onProgress || (() => {});
|
|
351
|
-
const { state, version } =
|
|
408
|
+
const { state, version } = await detectDockerAsync();
|
|
352
409
|
if (state === 'not_installed') return { ok: false, error: 'Docker is not installed. Run /sandbox guide for setup steps.' };
|
|
353
410
|
if (state === 'no_daemon') {
|
|
354
411
|
return { ok: false, error: 'Docker is installed but the daemon is not running. Start Docker Desktop (or `sudo systemctl start docker`), then run /sandbox setup again.' };
|
|
@@ -558,10 +615,13 @@ module.exports = {
|
|
|
558
615
|
DEFAULT_IMAGE,
|
|
559
616
|
OS_CATALOG,
|
|
560
617
|
detectDocker,
|
|
618
|
+
detectDockerAsync,
|
|
561
619
|
installGuide,
|
|
562
620
|
installLink,
|
|
563
621
|
openInstallPage,
|
|
564
622
|
sandboxStatus,
|
|
623
|
+
sandboxStatusAsync,
|
|
624
|
+
sandboxPending,
|
|
565
625
|
toSandboxPath,
|
|
566
626
|
runInSandbox,
|
|
567
627
|
setupSandbox,
|