loom-agent 1.2.36 → 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.
@@ -60,9 +60,9 @@ function escapeRegExp(s) {
60
60
  const BUILTIN_AGENTS = {
61
61
  build: {
62
62
  id: 'build', name: 'Build', mode: 'primary',
63
- description: 'Full development work with all tools enabled.',
63
+ description: 'Main orchestrator agent: full development work with all tools, drives tasks to completion.',
64
64
  tools: ['*'],
65
- prompt: null,
65
+ prompt: 'You are the MAIN ORCHESTRATOR agent. For non-trivial, multi-step work: (1) capture the plan as a todowrite list up front, (2) keep working until every item is completed or explicitly cancelled — never stop mid-task with a summary while todos remain, (3) delegate independent/parallelizable packages via the task tool (research for deep investigation, coder for isolated implementation, reviewer for review, tester for tests, web for browser work, explore for fast code search), (4) verify your work (run tests/typecheck/lint the project actually uses) before declaring done, (5) finish with a terse summary of what changed.',
66
66
  },
67
67
  plan: {
68
68
  id: 'plan', name: 'Plan', mode: 'primary',
@@ -95,6 +95,36 @@ const BUILTIN_AGENTS = {
95
95
  tools: ['read', 'glob', 'grep', 'webfetch'],
96
96
  prompt: null,
97
97
  },
98
+ research: {
99
+ id: 'research', name: 'Research', mode: 'subagent',
100
+ description: 'Deep multi-source research: combines web search/fetch with local code and docs, cross-checks facts, and returns a structured findings report with sources and confidence levels.',
101
+ tools: ['read', 'glob', 'grep', 'webfetch', 'websearch', 'todowrite'],
102
+ prompt: 'You are the Research agent. Investigate the topic from multiple independent sources (websearch/webfetch AND local files when relevant). Never state a fact you have not verified in a source. Track your investigation with todowrite when it has many steps. Return a structured report: "## Findings" (numbered facts, each with its source: URL or file:line), "## Gaps" (what you could not verify), "## Recommendation".',
103
+ },
104
+ coder: {
105
+ id: 'coder', name: 'Coder', mode: 'subagent',
106
+ description: 'Implementation specialist: writes and edits code, runs commands, verifies results. Use for well-scoped feature/bugfix packages that can run independently.',
107
+ tools: ['*', '!task'],
108
+ prompt: 'You are the Coder agent. Implement exactly what was delegated — no scope creep. Follow the existing code conventions of the files you touch (imports, naming, JSDoc/typing style). Verify your work (run the relevant tests/typecheck) before reporting. Final report: files changed with line deltas, commands run and their outcomes, and anything left undone.',
109
+ },
110
+ reviewer: {
111
+ id: 'reviewer', name: 'Reviewer', mode: 'subagent',
112
+ description: 'Code review specialist: reads diffs and files, runs read-only git/inspection commands, returns severity-ranked findings with file:line references.',
113
+ tools: ['read', 'glob', 'grep', 'bash', 'webfetch'],
114
+ prompt: 'You are a strict code reviewer. Hunt for: correctness bugs, security issues, data loss risks, broken edge cases, missing tests, and convention violations. Inspect the actual diff/files (git diff, git log, read) — never review code you have not read. Findings first, ranked by severity (critical/major/minor), each with file:line and a concrete suggested fix. If there are no issues, say so explicitly and note what you checked.',
115
+ },
116
+ tester: {
117
+ id: 'tester', name: 'Tester', mode: 'subagent',
118
+ description: 'Test engineer: writes tests that reproduce the issue, runs them, iterates until green. Use for test creation, bug reproduction, and verification work.',
119
+ tools: ['read', 'write', 'edit', 'glob', 'grep', 'bash', 'todowrite'],
120
+ prompt: 'You are the Tester agent. First reproduce the behavior (write a failing test when possible), run it, then iterate until the outcome is deterministic and understood. Use the project\'s real test runner (inspect package.json/bunfig — never invent commands). Report: exact commands run, pass/fail counts, and what the tests prove.',
121
+ },
122
+ web: {
123
+ id: 'web', name: 'Web', mode: 'subagent',
124
+ description: 'Browser specialist: drives a real headless browser (navigate, click, fill, read DOM, screenshot) plus webfetch/websearch. Use for live-site inspection, rendered-page research, and end-to-end UI verification.',
125
+ tools: ['read', 'glob', 'grep', 'webfetch', 'websearch', 'browser', 'todowrite'],
126
+ prompt: 'You are the Web agent with a real headless browser (the browser tool). Prefer the browser over webfetch whenever the page needs JavaScript rendering, clicking, or form interaction. Verify claims by reading the ACTUAL DOM (browser text/eval/links) — never guess page content. Use screenshots to confirm visual state when relevant. Stay on http/https pages; do not use the browser to access local files.',
127
+ },
98
128
  };
99
129
 
100
130
  function readOnlyTools() {
package/src/core/cli.js CHANGED
@@ -9,6 +9,10 @@ const { ProviderRouter, PROVIDERS } = require('../providers');
9
9
  const { saveSession, loadSession } = require('./session-store');
10
10
  const { defaultMcpInstall } = require('./plugin-cmd');
11
11
 
12
+ // Single source of truth for every version string the CLI prints (banner,
13
+ // --version). package.json is the only place to bump.
14
+ const VERSION = require('../../package.json').version;
15
+
12
16
  const LOOM_BASE = `
13
17
  ██╗ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ███████╗
14
18
  ██║ ██╔═══██╗██╔═══██╗████╗ ████║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
@@ -38,7 +42,7 @@ class LoomCLI {
38
42
  terminal: true,
39
43
  });
40
44
  console.log(LOOM_BASE);
41
- console.log(`\n Loom Code v1.2.28 — AI Coding Agent for the terminal`);
45
+ console.log(`\n Loom Code v${VERSION} — AI Coding Agent for the terminal`);
42
46
  console.log(` Press Ctrl+C or ESC to interrupt | /help for commands\n`);
43
47
 
44
48
  process.stdin.on('keypress', (str, key) => {
@@ -391,7 +395,13 @@ class LoomCLI {
391
395
  */
392
396
  async openGraphBrowser(graph) {
393
397
  const http = require('http');
394
- const template = fs.readFileSync(path.join(__dirname, '..', 'web', 'graph-view.html'), 'utf8');
398
+ let template;
399
+ try {
400
+ template = fs.readFileSync(path.join(__dirname, '..', 'web', 'graph-view.html'), 'utf8');
401
+ } catch {
402
+ console.log('Browser graph view is not available (the web UI has been removed).');
403
+ return;
404
+ }
395
405
  const html = template.replace('window.__GRAPH__ || { nodes: [], edges: [] }', JSON.stringify(graph).replace(/<\/script/gi, '<\\/script'));
396
406
  const server = http.createServer((req, res) => {
397
407
  const p = req.url || '/';
@@ -509,7 +519,6 @@ async function main() {
509
519
  const cli = new LoomCLI();
510
520
 
511
521
  const args = process.argv.slice(2);
512
- const VERSION = require('../../package.json').version;
513
522
 
514
523
  const printIdx = args.indexOf('-p');
515
524
  const printLongIdx = args.indexOf('--print');
@@ -530,8 +539,7 @@ if (args.includes('--help') || args.includes('-h')) {
530
539
  console.log(' loom --basic Use basic line-mode REPL (no TUI)');
531
540
  console.log(' loom --auto Auto-approve permission prompts');
532
541
  console.log(' loom acp ACP server (JSON-RPC over stdio, for editor plugins)');
533
- console.log(' loom web Browser interface (HTTP server, opens the browser)');
534
- console.log(' loom attach <url> Attach the terminal to a running `loom web` server');
542
+ console.log(' loom add mcp <spec> Add an MCP server (same syntax as /mcp add)');
535
543
  console.log(' loom -s <session-id> Resume a saved session');
536
544
  console.log(' loom --version Show version');
537
545
  console.log(' loom --help Show this help');
@@ -551,6 +559,23 @@ if (args.includes('--help') || args.includes('-h')) {
551
559
  return;
552
560
  }
553
561
 
562
+ // loom add mcp <name> [--] <command> [args...] — register an MCP server from
563
+ // the shell with the same one-liner syntax as /mcp add (quote paths with
564
+ // spaces; -e KEY=V for env). Exits 0 on success.
565
+ if (args[0] === 'add' && args[1] === 'mcp') {
566
+ const spec = args.slice(2);
567
+ if (!spec.length) {
568
+ console.log('Usage: loom add mcp [-e KEY=V] <name> [--] <command> [args...]');
569
+ console.log('Example: loom add mcp fetch -- npx -y mcp-server-fetch');
570
+ process.exit(1);
571
+ }
572
+ const { mcpAddLineCmd } = require('./plugin-cmd');
573
+ const msg = mcpAddLineCmd(spec.join(' '));
574
+ const ok = typeof msg === 'string' && msg.startsWith('Added');
575
+ console.log(ok ? msg : 'Error: ' + msg);
576
+ process.exit(ok ? 0 : 1);
577
+ }
578
+
554
579
  if (sessionId && sIndex !== -1) {
555
580
  const data = loadSession(sessionId);
556
581
  if (data && data.messages && data.messages.length) {
@@ -34,7 +34,7 @@ const DANGER_PATTERNS = [
34
34
  // user config).
35
35
  const PERMISSION_KEYS = [
36
36
  'read', 'edit', 'glob', 'grep', 'bash', 'task', 'skill', 'lsp', 'question',
37
- 'webfetch', 'websearch',
37
+ 'webfetch', 'websearch', 'browser',
38
38
  'external_directory', 'doom_loop',
39
39
  ];
40
40
 
@@ -53,6 +53,15 @@ const DEFAULT_PERMISSIONS = {
53
53
  '*.env.*': 'deny',
54
54
  '*.env.example': 'allow',
55
55
  },
56
+ // Browser: passive reads of live pages are as safe as webfetch (allow);
57
+ // INTERACTIVE actions (click/fill/press) can submit forms and change remote
58
+ // state, so they prompt by default. Patterns match "action arg".
59
+ browser: {
60
+ '*': 'allow',
61
+ 'click*': 'ask',
62
+ 'fill*': 'ask',
63
+ 'press*': 'ask',
64
+ },
56
65
  };
57
66
 
58
67
  // Tools that write files map onto the "edit" permission (OpenCode semantics:
@@ -71,6 +80,7 @@ const TOOL_TO_PERMISSION = {
71
80
  ask: 'question',
72
81
  webfetch: 'webfetch',
73
82
  websearch: 'websearch',
83
+ browser: 'browser',
74
84
  todowrite: null, // never prompts
75
85
  mcp: null, // wired by the session (only the "add" action prompts)
76
86
  };
@@ -301,6 +311,7 @@ class PermissionManager {
301
311
  case 'ask': return String(inp.question ?? inp.text ?? '');
302
312
  case 'webfetch': return String(inp.url ?? '');
303
313
  case 'websearch': return String(inp.query ?? '');
314
+ case 'browser': return String(inp.action ?? '') + ' ' + String(inp.url ?? inp.selector ?? inp.key ?? inp.path ?? '');
304
315
  default: return String(inp.command ?? inp.filePath ?? inp.path ?? inp.pattern ?? inp.url ?? '');
305
316
  }
306
317
  }
@@ -123,6 +123,7 @@ function mcpHelp() {
123
123
  '',
124
124
  'Example (claude-compatible): /mcp add stm32 -- "C:\\stm32-mcp\\.venv\\Scripts\\python.exe" -m stm32_mcp.server',
125
125
  'Example with env: /mcp add -e BRAVE_API_KEY=x brave-search -- npx -y @modelcontextprotocol/server-brave-search',
126
+ 'From the shell (same syntax): loom add mcp [-e KEY=V] <name> [--] <command> [args...]',
126
127
  'The "--" separator is optional; quote any path with spaces. Env keys are usable as $KEY in args.',
127
128
  'In the /mcp and /connectors browsers, press A to add with the same one-liner syntax.',
128
129
  ].join('\n');
@@ -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
@@ -0,0 +1,204 @@
1
+ // Headless browser control for the Web agent (and any build-mode session).
2
+ // Wraps Playwright behind a single-page singleton: the first action launches
3
+ // a headless Chromium and every later action reuses it, so "open a page, click
4
+ // around, read the DOM" works across tool calls without re-launching.
5
+ //
6
+ // Engine resolution order:
7
+ // 1. `playwright` (bundled chromium download, if the user installed it)
8
+ // 2. `playwright-core` + a system Chrome / Edge channel (zero download)
9
+ // Everything runs in the page context only — no filesystem access beyond the
10
+ // screenshot path the caller passes (enforced by the tool-layer sandbox for
11
+ // writes is intentionally NOT applied here: screenshots land in the OS temp
12
+ // dir by default and the tool returns the absolute path).
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ const INSTALL_HINT = 'Install a browser engine: npm i playwright then npx playwright install chromium (or install playwright-core and have Chrome/Edge available)';
18
+ const ACTION_TIMEOUT_MS = 30000;
19
+
20
+ /** @type {{ browser: any, page: any }|null} */
21
+ let _engine = null;
22
+ let _playwright = null; // null = not tried, false = unavailable, else module
23
+
24
+ const ACTIONS = ['goto', 'click', 'fill', 'press', 'text', 'eval', 'links', 'screenshot', 'close'];
25
+
26
+ function loadPlaywright() {
27
+ if (_playwright !== null) return _playwright || null;
28
+ for (const mod of ['playwright', 'playwright-core']) {
29
+ try { _playwright = require(mod); return _playwright; } catch {}
30
+ }
31
+ _playwright = false;
32
+ return null;
33
+ }
34
+
35
+ function withTimeout(promise, ms) {
36
+ let timer;
37
+ const timeout = new Promise((_, reject) => {
38
+ timer = setTimeout(() => reject(new Error('browser action timed out after ' + ms + 'ms')), ms);
39
+ });
40
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
41
+ }
42
+
43
+ function checkUrl(url) {
44
+ const u = String(url || '').trim();
45
+ if (!u) return { error: 'goto needs a url.' };
46
+ let parsed;
47
+ try { parsed = new URL(u); } catch { return { error: 'Invalid url: ' + u }; }
48
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
49
+ return { error: 'Only http/https urls are allowed (got ' + parsed.protocol + ').' };
50
+ }
51
+ return { url: parsed.href };
52
+ }
53
+
54
+ async function pageSnapshot(page) {
55
+ const title = await page.title().catch(() => '');
56
+ return 'URL: ' + page.url() + (title ? '\nTitle: ' + title : '');
57
+ }
58
+
59
+ /** Launch (or reuse) the headless browser. Returns the page or {error}. */
60
+ async function ensurePage() {
61
+ if (_engine && _engine.page && !_engine.page.isClosed()) return _engine.page;
62
+ const pw = loadPlaywright();
63
+ if (!pw) {
64
+ return { error: 'No browser engine found. ' + INSTALL_HINT };
65
+ }
66
+ const attempts = [
67
+ () => pw.chromium.launch({ headless: true }),
68
+ () => pw.chromium.launch({ headless: true, channel: 'chrome' }),
69
+ () => pw.chromium.launch({ headless: true, channel: 'msedge' }),
70
+ ];
71
+ let lastErr = null;
72
+ for (const attempt of attempts) {
73
+ try {
74
+ const browser = await withTimeout(attempt(), 20000);
75
+ const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
76
+ const page = await context.newPage();
77
+ _engine = { browser, page };
78
+ return page;
79
+ } catch (err) { lastErr = err; }
80
+ }
81
+ return { error: 'Failed to launch a browser: ' + (lastErr && lastErr.message) + '. ' + INSTALL_HINT };
82
+ }
83
+
84
+ /** Close the singleton browser (also used as the `close` action). */
85
+ async function closeBrowser() {
86
+ if (!_engine) return 'No browser is running.';
87
+ try { await _engine.browser.close(); } catch {}
88
+ _engine = null;
89
+ return 'Browser closed.';
90
+ }
91
+
92
+ /**
93
+ * Execute one browser action. `params` comes straight from the tool call:
94
+ * action, url, selector, text, key, script, path
95
+ * @param {Record<string, any>} params
96
+ * @returns {Promise<string|{error: string}>}
97
+ */
98
+ async function run(params) {
99
+ const action = String(params.action || '').toLowerCase().trim();
100
+ if (!ACTIONS.includes(action)) {
101
+ return { error: 'Unknown action "' + action + '". Use one of: ' + ACTIONS.join(' | ') };
102
+ }
103
+ if (action === 'close') return closeBrowser();
104
+
105
+ // Validate inputs BEFORE launching a browser so bad calls fail fast and
106
+ // never spawn a headless process.
107
+ if (action === 'goto') {
108
+ const u = checkUrl(params.url);
109
+ if (u.error) return u;
110
+ }
111
+ if ((action === 'click' || action === 'fill') && !String(params.selector || '').trim()) {
112
+ return { error: action + ' needs a CSS selector.' };
113
+ }
114
+ if (action === 'press' && !String(params.key || '').trim()) {
115
+ return { error: 'press needs a key (e.g. Enter, Tab, Escape, Control+A).' };
116
+ }
117
+ if (action === 'eval' && !String(params.script || '').trim()) {
118
+ return { error: 'eval needs a JavaScript expression (runs in the page context).' };
119
+ }
120
+
121
+ const page = await ensurePage();
122
+ if (page && typeof page === 'object' && 'error' in page) return page;
123
+
124
+ try {
125
+ switch (action) {
126
+ case 'goto': {
127
+ const u = checkUrl(params.url);
128
+ if (u.error) return u;
129
+ await withTimeout(page.goto(u.url, { waitUntil: 'domcontentloaded', timeout: ACTION_TIMEOUT_MS }), ACTION_TIMEOUT_MS + 2000);
130
+ await page.waitForLoadState('load', { timeout: 10000 }).catch(() => {});
131
+ return 'Opened ' + (await pageSnapshot(page));
132
+ }
133
+ case 'click': {
134
+ const sel = String(params.selector || '').trim();
135
+ if (!sel) return { error: 'click needs a CSS selector.' };
136
+ await withTimeout(page.click(sel, { timeout: 10000 }), 12000);
137
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
138
+ return 'Clicked "' + sel + '".\n' + (await pageSnapshot(page));
139
+ }
140
+ case 'fill': {
141
+ const sel = String(params.selector || '').trim();
142
+ if (!sel) return { error: 'fill needs a CSS selector.' };
143
+ await withTimeout(page.fill(sel, String(params.text ?? ''), { timeout: 10000 }), 12000);
144
+ return 'Filled "' + sel + '" with ' + String(params.text ?? '').length + ' chars.';
145
+ }
146
+ case 'press': {
147
+ const key = String(params.key || '').trim();
148
+ if (!key) return { error: 'press needs a key (e.g. Enter, Tab, Escape, Control+A).' };
149
+ await page.keyboard.press(key);
150
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
151
+ return 'Pressed ' + key + '.\n' + (await pageSnapshot(page));
152
+ }
153
+ case 'text': {
154
+ const body = await withTimeout(page.innerText('body').catch(() => ''), 10000);
155
+ const text = String(body || '').replace(/\n{3,}/g, '\n\n').trim();
156
+ const head = await pageSnapshot(page);
157
+ return head + '\n\n' + (text ? text.slice(0, 10000) : '(page has no visible text — try action="eval" or action="links")');
158
+ }
159
+ case 'eval': {
160
+ const script = String(params.script || '').trim();
161
+ if (!script) return { error: 'eval needs a JavaScript expression (runs in the page context).' };
162
+ // Function-body form keeps multi-statement scripts working.
163
+ const result = await withTimeout(page.evaluate((src) => {
164
+ try { return JSON.stringify({ ok: true, value: new Function('return (' + src + ')')() }); }
165
+ catch (e) { return JSON.stringify({ ok: false, error: String(e && e.message || e) }); }
166
+ }, script), 10000);
167
+ const parsed = JSON.parse(String(result));
168
+ if (!parsed.ok) return { error: 'eval failed: ' + parsed.error };
169
+ const out = typeof parsed.value === 'string' ? parsed.value : JSON.stringify(parsed.value, null, 2);
170
+ return String(out == null ? 'null' : out).slice(0, 10000);
171
+ }
172
+ case 'links': {
173
+ const links = await withTimeout(
174
+ page.$$eval('a[href]', (els) => els.slice(0, 100).map((a) => ({
175
+ href: a.getAttribute('href') || '',
176
+ text: (a.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 120),
177
+ }))),
178
+ 10000,
179
+ );
180
+ if (!links.length) return (await pageSnapshot(page)) + '\n\n(no links found)';
181
+ return links.map((l, i) => (i + 1) + '. ' + l.text + ' — ' + l.href).join('\n').slice(0, 10000);
182
+ }
183
+ case 'screenshot': {
184
+ const dest = params.path
185
+ ? path.resolve(String(params.path))
186
+ : path.join(os.tmpdir(), 'loom-browser-' + Date.now() + '.png');
187
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
188
+ await withTimeout(page.screenshot({ path: dest, fullPage: false }), 15000);
189
+ return 'Screenshot saved: ' + dest + '\n' + (await pageSnapshot(page));
190
+ }
191
+ default:
192
+ return { error: 'Unknown action "' + action + '".' };
193
+ }
194
+ } catch (err) {
195
+ return { error: 'browser ' + action + ' failed: ' + (err && err.message || err) };
196
+ }
197
+ }
198
+
199
+ // Test/diagnostic hook — true when a browser page is live.
200
+ function isRunning() {
201
+ return !!( _engine && _engine.page && !_engine.page.isClosed() );
202
+ }
203
+
204
+ module.exports = { run, closeBrowser, isRunning, ACTIONS };
@@ -440,7 +440,7 @@ const TOOLS = {
440
440
  },
441
441
  task: {
442
442
  name: 'task',
443
- description: 'Delegate a self-contained subtask to a subagent that runs fully autonomously and returns its final answer. Use when a piece of work can run independently (parallelizable), needs a focused toolset, or benefits from a different model. Pick the smallest suitable agent: explore (fast read-only code search), scout (web/docs research), general (full toolset except delegation). Pass the agent a COMPLETE, self-contained instruction: the goal, relevant file paths, and exactly what to return. The subagent may run tools autonomously; fold its findings into your own answer afterwards.',
443
+ description: 'Delegate a self-contained subtask to a subagent that runs fully autonomously and returns its final answer. Use when a piece of work can run independently (parallelizable), needs a focused toolset, or benefits from a different model. Agents: general (full toolset, default), explore (fast read-only code search), scout (quick docs lookups), research (deep multi-source investigation with a sourced report), coder (isolated implementation packages), reviewer (severity-ranked code review), tester (write/run tests until green), web (headless-browser work on live pages). Pick the smallest suitable agent. Pass a COMPLETE, self-contained instruction: the goal, relevant file paths, and exactly what to return. The subagent may run tools autonomously; fold its findings into your own answer afterwards.',
444
444
  parameters: {
445
445
  prompt: { type: 'string', required: true, description: 'Complete instructions for the subagent — goal, relevant file paths, and the exact output to return' },
446
446
  agent: { type: 'string', required: false, description: 'Subagent id: general (default), explore, scout, or a custom one from config.agents' },
@@ -462,6 +462,22 @@ const TOOLS = {
462
462
  return usage + '\n\n' + res.content;
463
463
  }
464
464
  },
465
+ browser: {
466
+ name: 'browser',
467
+ description: 'Drive a real headless browser (Playwright). Actions: goto (open an http/https url), click (CSS selector), fill (selector + text), press (key like Enter), text (visible page text + title/url), eval (JS expression in the page context), links (list page links), screenshot (save PNG, returns the file path), close. The browser stays open between calls so you can navigate, interact, and re-read the page. Prefer this over webfetch when the page needs JavaScript rendering or interaction.',
468
+ parameters: {
469
+ action: { type: 'string', required: true, description: '"goto" | "click" | "fill" | "press" | "text" | "eval" | "links" | "screenshot" | "close"' },
470
+ url: { type: 'string', required: false, description: 'Page url (action=goto)' },
471
+ selector: { type: 'string', required: false, description: 'CSS selector (action=click or fill)' },
472
+ text: { type: 'string', required: false, description: 'Text to type into the selector (action=fill)' },
473
+ key: { type: 'string', required: false, description: 'Key to press, e.g. Enter (action=press)' },
474
+ script: { type: 'string', required: false, description: 'JS expression evaluated in the page (action=eval)' },
475
+ path: { type: 'string', required: false, description: 'Screenshot destination path (action=screenshot; default: OS temp dir)' },
476
+ },
477
+ async execute(params) {
478
+ return require('./browser').run(params);
479
+ }
480
+ },
465
481
  mcp: {
466
482
  name: 'mcp',
467
483
  description: 'Manage MCP (tool) servers for this workspace. Use when the user asks to add/remove/enable/disable an MCP server or connector (e.g. github, filesystem, gmail) or when a task requires tools the session does not have yet. In the TUI, hosting/cloud services (supabase, vercel, nextjs, railway, netlify, cloudflare) live under /connectors; dev-tool MCP servers (playwright, github, sentry, figma, postgres, mongodb, linear, slack, brave-search, exa, context7, filesystem, sqlite, puppeteer) live under /mcp. Common packages: Playwright=@playwright/mcp, Sentry=@sentry/mcp-server (needs --access-token TOKEN), Figma=figma-developer-mcp (env FIGMA_API_KEY), Postgres=@modelcontextprotocol/server-postgres (env DATABASE_URL), MongoDB=mongodb-mcp-server (env MONGODB_CONNECTION_STRING), Linear=linear-mcp-server (env LINEAR_API_KEY), Slack=@modelcontextprotocol/server-slack (env SLACK_BOT_TOKEN + SLACK_TEAM_ID), Brave=@modelcontextprotocol/server-brave-search (env BRAVE_API_KEY), Exa=exa-mcp-server (env EXA_API_KEY), Context7=@upstash/context7-mcp, Vercel=vercel-mcp-server (env VERCEL_TOKEN), Railway=@railway/mcp-server (env RAILWAY_API_TOKEN), Netlify=netlify-mcp-server (env NETLIFY_AUTH_TOKEN), Cloudflare=@cloudflare/mcp-server-cloudflare (env CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID), Supabase=@supabase/mcp-server-supabase (needs --access-token TOKEN), GitHub=ghcr.io/github/github-mcp-server (docker). If unsure about an MCP package, verify it exists first with webfetch (e.g. https://registry.npmjs.org/<pkg>) or web search (fetch or bash curl) — never trust an unverified npm package.',
package/src/tui/App.tsx CHANGED
@@ -464,12 +464,27 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
464
464
  // Keep the partial text in the bubble; the session already stored it,
465
465
  // so the user can type "continue" to resume the task.
466
466
  patchMessageAt(idx, { thinking: false, interrupted: true, thinkTime: Date.now() - t0, isError: false, parts: snapshotParts() });
467
- appendMessage({ role: "system", content: "Interrupted \u2014 partial response kept. Type \"continue\" to resume the task." });
467
+ var keptChars = String(resp.content || "").length;
468
+ var inProgress = null;
469
+ var tdNow = sess.todos || [];
470
+ for (var ti = 0; ti < tdNow.length; ti++) { if (tdNow[ti].status === "in_progress") { inProgress = tdNow[ti]; break; } }
471
+ appendMessage({ role: "system", content: "Interrupted \u2014 kept " + keptChars + " chars of the partial response" + (inProgress ? " (in progress: " + inProgress.content + ")" : "") + ". Type \"continue\" to resume the task." });
468
472
  } else {
469
- patchMessageAt(idx, { content: resp.content || "(no response)", thinking: false, thinkTime: Date.now() - t0, isError: isErr, parts: snapshotParts() });
473
+ patchMessageAt(idx, { content: resp.content || "(the model ended the turn without any text)", thinking: false, thinkTime: Date.now() - t0, isError: isErr, parts: snapshotParts() });
474
+ // Real leftovers, not a generic stop line: when the turn wrote todos
475
+ // and still has open ones, the session reports exactly which.
476
+ var openTodos = resp.openTodos;
477
+ if (!isErr && openTodos && openTodos.length) {
478
+ var openNames = [];
479
+ for (var oi = 0; oi < Math.min(openTodos.length, 8); oi++) openNames.push("- [" + openTodos[oi].status + "] " + openTodos[oi].content);
480
+ appendMessage({ role: "system", content: "Stopped with " + openTodos.length + " todo(s) still open:\n" + openNames.join("\n") + (openTodos.length > 8 ? "\n- \u2026" : "") + "\nSay \"continue\" to keep going." });
481
+ }
470
482
  }
471
483
  if (!isErr && sess.mode === "plan") {
472
- appendMessage({ role: "system", content: "Plan complete \u2014 press Tab to switch to Build, then send \"go\" to execute." });
484
+ var plannedSteps = 0;
485
+ var planTodos = sess.todos || [];
486
+ for (var pi = 0; pi < planTodos.length; pi++) { if (planTodos[pi].status !== "completed" && planTodos[pi].status !== "cancelled") plannedSteps++; }
487
+ appendMessage({ role: "system", content: "Plan complete \u2014 " + (plannedSteps ? plannedSteps + " step(s) planned. " : "") + "press Tab to switch to Build, then send \"go\" to execute." });
473
488
  }
474
489
  setSpeedStats({ live: null, last: sess.getSpeed().last });
475
490
  }).catch(function(e) {
@@ -710,7 +725,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
710
725
  fsx.mkdirSync(dir, { recursive: true });
711
726
  const esc = (s: string) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
712
727
  const body = messages().map((m: any) => `<div class="m ${esc(m.role)}"><b>${esc(m.role)}</b><pre>${esc(String(m.content ?? ""))}</pre></div>`).join("\n");
713
- const html = `<!doctype html><meta charset="utf-8"><title>Loom session</title><style>body{background:#191817;color:#e8e2d9;font-family:ui-monospace,monospace;max-width:900px;margin:2rem auto;padding:0 1rem}.m{border-left:3px solid #555;padding:.25rem .75rem;margin:.6rem 0}.m.user{border-color:#d7a35f}.m.assistant{border-color:#7aa2f7}.m.system{color:#9a938a}pre{white-space:pre-wrap;font-family:inherit;margin:.2rem 0}</style><h1>Loom Code session</h1>${body}`;
728
+ const html = `<!doctype html><meta charset="utf-8"><title>Loom session</title><style>body{background:#171512;color:#f2ede4;font-family:ui-monospace,monospace;max-width:900px;margin:2rem auto;padding:0 1rem}.m{border-left:3px solid #463f36;padding:.25rem .75rem;margin:.6rem 0}.m.user{border-color:#a8c496}.m.assistant{border-color:#e8845c}.m.system{color:#a89d8d}pre{white-space:pre-wrap;font-family:inherit;margin:.2rem 0}</style><h1>Loom Code session</h1>${body}`;
714
729
  const f = pathx.join(dir, (sessionId() || "session") + ".html");
715
730
  fsx.writeFileSync(f, html);
716
731
  showToast("Shared: " + f, "ok", 6000);