loom-agent 1.2.33 → 1.2.37
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 +118 -0
- package/README.md +14 -30
- package/bin/loom-bun.js +95 -67
- package/bin/loom-tui.js +21 -1
- package/bin/loom.js +0 -10
- package/package.json +14 -9
- package/scripts/postinstall.js +57 -5
- 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 +56 -7
- package/src/core/permissions.js +12 -1
- package/src/core/plugin-cmd.js +1 -0
- package/src/core/session.js +87 -6
- package/src/mcp/mcp-client.js +42 -2
- package/src/skills/skills-manager.js +25 -2
- package/src/tools/browser.js +204 -0
- package/src/tools/index.js +116 -5
- package/src/tui/App.tsx +19 -4
- 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 +31 -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 +4 -2
- 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 -470
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 || '/';
|
|
@@ -460,7 +470,31 @@ class LoomCLI {
|
|
|
460
470
|
}
|
|
461
471
|
|
|
462
472
|
function findBun() {
|
|
463
|
-
//
|
|
473
|
+
// 1. The bundled bun binary shipped via @oven/bun-<platform> optional deps
|
|
474
|
+
// (npm i -g loom-agent brings its own bun — works with zero setup).
|
|
475
|
+
try {
|
|
476
|
+
const PKGS = {
|
|
477
|
+
'win32-x64': 'bun-windows-x64',
|
|
478
|
+
'win32-arm64': 'bun-windows-aarch64',
|
|
479
|
+
'darwin-x64': 'bun-darwin-x64',
|
|
480
|
+
'darwin-arm64': 'bun-darwin-aarch64',
|
|
481
|
+
'linux-x64': 'bun-linux-x64',
|
|
482
|
+
'linux-arm64': 'bun-linux-aarch64',
|
|
483
|
+
};
|
|
484
|
+
const short = PKGS[process.platform + '-' + process.arch];
|
|
485
|
+
if (short) {
|
|
486
|
+
const exe = process.platform === 'win32' ? 'bun.exe' : 'bun';
|
|
487
|
+
const pkgRoot = path.join(__dirname, '..', '..');
|
|
488
|
+
const candidates = [
|
|
489
|
+
path.join(pkgRoot, 'node_modules', '@oven', short, 'bin', exe), // nested
|
|
490
|
+
path.join(pkgRoot, '..', '@oven', short, 'bin', exe), // hoisted
|
|
491
|
+
];
|
|
492
|
+
for (const c of candidates) {
|
|
493
|
+
if (fs.existsSync(c)) return c;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
} catch {}
|
|
497
|
+
// 2. Common installation paths
|
|
464
498
|
const paths = [
|
|
465
499
|
path.join(os.homedir(), '.bun', 'bin', 'bun.exe'),
|
|
466
500
|
path.join(os.homedir(), '.bun', 'bin', 'bun'),
|
|
@@ -471,7 +505,7 @@ function findBun() {
|
|
|
471
505
|
for (const p of paths) {
|
|
472
506
|
if (fs.existsSync(p)) return p;
|
|
473
507
|
}
|
|
474
|
-
// Search PATH (Windows: where.exe, POSIX: which)
|
|
508
|
+
// 3. Search PATH (Windows: where.exe, POSIX: which)
|
|
475
509
|
try {
|
|
476
510
|
const { execSync } = require('child_process');
|
|
477
511
|
const isWin = process.platform === 'win32';
|
|
@@ -485,7 +519,6 @@ async function main() {
|
|
|
485
519
|
const cli = new LoomCLI();
|
|
486
520
|
|
|
487
521
|
const args = process.argv.slice(2);
|
|
488
|
-
const VERSION = require('../../package.json').version;
|
|
489
522
|
|
|
490
523
|
const printIdx = args.indexOf('-p');
|
|
491
524
|
const printLongIdx = args.indexOf('--print');
|
|
@@ -506,8 +539,7 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
506
539
|
console.log(' loom --basic Use basic line-mode REPL (no TUI)');
|
|
507
540
|
console.log(' loom --auto Auto-approve permission prompts');
|
|
508
541
|
console.log(' loom acp ACP server (JSON-RPC over stdio, for editor plugins)');
|
|
509
|
-
console.log(' loom
|
|
510
|
-
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)');
|
|
511
543
|
console.log(' loom -s <session-id> Resume a saved session');
|
|
512
544
|
console.log(' loom --version Show version');
|
|
513
545
|
console.log(' loom --help Show this help');
|
|
@@ -527,6 +559,23 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
527
559
|
return;
|
|
528
560
|
}
|
|
529
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
|
+
|
|
530
579
|
if (sessionId && sIndex !== -1) {
|
|
531
580
|
const data = loadSession(sessionId);
|
|
532
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');
|
package/src/core/session.js
CHANGED
|
@@ -23,6 +23,13 @@ const COMPACT_DEFAULT_THRESHOLD = 0.75;
|
|
|
23
23
|
const COMPACT_KEEP_MESSAGES = 8;
|
|
24
24
|
// Never compact conversations shorter than this.
|
|
25
25
|
const COMPACT_MIN_MESSAGES = 6;
|
|
26
|
+
// Drive-to-completion: how many continuation nudges one turn may spend when
|
|
27
|
+
// the model stops with todos still unfinished (the "stops between tasks"
|
|
28
|
+
// failure). Capped so a model that refuses to update its todos cannot spin.
|
|
29
|
+
const MAX_DRIVE_NUDGES = 5;
|
|
30
|
+
// Transient provider failures (network blip, 5xx, stream reset) retry the
|
|
31
|
+
// model call this many times before the turn surfaces an error.
|
|
32
|
+
const PROVIDER_RETRIES = 2;
|
|
26
33
|
|
|
27
34
|
/**
|
|
28
35
|
* @typedef {Object} SpeedStats
|
|
@@ -244,6 +251,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
244
251
|
- If the user wants code changes, tell them to switch to Build mode (Tab or /build) and resend.` : ''}${this.config.outputStyle ? `\n\n## Output style\n${this.config.outputStyle}` : ''}`;
|
|
245
252
|
}
|
|
246
253
|
|
|
254
|
+
// System-prompt suffix for the active PRIMARY agent (build/plan/chat): each
|
|
255
|
+
// primary can carry a role prompt (the Build orchestrator instructions, the
|
|
256
|
+
// Plan agent role, …) that appends after the base prompt. Child sessions and
|
|
257
|
+
// @agent turns already use _agentBlock, and primaries never set this.agent,
|
|
258
|
+
// so the two cannot stack. Resolved lazily so per-turn config changes apply.
|
|
259
|
+
_primaryBlock() {
|
|
260
|
+
if (this._isChild || this.agent) return '';
|
|
261
|
+
let agent = null;
|
|
262
|
+
try { agent = require('./agents').resolveAgent(this.mode); } catch { return ''; }
|
|
263
|
+
if (!agent || !agent.prompt) return '';
|
|
264
|
+
return '\n\n## Acting as the "' + agent.name + '" agent\n' + agent.prompt + '\n';
|
|
265
|
+
}
|
|
266
|
+
|
|
247
267
|
// Create ./LOOM.md with a template when missing, so memory exists from the
|
|
248
268
|
// first turn (Claude Code behavior). Disable with LOOM_MEM_AUTO=0 (tests).
|
|
249
269
|
ensureMemoryFile() {
|
|
@@ -542,13 +562,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
542
562
|
interrupted: true,
|
|
543
563
|
});
|
|
544
564
|
}
|
|
545
|
-
return { type: 'text', content: streamed || '(interrupted)', interrupted: true };
|
|
565
|
+
return { type: 'text', content: streamed || '(interrupted before any text was streamed)', interrupted: true };
|
|
546
566
|
};
|
|
547
567
|
|
|
548
568
|
// No tool-use cap: the model decides when a turn is finished; the user can
|
|
549
569
|
// always Esc-interrupt a runaway loop.
|
|
550
570
|
let ranTools = false;
|
|
551
571
|
let nudged = false;
|
|
572
|
+
let toolCallsThisTurn = 0;
|
|
573
|
+
// Drive-to-completion state (see the no-toolCalls branch below).
|
|
574
|
+
let driveNudges = 0;
|
|
575
|
+
let todowroteThisTurn = false;
|
|
576
|
+
// Transient provider-error retry state.
|
|
577
|
+
let providerRetries = 0;
|
|
552
578
|
while (true) {
|
|
553
579
|
if (this.interrupted) {
|
|
554
580
|
return finishInterrupted();
|
|
@@ -575,6 +601,16 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
575
601
|
continue;
|
|
576
602
|
}
|
|
577
603
|
}
|
|
604
|
+
// Transient provider failure (network blip, 5xx, stream reset): retry
|
|
605
|
+
// the model call a couple of times instead of killing the turn
|
|
606
|
+
// mid-task. No tools ran for this iteration, so a retry is side-effect
|
|
607
|
+
// free.
|
|
608
|
+
if (providerRetries < PROVIDER_RETRIES) {
|
|
609
|
+
providerRetries++;
|
|
610
|
+
await new Promise((resolve) => setTimeout(resolve, 400 * providerRetries));
|
|
611
|
+
if (this.interrupted) return finishInterrupted();
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
578
614
|
return { type: 'error', content: err.message };
|
|
579
615
|
}
|
|
580
616
|
|
|
@@ -611,15 +647,59 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
611
647
|
// Silent-stop guard: models occasionally end a turn with an EMPTY
|
|
612
648
|
// reply right after running tools (truncation / provider hiccup),
|
|
613
649
|
// which reads to the user like "the agent just stopped". One
|
|
614
|
-
// automatic continuation instead of dead-ending with
|
|
650
|
+
// automatic continuation instead of dead-ending with a placeholder.
|
|
615
651
|
if (!text.trim() && ranTools && !nudged && !this.interrupted) {
|
|
616
652
|
nudged = true;
|
|
617
|
-
this.addMessage({
|
|
653
|
+
this.addMessage({
|
|
654
|
+
role: 'user',
|
|
655
|
+
content: '(the model returned an empty reply after ' + toolCallsThisTurn +
|
|
656
|
+
' tool call(s) — continue where you left off and finish the task)',
|
|
657
|
+
});
|
|
618
658
|
continue;
|
|
619
659
|
}
|
|
620
|
-
|
|
660
|
+
// Drive-to-completion guard: the model stopped calling tools in the
|
|
661
|
+
// middle of tracked work (todos written this turn still
|
|
662
|
+
// pending/in_progress). Inject one continuation that quotes the
|
|
663
|
+
// model's ACTUAL last message and lists the unfinished items — up to
|
|
664
|
+
// MAX_DRIVE_NUDGES times — so a premature summary does not strand the
|
|
665
|
+
// task. The model can stop cleanly by completing or cancelling the
|
|
666
|
+
// items via todowrite. Plan/chat modes are excluded (plan drafts are
|
|
667
|
+
// meant to end with pending steps).
|
|
668
|
+
if (!this.interrupted && this.mode === 'build' && ranTools && todowroteThisTurn && driveNudges < MAX_DRIVE_NUDGES) {
|
|
669
|
+
const unfinished = (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress');
|
|
670
|
+
if (unfinished.length) {
|
|
671
|
+
driveNudges++;
|
|
672
|
+
const list = unfinished.slice(0, 20).map((t) => '- [' + t.status + '] ' + t.content).join('\n');
|
|
673
|
+
// Quote what the model actually said — a canned nudge alone gives
|
|
674
|
+
// the user (and the model) no clue WHERE the turn went off the rails.
|
|
675
|
+
const lastReply = text.replace(/\s+/g, ' ').trim();
|
|
676
|
+
const snippet = lastReply
|
|
677
|
+
? '\nYour last message was: "' + (lastReply.length > 240 ? lastReply.slice(0, 240) + '…' : lastReply) + '"'
|
|
678
|
+
: '';
|
|
679
|
+
this.addMessage({
|
|
680
|
+
role: 'user',
|
|
681
|
+
content: '(unfinished work detected: ' + unfinished.length + ' todo(s) still open:\n' + list + snippet +
|
|
682
|
+
'\nYour reply stopped the turn, but the work above is not done — continue exactly where you left off and finish it. ' +
|
|
683
|
+
'Only stop when every item is completed or cancelled via todowrite, or when something concrete blocks you — then name the blocker.)',
|
|
684
|
+
});
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// Surface WHAT is still open when a build-mode turn that WROTE todos
|
|
689
|
+
// ends with work remaining (same signal as the nudge above), so the
|
|
690
|
+
// UI can show the real leftovers instead of a generic "stopped"
|
|
691
|
+
// placeholder. Stale todos from older turns stay silent.
|
|
692
|
+
const openTodos = (this.mode === 'build' && todowroteThisTurn)
|
|
693
|
+
? (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress')
|
|
694
|
+
: [];
|
|
695
|
+
return {
|
|
696
|
+
type: 'text',
|
|
697
|
+
content: text || '(the model ended the turn without any text)',
|
|
698
|
+
openTodos: openTodos.length ? openTodos : undefined,
|
|
699
|
+
};
|
|
621
700
|
}
|
|
622
701
|
ranTools = true;
|
|
702
|
+
toolCallsThisTurn += toolCalls.length;
|
|
623
703
|
|
|
624
704
|
// Execute independent tool calls in parallel; results are appended in the
|
|
625
705
|
// original call order so the conversation history stays deterministic.
|
|
@@ -659,7 +739,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
659
739
|
// are checked separately where they apply.
|
|
660
740
|
const permKey = { read:'read', edit:'edit', write:'edit', glob:'glob', grep:'grep', bash:'bash',
|
|
661
741
|
task:'task', skill:'skill', lsp:'lsp', question:'question', ask:'question', webfetch:'webfetch',
|
|
662
|
-
websearch:'websearch' }[tc.name];
|
|
742
|
+
websearch:'websearch', browser:'browser' }[tc.name];
|
|
663
743
|
const isMcpAdd = tc.name === 'mcp' && tc.input && tc.input.action === 'add';
|
|
664
744
|
if (permKey || isMcpAdd) {
|
|
665
745
|
const arg = this.permissions.permissionArg(tc.name, tc.input || {});
|
|
@@ -734,6 +814,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
734
814
|
this.addMessage({ role: 'tool', toolCallId: tc.id, content: text });
|
|
735
815
|
// Persist real todo state when the model uses the todowrite tool.
|
|
736
816
|
if (tc.name === 'todowrite' && !outcome.error) {
|
|
817
|
+
todowroteThisTurn = true;
|
|
737
818
|
this.setTodos(tc.input && tc.input.todos);
|
|
738
819
|
// Notify observers (sidebar, etc.) over the tiny event bus — the TUI
|
|
739
820
|
// subscribes once and the panel updates without a re-render cycle.
|
|
@@ -805,7 +886,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
805
886
|
maxTokens: this.config.maxTokens || 8192,
|
|
806
887
|
temperature: this.config.temperature ?? 0.7,
|
|
807
888
|
tools: toolDefs,
|
|
808
|
-
system: this.systemPrompt + (this._skillBlock || '') + (this._agentBlock || ''),
|
|
889
|
+
system: this.systemPrompt + (this._skillBlock || '') + (this._agentBlock || '') + this._primaryBlock(),
|
|
809
890
|
signal: this.abortController?.signal,
|
|
810
891
|
// Extended thinking: models tagged 'reasoning' reason BEFORE every
|
|
811
892
|
// reply — including after each tool result — so complex tasks get
|
package/src/mcp/mcp-client.js
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
const { spawn } = require('child_process');
|
|
2
|
+
// Minimal OS/runtime baseline handed to every spawned MCP server (see note in
|
|
3
|
+
// connectToJson). Explicit cfg.env keys are layered on top per server.
|
|
4
|
+
const MCP_ENV_KEYS = [
|
|
5
|
+
'PATH', 'PATHEXT', 'COMSPEC', 'SystemRoot', 'windir', 'SystemDrive',
|
|
6
|
+
'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
|
7
|
+
'PROGRAMFILES', 'PROGRAMDATA', 'ALLUSERSPROFILE', 'COMMONPROGRAMFILES',
|
|
8
|
+
'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS', 'OS',
|
|
9
|
+
'COMPUTERNAME', 'USERNAME', 'LANG', 'TZ', 'TERM', 'SHELL',
|
|
10
|
+
'XDG_CONFIG_HOME', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
|
|
11
|
+
];
|
|
12
|
+
/** @returns {Record<string,string>} filtered copy of process.env */
|
|
13
|
+
function buildMcpBaseEnv() {
|
|
14
|
+
const out = /** @type {Record<string,string>} */ ({});
|
|
15
|
+
for (const k of MCP_ENV_KEYS) {
|
|
16
|
+
const v = process.env[k];
|
|
17
|
+
if (v !== undefined) out[k] = v;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
const MCP_BASE_ENV = buildMcpBaseEnv();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compose the environment for one MCP server process: the filtered baseline
|
|
25
|
+
* plus whatever that server's config explicitly declares (cfg.env wins).
|
|
26
|
+
* @param {{env?: Record<string,string|undefined>|null}} cfg
|
|
27
|
+
* @returns {Record<string,string>}
|
|
28
|
+
*/
|
|
29
|
+
function mcpSpawnEnv(cfg) {
|
|
30
|
+
return Object.assign({}, MCP_BASE_ENV, cfg.env || {});
|
|
31
|
+
}
|
|
2
32
|
const { loadServers } = require('./mcp-manager');
|
|
3
33
|
|
|
4
34
|
let toolCachePromise = null;
|
|
@@ -22,9 +52,19 @@ function killTree(child) {
|
|
|
22
52
|
|
|
23
53
|
function connectToJson(cfg, timeoutMs) {
|
|
24
54
|
return new Promise((resolve, reject) => {
|
|
55
|
+
// MCP servers are third-party processes. Inheriting the parent's FULL
|
|
56
|
+
// environment would hand every provider API key / cloud token / cookie in
|
|
57
|
+
// the user's shell to whatever npm package a config spawns. Instead pass a
|
|
58
|
+
// minimal OS/runtime baseline plus only what cfg.env explicitly declares —
|
|
59
|
+
// per-server secrets belong in the server's own env config, not leaked by
|
|
60
|
+
// inheritance. (network proxies forwarded; they carry routing, not auth.)
|
|
61
|
+
// Per-server environment via allowlist + explicit cfg.env only — see
|
|
62
|
+
// buildMcpBaseEnv/mcpSpawnEnv above; inheriting all of process.env would
|
|
63
|
+
// leak provider keys to every third-party server.
|
|
64
|
+
const env = mcpSpawnEnv(cfg);
|
|
25
65
|
const child = spawn(cfg.command, cfg.args || [], {
|
|
26
66
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
27
|
-
env
|
|
67
|
+
env,
|
|
28
68
|
// No console window for stdio MCP servers: on Windows spawn() would
|
|
29
69
|
// otherwise pop a flashing console up and down while chats happen.
|
|
30
70
|
windowsHide: true,
|
|
@@ -198,4 +238,4 @@ function getCachedTools() {
|
|
|
198
238
|
return warm();
|
|
199
239
|
}
|
|
200
240
|
|
|
201
|
-
module.exports = { getTools, getCachedTools, clearCache, buildToolName, callTool, warm, callRpc, killTree };
|
|
241
|
+
module.exports = { getTools, getCachedTools, clearCache, buildToolName, callTool, warm, callRpc, killTree, buildMcpBaseEnv, mcpSpawnEnv };
|
|
@@ -11,6 +11,19 @@ function validateUrl(url) {
|
|
|
11
11
|
} catch { return false; }
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Reduce an install target to a single safe folder name under ~/.loom/skills.
|
|
16
|
+
* Strict allowlist (alnum/_/./-, ≤64 chars): drives, separators and '..'
|
|
17
|
+
* segments are rejected outright, never silently collapsed, so remote-provided
|
|
18
|
+
* names can't rename unexpectedly or point outside the skills directory.
|
|
19
|
+
* @param {unknown} name
|
|
20
|
+
* @returns {string|null} cleaned single-segment name, or null when unusable
|
|
21
|
+
*/
|
|
22
|
+
function safeSkillName(name) {
|
|
23
|
+
const s = typeof name === 'string' ? name.trim() : '';
|
|
24
|
+
return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(s) ? s : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
const LOOM_DIR = path.join(os.homedir(), '.loom');
|
|
15
28
|
|
|
16
29
|
function globalSkillsDir() {
|
|
@@ -111,8 +124,17 @@ function installFrom(srcDir, targetName) {
|
|
|
111
124
|
if (!fs.existsSync(path.join(src, 'SKILL.md'))) {
|
|
112
125
|
return { error: `No SKILL.md in ${src}` };
|
|
113
126
|
}
|
|
114
|
-
const name = targetName
|
|
115
|
-
|
|
127
|
+
const name = safeSkillName(targetName != null ? targetName : path.basename(src));
|
|
128
|
+
if (!name) {
|
|
129
|
+
return { error: `Invalid skill name: ${JSON.stringify(targetName)} — use letters/digits/-/_ only` };
|
|
130
|
+
}
|
|
131
|
+
const destRoot = globalSkillsDir();
|
|
132
|
+
const dest = path.join(destRoot, name);
|
|
133
|
+
// Paranoia check: `name` is already a sanitized single segment, so this can
|
|
134
|
+
// not trip short of path.join behavior changing underneath us.
|
|
135
|
+
if (path.resolve(dest) !== path.join(path.resolve(destRoot), name)) {
|
|
136
|
+
return { error: 'Invalid install location' };
|
|
137
|
+
}
|
|
116
138
|
if (fs.existsSync(dest)) {
|
|
117
139
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
118
140
|
}
|
|
@@ -210,4 +232,5 @@ module.exports = {
|
|
|
210
232
|
globalSkillsDir,
|
|
211
233
|
agentsSkillsDir,
|
|
212
234
|
projectSkillsDir,
|
|
235
|
+
safeSkillName,
|
|
213
236
|
};
|