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.
Files changed (39) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +14 -30
  3. package/bin/loom-bun.js +95 -67
  4. package/bin/loom-tui.js +21 -1
  5. package/bin/loom.js +0 -10
  6. package/package.json +14 -9
  7. package/scripts/postinstall.js +57 -5
  8. package/src/connectors/convex.js +356 -0
  9. package/src/connectors/deploy.js +390 -0
  10. package/src/connectors/index.js +23 -0
  11. package/src/connectors/supabase.js +279 -0
  12. package/src/core/agents.js +32 -2
  13. package/src/core/cli.js +56 -7
  14. package/src/core/permissions.js +12 -1
  15. package/src/core/plugin-cmd.js +1 -0
  16. package/src/core/session.js +87 -6
  17. package/src/mcp/mcp-client.js +42 -2
  18. package/src/skills/skills-manager.js +25 -2
  19. package/src/tools/browser.js +204 -0
  20. package/src/tools/index.js +116 -5
  21. package/src/tui/App.tsx +19 -4
  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 +31 -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 +4 -2
  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 -470
@@ -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 || '/';
@@ -460,7 +470,31 @@ class LoomCLI {
460
470
  }
461
471
 
462
472
  function findBun() {
463
- // Check common installation paths
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 web Browser interface (HTTP server, opens the browser)');
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) {
@@ -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');
@@ -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 '(no response)'.
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({ role: 'user', content: '(your last reply was empty — continue where you left off and finish the task)' });
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
- return { type: 'text', content: text || '(no response)' };
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
@@ -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: Object.assign({}, process.env, cfg.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 || path.basename(src);
115
- const dest = path.join(globalSkillsDir(), name);
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
  };