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.
- package/CHANGELOG.md +128 -0
- package/README.md +38 -56
- package/bin/loom-bun.js +2 -10
- package/bin/loom.js +0 -10
- package/package.json +126 -126
- package/src/connectors/convex.js +356 -0
- package/src/connectors/deploy.js +390 -0
- package/src/connectors/index.js +23 -0
- package/src/connectors/supabase.js +279 -0
- package/src/core/agents.js +32 -2
- package/src/core/cli.js +30 -5
- package/src/core/permissions.js +12 -1
- package/src/core/plugin-cmd.js +1 -0
- package/src/core/sandbox.js +400 -0
- package/src/core/session-store.js +245 -167
- package/src/core/session.js +157 -11
- package/src/providers/anthropic.js +10 -5
- package/src/providers/openai-compat.js +12 -4
- package/src/tools/browser.js +204 -0
- package/src/tools/index.js +35 -1
- package/src/tui/App.tsx +103 -8
- package/src/tui/components/BreadcrumbBar.tsx +10 -9
- package/src/tui/components/ChatArea.tsx +3 -3
- package/src/tui/components/InputBar.tsx +22 -11
- package/src/tui/components/MdText.tsx +2 -1
- package/src/tui/components/Modals.tsx +191 -17
- package/src/tui/components/PermissionPopup.tsx +15 -12
- package/src/tui/components/Sidebar.tsx +6 -2
- package/src/tui/components/SplashScreen.tsx +17 -24
- package/src/tui/components/SubagentPanel.tsx +1 -1
- package/src/tui/components/ToastOverlay.tsx +23 -12
- package/src/tui/store.ts +38 -35
- package/src/tui/theme.ts +45 -30
- package/src/tui/tool-display.ts +13 -2
- package/docs/web.md +0 -205
- package/src/web/attach.js +0 -242
- package/src/web/graph-view.html +0 -262
- package/src/web/index.html +0 -824
- package/src/web/web-server.js +0 -535
package/src/core/session.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
614
|
-
//
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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 };
|
package/src/tools/index.js
CHANGED
|
@@ -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.
|
|
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.',
|