loom-agent 1.2.36 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +38 -56
  3. package/bin/loom-bun.js +2 -10
  4. package/bin/loom.js +0 -10
  5. package/package.json +126 -126
  6. package/src/connectors/convex.js +356 -0
  7. package/src/connectors/deploy.js +390 -0
  8. package/src/connectors/index.js +23 -0
  9. package/src/connectors/supabase.js +279 -0
  10. package/src/core/agents.js +32 -2
  11. package/src/core/cli.js +30 -5
  12. package/src/core/permissions.js +12 -1
  13. package/src/core/plugin-cmd.js +1 -0
  14. package/src/core/sandbox.js +400 -0
  15. package/src/core/session-store.js +245 -167
  16. package/src/core/session.js +157 -11
  17. package/src/providers/anthropic.js +10 -5
  18. package/src/providers/openai-compat.js +12 -4
  19. package/src/tools/browser.js +204 -0
  20. package/src/tools/index.js +35 -1
  21. package/src/tui/App.tsx +103 -8
  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 +191 -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 +38 -35
  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 -535
@@ -23,6 +23,31 @@ 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 = 8;
30
+ // Empty-reply nudges per turn (provider hiccup / reasoning-only stop).
31
+ const MAX_EMPTY_NUDGES = 3;
32
+ // finish_reason=length (output budget exhausted) auto-continues the turn
33
+ // this many times before surfacing the truncation placeholder. This is the
34
+ // #1 cause of "stops in the middle" with reasoning models: thinking eats
35
+ // the whole max_tokens budget and the answer never streams.
36
+ const MAX_TRUNCATION_CONTINUES = 5;
37
+ // Transient provider failures (network blip, 5xx, stream reset) retry the
38
+ // model call this many times before the turn surfaces an error.
39
+ const PROVIDER_RETRIES = 2;
40
+
41
+ // Does this reply sound like the model stopped in the MIDDLE of working?
42
+ // Used when todos exist but weren't touched this turn: a short reply, or a
43
+ // long one that ends on a continuation promise ("Now I will…", "Next, …"),
44
+ // an open colon, or an ellipsis is unfinished. A full explanation is not.
45
+ function looksUnfinished(text) {
46
+ const t = String(text || '').trim();
47
+ if (!t) return true;
48
+ if (t.length < 320) return true;
49
+ return /(?:\b(?:now|next|then|let me|i'?ll|going to|proceeding)\b[^.!?]*[.!]?\s*$)|[:…]$|\.\.\.\s*$/i.test(t);
50
+ }
26
51
 
27
52
  /**
28
53
  * @typedef {Object} SpeedStats
@@ -244,6 +269,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
244
269
  - 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
270
  }
246
271
 
272
+ // System-prompt suffix for the active PRIMARY agent (build/plan/chat): each
273
+ // primary can carry a role prompt (the Build orchestrator instructions, the
274
+ // Plan agent role, …) that appends after the base prompt. Child sessions and
275
+ // @agent turns already use _agentBlock, and primaries never set this.agent,
276
+ // so the two cannot stack. Resolved lazily so per-turn config changes apply.
277
+ _primaryBlock() {
278
+ if (this._isChild || this.agent) return '';
279
+ let agent = null;
280
+ try { agent = require('./agents').resolveAgent(this.mode); } catch { return ''; }
281
+ if (!agent || !agent.prompt) return '';
282
+ return '\n\n## Acting as the "' + agent.name + '" agent\n' + agent.prompt + '\n';
283
+ }
284
+
247
285
  // Create ./LOOM.md with a template when missing, so memory exists from the
248
286
  // first turn (Claude Code behavior). Disable with LOOM_MEM_AUTO=0 (tests).
249
287
  ensureMemoryFile() {
@@ -385,6 +423,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
385
423
  this.messages.push(msg);
386
424
  }
387
425
 
426
+ // Remove a trailing empty assistant row (no text, no toolCalls). getResponse
427
+ // appends the assistant message even when the provider returned nothing —
428
+ // Anthropic rejects empty assistant content with a 400 on the next call, so
429
+ // continuation paths drop the row before adding their user nudge.
430
+ _dropTrailingEmptyAssistant() {
431
+ const last = this.messages[this.messages.length - 1];
432
+ if (last && last.role === 'assistant' && !(last.toolCalls || []).length && !String(last.content || '').trim()) {
433
+ this.messages.pop();
434
+ return true;
435
+ }
436
+ return false;
437
+ }
438
+
388
439
  // ─── Todo state ───
389
440
  // The todowrite tool persists into the session so the sidebar shows the
390
441
  // real, up-to-date task list instead of regex-scanning replies.
@@ -542,13 +593,20 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
542
593
  interrupted: true,
543
594
  });
544
595
  }
545
- return { type: 'text', content: streamed || '(interrupted)', interrupted: true };
596
+ return { type: 'text', content: streamed || '(interrupted before any text was streamed)', interrupted: true };
546
597
  };
547
598
 
548
599
  // No tool-use cap: the model decides when a turn is finished; the user can
549
600
  // always Esc-interrupt a runaway loop.
550
601
  let ranTools = false;
551
- let nudged = false;
602
+ let emptyNudges = 0;
603
+ let toolCallsThisTurn = 0;
604
+ // Drive-to-completion state (see the no-toolCalls branch below).
605
+ let driveNudges = 0;
606
+ let truncContinues = 0;
607
+ let todowroteThisTurn = false;
608
+ // Transient provider-error retry state.
609
+ let providerRetries = 0;
552
610
  while (true) {
553
611
  if (this.interrupted) {
554
612
  return finishInterrupted();
@@ -575,6 +633,16 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
575
633
  continue;
576
634
  }
577
635
  }
636
+ // Transient provider failure (network blip, 5xx, stream reset): retry
637
+ // the model call a couple of times instead of killing the turn
638
+ // mid-task. No tools ran for this iteration, so a retry is side-effect
639
+ // free.
640
+ if (providerRetries < PROVIDER_RETRIES) {
641
+ providerRetries++;
642
+ await new Promise((resolve) => setTimeout(resolve, 400 * providerRetries));
643
+ if (this.interrupted) return finishInterrupted();
644
+ continue;
645
+ }
578
646
  return { type: 'error', content: err.message };
579
647
  }
580
648
 
@@ -608,18 +676,95 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
608
676
 
609
677
  if (!toolCalls.length) {
610
678
  const text = String(resp.content || '');
679
+
680
+ // Truncation auto-continue: finish_reason=length means the reply was
681
+ // CUT OFF by maxTokens — common with reasoning models (thinking eats
682
+ // the budget, the final answer never streams). Instead of dead-ending
683
+ // with a placeholder (the classic "stops in the middle"), continue the
684
+ // turn. Empty-content truncations also drop the empty assistant row
685
+ // from history — Anthropic 400s on empty assistant messages.
686
+ if (!this.interrupted && resp.finishReason === 'length' && truncContinues < MAX_TRUNCATION_CONTINUES) {
687
+ truncContinues++;
688
+ if (!text.trim()) this._dropTrailingEmptyAssistant();
689
+ const at = text.trim() ? text.replace(/\s+/g, ' ').trim().slice(-160) : null;
690
+ this.addMessage({
691
+ role: 'user',
692
+ content: '(your reply was cut off by the output-token limit' +
693
+ (at ? ' right after: \u2026' + at : ' before any text streamed') +
694
+ ' \u2014 continue exactly from there and finish the task. Keep the remaining reply compact so it fits.)',
695
+ });
696
+ continue;
697
+ }
698
+
611
699
  // Silent-stop guard: models occasionally end a turn with an EMPTY
612
- // reply right after running tools (truncation / provider hiccup),
613
- // which reads to the user like "the agent just stopped". One
614
- // automatic continuation instead of dead-ending with '(no response)'.
615
- if (!text.trim() && ranTools && !nudged && !this.interrupted) {
616
- nudged = true;
617
- this.addMessage({ role: 'user', content: '(your last reply was empty — continue where you left off and finish the task)' });
700
+ // reply — right after running tools (truncation / provider hiccup)
701
+ // or even on a plain first reply (reasoning-only stop, content
702
+ // filter). Automatic continuation instead of dead-ending with a
703
+ // placeholder — up to MAX_EMPTY_NUDGES per turn (hiccups repeat).
704
+ // finish_reason=length skips this: the truncation block above already
705
+ // spent its budget, and the placeholder below explains why.
706
+ if (!text.trim() && emptyNudges < MAX_EMPTY_NUDGES && !this.interrupted && resp.finishReason !== 'length') {
707
+ emptyNudges++;
708
+ this._dropTrailingEmptyAssistant();
709
+ this.addMessage({
710
+ role: 'user',
711
+ content: ranTools
712
+ ? '(the model returned an empty reply after ' + toolCallsThisTurn +
713
+ ' tool call(s) — continue where you left off and finish the task)'
714
+ : '(your last reply was empty — respond to the user with text, or use tools to do the work)',
715
+ });
618
716
  continue;
619
717
  }
620
- return { type: 'text', content: text || '(no response)' };
718
+ // Drive-to-completion guard: the model stopped calling tools in the
719
+ // middle of tracked work. Two triggers:
720
+ // 1. todos written THIS turn are still pending/in_progress (the
721
+ // strong signal — always nudge), or
722
+ // 2. tools ran this turn, todos exist, and the reply LOOKS
723
+ // unfinished (short / ends on a promise) — catches premature
724
+ // wrap-ups from models that never call todowrite.
725
+ // Up to MAX_DRIVE_NUDGES continuations. Plan/chat modes are excluded
726
+ // (plan drafts are meant to end with pending steps).
727
+ if (!this.interrupted && this.mode === 'build' && ranTools && driveNudges < MAX_DRIVE_NUDGES) {
728
+ const unfinished = (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress');
729
+ const gated = todowroteThisTurn || looksUnfinished(text);
730
+ if (unfinished.length && gated) {
731
+ driveNudges++;
732
+ const list = unfinished.slice(0, 20).map((t) => '- [' + t.status + '] ' + t.content).join('\n');
733
+ // Quote what the model actually said — a canned nudge alone gives
734
+ // the user (and the model) no clue WHERE the turn went off the rails.
735
+ const lastReply = text.replace(/\s+/g, ' ').trim();
736
+ const snippet = lastReply
737
+ ? '\nYour last message was: "' + (lastReply.length > 240 ? lastReply.slice(0, 240) + '…' : lastReply) + '"'
738
+ : '';
739
+ this.addMessage({
740
+ role: 'user',
741
+ content: '(unfinished work detected: ' + unfinished.length + ' todo(s) still open:\n' + list + snippet +
742
+ '\nYour reply stopped the turn, but the work above is not done — continue exactly where you left off and finish it. ' +
743
+ 'Only stop when every item is completed or cancelled via todowrite, or when something concrete blocks you — then name the blocker.)',
744
+ });
745
+ continue;
746
+ }
747
+ }
748
+ // Surface WHAT is still open when a build-mode turn engaged the drive
749
+ // guard (or wrote todos) and ends with work remaining, so the UI can
750
+ // show the real leftovers instead of a generic "stopped" placeholder.
751
+ const openTodos = (this.mode === 'build' && (todowroteThisTurn || driveNudges > 0))
752
+ ? (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress')
753
+ : [];
754
+ // Safety net: after all continuation budgets are spent, a
755
+ // finish_reason=length reply still gets the explanatory placeholder
756
+ // instead of mystery silence.
757
+ const truncHint = resp.finishReason === 'length'
758
+ ? '(the reply hit the max-tokens limit mid-stream — the model ran out of output budget before writing any text. Raise maxTokens in config, or /think off to free the thinking budget)'
759
+ : '(the model ended the turn without any text)';
760
+ return {
761
+ type: 'text',
762
+ content: text || truncHint,
763
+ openTodos: openTodos.length ? openTodos : undefined,
764
+ };
621
765
  }
622
766
  ranTools = true;
767
+ toolCallsThisTurn += toolCalls.length;
623
768
 
624
769
  // Execute independent tool calls in parallel; results are appended in the
625
770
  // original call order so the conversation history stays deterministic.
@@ -659,7 +804,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
659
804
  // are checked separately where they apply.
660
805
  const permKey = { read:'read', edit:'edit', write:'edit', glob:'glob', grep:'grep', bash:'bash',
661
806
  task:'task', skill:'skill', lsp:'lsp', question:'question', ask:'question', webfetch:'webfetch',
662
- websearch:'websearch' }[tc.name];
807
+ websearch:'websearch', browser:'browser' }[tc.name];
663
808
  const isMcpAdd = tc.name === 'mcp' && tc.input && tc.input.action === 'add';
664
809
  if (permKey || isMcpAdd) {
665
810
  const arg = this.permissions.permissionArg(tc.name, tc.input || {});
@@ -734,6 +879,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
734
879
  this.addMessage({ role: 'tool', toolCallId: tc.id, content: text });
735
880
  // Persist real todo state when the model uses the todowrite tool.
736
881
  if (tc.name === 'todowrite' && !outcome.error) {
882
+ todowroteThisTurn = true;
737
883
  this.setTodos(tc.input && tc.input.todos);
738
884
  // Notify observers (sidebar, etc.) over the tiny event bus — the TUI
739
885
  // subscribes once and the panel updates without a re-render cycle.
@@ -805,7 +951,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
805
951
  maxTokens: this.config.maxTokens || 8192,
806
952
  temperature: this.config.temperature ?? 0.7,
807
953
  tools: toolDefs,
808
- system: this.systemPrompt + (this._skillBlock || '') + (this._agentBlock || ''),
954
+ system: this.systemPrompt + (this._skillBlock || '') + (this._agentBlock || '') + this._primaryBlock(),
809
955
  signal: this.abortController?.signal,
810
956
  // Extended thinking: models tagged 'reasoning' reason BEFORE every
811
957
  // reply — including after each tool result — so complex tasks get
@@ -50,10 +50,15 @@ function buildBody(messages, options) {
50
50
  continue;
51
51
  }
52
52
  if (m.role === 'tool') {
53
- out.push({
54
- role: 'user',
55
- content: [{ type: 'tool_result', tool_use_id: m.toolCallId, content: String(m.content) }],
56
- });
53
+ // tool_use_id is required by the API — skip orphaned tool rows from
54
+ // resumed/hand-edited sessions rather than failing the whole turn.
55
+ const tcid = m.toolCallId || m.tool_call_id;
56
+ if (tcid) {
57
+ out.push({
58
+ role: 'user',
59
+ content: [{ type: 'tool_result', tool_use_id: tcid, content: String(m.content) }],
60
+ });
61
+ }
57
62
  continue;
58
63
  }
59
64
  if (m.role === 'assistant') {
@@ -70,7 +75,7 @@ function buildBody(messages, options) {
70
75
  } else if (m.content) {
71
76
  blocks.push({ type: 'text', text: m.content });
72
77
  }
73
- for (const tc of m.toolCalls || []) {
78
+ for (const tc of (m.toolCalls || m.tool_calls) || []) {
74
79
  blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input || {} });
75
80
  }
76
81
  out.push({ role: 'assistant', content: blocks });
@@ -9,12 +9,17 @@ function formatMessages(messages, options) {
9
9
  for (const m of messages) {
10
10
  if (m.role === 'system') continue;
11
11
  if (m.role === 'tool') {
12
- out.push({ role: 'tool', tool_call_id: m.toolCallId, content: String(m.content) });
12
+ // A tool row without its id would be rejected by the API ("missing
13
+ // field tool_call_id" — NVIDIA is strict about this). Skip orphans
14
+ // from hand-edited/resumed sessions instead of failing the turn.
15
+ const tcid = m.toolCallId || m.tool_call_id;
16
+ if (tcid) out.push({ role: 'tool', tool_call_id: tcid, content: String(m.content) });
13
17
  continue;
14
18
  }
15
19
  const msg = { role: m.role, content: m.content || '' };
16
- if (m.toolCalls && m.toolCalls.length) {
17
- msg.tool_calls = m.toolCalls.map(tc => ({
20
+ const calls = m.toolCalls || m.tool_calls;
21
+ if (calls && calls.length) {
22
+ msg.tool_calls = calls.map(tc => ({
18
23
  id: tc.id,
19
24
  type: 'function',
20
25
  function: { name: tc.name, arguments: typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input || {}) },
@@ -80,6 +85,7 @@ function normalize(resp) {
80
85
  content: message.content || '',
81
86
  toolCalls,
82
87
  usage: resp.usage,
88
+ finishReason: choice?.finish_reason || undefined,
83
89
  };
84
90
  }
85
91
 
@@ -208,10 +214,12 @@ function createOpenAICompatProvider({ getKey, providerId, envKeyHint, clientFact
208
214
  let content = '';
209
215
  let reasoning = '';
210
216
  let usage = null;
217
+ let finishReason = null;
211
218
  const toolAcc = new Map();
212
219
 
213
220
  for await (const chunk of s) {
214
221
  if (chunk.usage) usage = chunk.usage;
222
+ if (chunk.choices?.[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
215
223
  const delta = chunk.choices?.[0]?.delta || {};
216
224
  // DeepSeek-style reasoning_content / OpenRouter reasoning deltas.
217
225
  const rt = delta.reasoning_content || delta.reasoning;
@@ -243,7 +251,7 @@ function createOpenAICompatProvider({ getKey, providerId, envKeyHint, clientFact
243
251
  toolCalls.push({ id: acc.id, name: acc.name, input });
244
252
  }
245
253
 
246
- return { content, reasoning, toolCalls, usage };
254
+ return { content, reasoning, toolCalls, usage, finishReason: finishReason || undefined };
247
255
  } catch (err) {
248
256
  if (isAbortError(err)) throw err;
249
257
  throw wrapErr(err, options.model || '?', envKeyHint, getBaseUrl(providerId));
@@ -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 };
@@ -193,6 +193,24 @@ const TOOLS = {
193
193
  background: { type: 'boolean', required: false, description: 'Run without waiting (dev servers, long builds). Returns a task id immediately; check with the /tasks panel.' },
194
194
  },
195
195
  async execute(params, ctx) {
196
+ // Docker sandbox: when enabled (/sandbox setup), every command runs
197
+ // inside the loom-sandbox container instead of the host shell.
198
+ // Background tasks stay on the host (they outlive tool timeouts).
199
+ const sandbox = require('../core/sandbox');
200
+ if (!params.background && sandbox.shouldUseSandbox()) {
201
+ if (!pathAllowed(params.workdir || cwd)) return { error: sandboxDenied('bash', params.workdir || cwd) };
202
+ const risk = params._approved ? null : commandRiskLabel(params.command);
203
+ if (risk) return { error: `Blocked by safety filter: ${risk}. Approve it via the permission prompt to run it anyway.` };
204
+ const stream = ctx && typeof ctx.stream === 'function' ? ctx.stream : null;
205
+ const res = await sandbox.runInSandbox(params.command, {
206
+ workdir: params.workdir || cwd,
207
+ onChunk: stream ? (text, which) => stream(text, which) : undefined,
208
+ });
209
+ if ('error' in res) return { error: res.error };
210
+ let out = res.output;
211
+ if (res.code !== 0) out += `\n[exit code: ${res.code}]`;
212
+ return out || 'command completed';
213
+ }
196
214
  // Background mode: fire-and-forget via the task registry.
197
215
  if (params.background) {
198
216
  const bt = require('../core/background-tasks');
@@ -440,7 +458,7 @@ const TOOLS = {
440
458
  },
441
459
  task: {
442
460
  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.',
461
+ 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
462
  parameters: {
445
463
  prompt: { type: 'string', required: true, description: 'Complete instructions for the subagent — goal, relevant file paths, and the exact output to return' },
446
464
  agent: { type: 'string', required: false, description: 'Subagent id: general (default), explore, scout, or a custom one from config.agents' },
@@ -462,6 +480,22 @@ const TOOLS = {
462
480
  return usage + '\n\n' + res.content;
463
481
  }
464
482
  },
483
+ browser: {
484
+ name: 'browser',
485
+ 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.',
486
+ parameters: {
487
+ action: { type: 'string', required: true, description: '"goto" | "click" | "fill" | "press" | "text" | "eval" | "links" | "screenshot" | "close"' },
488
+ url: { type: 'string', required: false, description: 'Page url (action=goto)' },
489
+ selector: { type: 'string', required: false, description: 'CSS selector (action=click or fill)' },
490
+ text: { type: 'string', required: false, description: 'Text to type into the selector (action=fill)' },
491
+ key: { type: 'string', required: false, description: 'Key to press, e.g. Enter (action=press)' },
492
+ script: { type: 'string', required: false, description: 'JS expression evaluated in the page (action=eval)' },
493
+ path: { type: 'string', required: false, description: 'Screenshot destination path (action=screenshot; default: OS temp dir)' },
494
+ },
495
+ async execute(params) {
496
+ return require('./browser').run(params);
497
+ }
498
+ },
465
499
  mcp: {
466
500
  name: 'mcp',
467
501
  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.',