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.
- package/CHANGELOG.md +53 -0
- package/README.md +4 -29
- package/bin/loom-bun.js +2 -10
- package/bin/loom.js +0 -10
- package/package.json +6 -7
- 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/session.js +87 -6
- package/src/tools/browser.js +204 -0
- package/src/tools/index.js +17 -1
- package/src/tui/App.tsx +19 -4
- package/src/tui/components/BreadcrumbBar.tsx +10 -9
- package/src/tui/components/ChatArea.tsx +3 -3
- package/src/tui/components/InputBar.tsx +22 -11
- package/src/tui/components/MdText.tsx +2 -1
- package/src/tui/components/Modals.tsx +31 -17
- package/src/tui/components/PermissionPopup.tsx +15 -12
- package/src/tui/components/Sidebar.tsx +6 -2
- package/src/tui/components/SplashScreen.tsx +17 -24
- package/src/tui/components/SubagentPanel.tsx +1 -1
- package/src/tui/components/ToastOverlay.tsx +23 -12
- package/src/tui/store.ts +4 -2
- package/src/tui/theme.ts +45 -30
- package/src/tui/tool-display.ts +13 -2
- package/docs/web.md +0 -205
- package/src/web/attach.js +0 -242
- package/src/web/graph-view.html +0 -262
- package/src/web/index.html +0 -824
- package/src/web/web-server.js +0 -535
package/src/core/agents.js
CHANGED
|
@@ -60,9 +60,9 @@ function escapeRegExp(s) {
|
|
|
60
60
|
const BUILTIN_AGENTS = {
|
|
61
61
|
build: {
|
|
62
62
|
id: 'build', name: 'Build', mode: 'primary',
|
|
63
|
-
description: '
|
|
63
|
+
description: 'Main orchestrator agent: full development work with all tools, drives tasks to completion.',
|
|
64
64
|
tools: ['*'],
|
|
65
|
-
prompt:
|
|
65
|
+
prompt: 'You are the MAIN ORCHESTRATOR agent. For non-trivial, multi-step work: (1) capture the plan as a todowrite list up front, (2) keep working until every item is completed or explicitly cancelled — never stop mid-task with a summary while todos remain, (3) delegate independent/parallelizable packages via the task tool (research for deep investigation, coder for isolated implementation, reviewer for review, tester for tests, web for browser work, explore for fast code search), (4) verify your work (run tests/typecheck/lint the project actually uses) before declaring done, (5) finish with a terse summary of what changed.',
|
|
66
66
|
},
|
|
67
67
|
plan: {
|
|
68
68
|
id: 'plan', name: 'Plan', mode: 'primary',
|
|
@@ -95,6 +95,36 @@ const BUILTIN_AGENTS = {
|
|
|
95
95
|
tools: ['read', 'glob', 'grep', 'webfetch'],
|
|
96
96
|
prompt: null,
|
|
97
97
|
},
|
|
98
|
+
research: {
|
|
99
|
+
id: 'research', name: 'Research', mode: 'subagent',
|
|
100
|
+
description: 'Deep multi-source research: combines web search/fetch with local code and docs, cross-checks facts, and returns a structured findings report with sources and confidence levels.',
|
|
101
|
+
tools: ['read', 'glob', 'grep', 'webfetch', 'websearch', 'todowrite'],
|
|
102
|
+
prompt: 'You are the Research agent. Investigate the topic from multiple independent sources (websearch/webfetch AND local files when relevant). Never state a fact you have not verified in a source. Track your investigation with todowrite when it has many steps. Return a structured report: "## Findings" (numbered facts, each with its source: URL or file:line), "## Gaps" (what you could not verify), "## Recommendation".',
|
|
103
|
+
},
|
|
104
|
+
coder: {
|
|
105
|
+
id: 'coder', name: 'Coder', mode: 'subagent',
|
|
106
|
+
description: 'Implementation specialist: writes and edits code, runs commands, verifies results. Use for well-scoped feature/bugfix packages that can run independently.',
|
|
107
|
+
tools: ['*', '!task'],
|
|
108
|
+
prompt: 'You are the Coder agent. Implement exactly what was delegated — no scope creep. Follow the existing code conventions of the files you touch (imports, naming, JSDoc/typing style). Verify your work (run the relevant tests/typecheck) before reporting. Final report: files changed with line deltas, commands run and their outcomes, and anything left undone.',
|
|
109
|
+
},
|
|
110
|
+
reviewer: {
|
|
111
|
+
id: 'reviewer', name: 'Reviewer', mode: 'subagent',
|
|
112
|
+
description: 'Code review specialist: reads diffs and files, runs read-only git/inspection commands, returns severity-ranked findings with file:line references.',
|
|
113
|
+
tools: ['read', 'glob', 'grep', 'bash', 'webfetch'],
|
|
114
|
+
prompt: 'You are a strict code reviewer. Hunt for: correctness bugs, security issues, data loss risks, broken edge cases, missing tests, and convention violations. Inspect the actual diff/files (git diff, git log, read) — never review code you have not read. Findings first, ranked by severity (critical/major/minor), each with file:line and a concrete suggested fix. If there are no issues, say so explicitly and note what you checked.',
|
|
115
|
+
},
|
|
116
|
+
tester: {
|
|
117
|
+
id: 'tester', name: 'Tester', mode: 'subagent',
|
|
118
|
+
description: 'Test engineer: writes tests that reproduce the issue, runs them, iterates until green. Use for test creation, bug reproduction, and verification work.',
|
|
119
|
+
tools: ['read', 'write', 'edit', 'glob', 'grep', 'bash', 'todowrite'],
|
|
120
|
+
prompt: 'You are the Tester agent. First reproduce the behavior (write a failing test when possible), run it, then iterate until the outcome is deterministic and understood. Use the project\'s real test runner (inspect package.json/bunfig — never invent commands). Report: exact commands run, pass/fail counts, and what the tests prove.',
|
|
121
|
+
},
|
|
122
|
+
web: {
|
|
123
|
+
id: 'web', name: 'Web', mode: 'subagent',
|
|
124
|
+
description: 'Browser specialist: drives a real headless browser (navigate, click, fill, read DOM, screenshot) plus webfetch/websearch. Use for live-site inspection, rendered-page research, and end-to-end UI verification.',
|
|
125
|
+
tools: ['read', 'glob', 'grep', 'webfetch', 'websearch', 'browser', 'todowrite'],
|
|
126
|
+
prompt: 'You are the Web agent with a real headless browser (the browser tool). Prefer the browser over webfetch whenever the page needs JavaScript rendering, clicking, or form interaction. Verify claims by reading the ACTUAL DOM (browser text/eval/links) — never guess page content. Use screenshots to confirm visual state when relevant. Stay on http/https pages; do not use the browser to access local files.',
|
|
127
|
+
},
|
|
98
128
|
};
|
|
99
129
|
|
|
100
130
|
function readOnlyTools() {
|
package/src/core/cli.js
CHANGED
|
@@ -9,6 +9,10 @@ const { ProviderRouter, PROVIDERS } = require('../providers');
|
|
|
9
9
|
const { saveSession, loadSession } = require('./session-store');
|
|
10
10
|
const { defaultMcpInstall } = require('./plugin-cmd');
|
|
11
11
|
|
|
12
|
+
// Single source of truth for every version string the CLI prints (banner,
|
|
13
|
+
// --version). package.json is the only place to bump.
|
|
14
|
+
const VERSION = require('../../package.json').version;
|
|
15
|
+
|
|
12
16
|
const LOOM_BASE = `
|
|
13
17
|
██╗ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ███████╗
|
|
14
18
|
██║ ██╔═══██╗██╔═══██╗████╗ ████║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
|
|
@@ -38,7 +42,7 @@ class LoomCLI {
|
|
|
38
42
|
terminal: true,
|
|
39
43
|
});
|
|
40
44
|
console.log(LOOM_BASE);
|
|
41
|
-
console.log(`\n Loom Code
|
|
45
|
+
console.log(`\n Loom Code v${VERSION} — AI Coding Agent for the terminal`);
|
|
42
46
|
console.log(` Press Ctrl+C or ESC to interrupt | /help for commands\n`);
|
|
43
47
|
|
|
44
48
|
process.stdin.on('keypress', (str, key) => {
|
|
@@ -391,7 +395,13 @@ class LoomCLI {
|
|
|
391
395
|
*/
|
|
392
396
|
async openGraphBrowser(graph) {
|
|
393
397
|
const http = require('http');
|
|
394
|
-
|
|
398
|
+
let template;
|
|
399
|
+
try {
|
|
400
|
+
template = fs.readFileSync(path.join(__dirname, '..', 'web', 'graph-view.html'), 'utf8');
|
|
401
|
+
} catch {
|
|
402
|
+
console.log('Browser graph view is not available (the web UI has been removed).');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
395
405
|
const html = template.replace('window.__GRAPH__ || { nodes: [], edges: [] }', JSON.stringify(graph).replace(/<\/script/gi, '<\\/script'));
|
|
396
406
|
const server = http.createServer((req, res) => {
|
|
397
407
|
const p = req.url || '/';
|
|
@@ -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
|
|
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) {
|
package/src/core/permissions.js
CHANGED
|
@@ -34,7 +34,7 @@ const DANGER_PATTERNS = [
|
|
|
34
34
|
// user config).
|
|
35
35
|
const PERMISSION_KEYS = [
|
|
36
36
|
'read', 'edit', 'glob', 'grep', 'bash', 'task', 'skill', 'lsp', 'question',
|
|
37
|
-
'webfetch', 'websearch',
|
|
37
|
+
'webfetch', 'websearch', 'browser',
|
|
38
38
|
'external_directory', 'doom_loop',
|
|
39
39
|
];
|
|
40
40
|
|
|
@@ -53,6 +53,15 @@ const DEFAULT_PERMISSIONS = {
|
|
|
53
53
|
'*.env.*': 'deny',
|
|
54
54
|
'*.env.example': 'allow',
|
|
55
55
|
},
|
|
56
|
+
// Browser: passive reads of live pages are as safe as webfetch (allow);
|
|
57
|
+
// INTERACTIVE actions (click/fill/press) can submit forms and change remote
|
|
58
|
+
// state, so they prompt by default. Patterns match "action arg".
|
|
59
|
+
browser: {
|
|
60
|
+
'*': 'allow',
|
|
61
|
+
'click*': 'ask',
|
|
62
|
+
'fill*': 'ask',
|
|
63
|
+
'press*': 'ask',
|
|
64
|
+
},
|
|
56
65
|
};
|
|
57
66
|
|
|
58
67
|
// Tools that write files map onto the "edit" permission (OpenCode semantics:
|
|
@@ -71,6 +80,7 @@ const TOOL_TO_PERMISSION = {
|
|
|
71
80
|
ask: 'question',
|
|
72
81
|
webfetch: 'webfetch',
|
|
73
82
|
websearch: 'websearch',
|
|
83
|
+
browser: 'browser',
|
|
74
84
|
todowrite: null, // never prompts
|
|
75
85
|
mcp: null, // wired by the session (only the "add" action prompts)
|
|
76
86
|
};
|
|
@@ -301,6 +311,7 @@ class PermissionManager {
|
|
|
301
311
|
case 'ask': return String(inp.question ?? inp.text ?? '');
|
|
302
312
|
case 'webfetch': return String(inp.url ?? '');
|
|
303
313
|
case 'websearch': return String(inp.query ?? '');
|
|
314
|
+
case 'browser': return String(inp.action ?? '') + ' ' + String(inp.url ?? inp.selector ?? inp.key ?? inp.path ?? '');
|
|
304
315
|
default: return String(inp.command ?? inp.filePath ?? inp.path ?? inp.pattern ?? inp.url ?? '');
|
|
305
316
|
}
|
|
306
317
|
}
|
package/src/core/plugin-cmd.js
CHANGED
|
@@ -123,6 +123,7 @@ function mcpHelp() {
|
|
|
123
123
|
'',
|
|
124
124
|
'Example (claude-compatible): /mcp add stm32 -- "C:\\stm32-mcp\\.venv\\Scripts\\python.exe" -m stm32_mcp.server',
|
|
125
125
|
'Example with env: /mcp add -e BRAVE_API_KEY=x brave-search -- npx -y @modelcontextprotocol/server-brave-search',
|
|
126
|
+
'From the shell (same syntax): loom add mcp [-e KEY=V] <name> [--] <command> [args...]',
|
|
126
127
|
'The "--" separator is optional; quote any path with spaces. Env keys are usable as $KEY in args.',
|
|
127
128
|
'In the /mcp and /connectors browsers, press A to add with the same one-liner syntax.',
|
|
128
129
|
].join('\n');
|
package/src/core/session.js
CHANGED
|
@@ -23,6 +23,13 @@ const COMPACT_DEFAULT_THRESHOLD = 0.75;
|
|
|
23
23
|
const COMPACT_KEEP_MESSAGES = 8;
|
|
24
24
|
// Never compact conversations shorter than this.
|
|
25
25
|
const COMPACT_MIN_MESSAGES = 6;
|
|
26
|
+
// Drive-to-completion: how many continuation nudges one turn may spend when
|
|
27
|
+
// the model stops with todos still unfinished (the "stops between tasks"
|
|
28
|
+
// failure). Capped so a model that refuses to update its todos cannot spin.
|
|
29
|
+
const MAX_DRIVE_NUDGES = 5;
|
|
30
|
+
// Transient provider failures (network blip, 5xx, stream reset) retry the
|
|
31
|
+
// model call this many times before the turn surfaces an error.
|
|
32
|
+
const PROVIDER_RETRIES = 2;
|
|
26
33
|
|
|
27
34
|
/**
|
|
28
35
|
* @typedef {Object} SpeedStats
|
|
@@ -244,6 +251,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
244
251
|
- If the user wants code changes, tell them to switch to Build mode (Tab or /build) and resend.` : ''}${this.config.outputStyle ? `\n\n## Output style\n${this.config.outputStyle}` : ''}`;
|
|
245
252
|
}
|
|
246
253
|
|
|
254
|
+
// System-prompt suffix for the active PRIMARY agent (build/plan/chat): each
|
|
255
|
+
// primary can carry a role prompt (the Build orchestrator instructions, the
|
|
256
|
+
// Plan agent role, …) that appends after the base prompt. Child sessions and
|
|
257
|
+
// @agent turns already use _agentBlock, and primaries never set this.agent,
|
|
258
|
+
// so the two cannot stack. Resolved lazily so per-turn config changes apply.
|
|
259
|
+
_primaryBlock() {
|
|
260
|
+
if (this._isChild || this.agent) return '';
|
|
261
|
+
let agent = null;
|
|
262
|
+
try { agent = require('./agents').resolveAgent(this.mode); } catch { return ''; }
|
|
263
|
+
if (!agent || !agent.prompt) return '';
|
|
264
|
+
return '\n\n## Acting as the "' + agent.name + '" agent\n' + agent.prompt + '\n';
|
|
265
|
+
}
|
|
266
|
+
|
|
247
267
|
// Create ./LOOM.md with a template when missing, so memory exists from the
|
|
248
268
|
// first turn (Claude Code behavior). Disable with LOOM_MEM_AUTO=0 (tests).
|
|
249
269
|
ensureMemoryFile() {
|
|
@@ -542,13 +562,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
542
562
|
interrupted: true,
|
|
543
563
|
});
|
|
544
564
|
}
|
|
545
|
-
return { type: 'text', content: streamed || '(interrupted)', interrupted: true };
|
|
565
|
+
return { type: 'text', content: streamed || '(interrupted before any text was streamed)', interrupted: true };
|
|
546
566
|
};
|
|
547
567
|
|
|
548
568
|
// No tool-use cap: the model decides when a turn is finished; the user can
|
|
549
569
|
// always Esc-interrupt a runaway loop.
|
|
550
570
|
let ranTools = false;
|
|
551
571
|
let nudged = false;
|
|
572
|
+
let toolCallsThisTurn = 0;
|
|
573
|
+
// Drive-to-completion state (see the no-toolCalls branch below).
|
|
574
|
+
let driveNudges = 0;
|
|
575
|
+
let todowroteThisTurn = false;
|
|
576
|
+
// Transient provider-error retry state.
|
|
577
|
+
let providerRetries = 0;
|
|
552
578
|
while (true) {
|
|
553
579
|
if (this.interrupted) {
|
|
554
580
|
return finishInterrupted();
|
|
@@ -575,6 +601,16 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
575
601
|
continue;
|
|
576
602
|
}
|
|
577
603
|
}
|
|
604
|
+
// Transient provider failure (network blip, 5xx, stream reset): retry
|
|
605
|
+
// the model call a couple of times instead of killing the turn
|
|
606
|
+
// mid-task. No tools ran for this iteration, so a retry is side-effect
|
|
607
|
+
// free.
|
|
608
|
+
if (providerRetries < PROVIDER_RETRIES) {
|
|
609
|
+
providerRetries++;
|
|
610
|
+
await new Promise((resolve) => setTimeout(resolve, 400 * providerRetries));
|
|
611
|
+
if (this.interrupted) return finishInterrupted();
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
578
614
|
return { type: 'error', content: err.message };
|
|
579
615
|
}
|
|
580
616
|
|
|
@@ -611,15 +647,59 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
611
647
|
// Silent-stop guard: models occasionally end a turn with an EMPTY
|
|
612
648
|
// reply right after running tools (truncation / provider hiccup),
|
|
613
649
|
// which reads to the user like "the agent just stopped". One
|
|
614
|
-
// automatic continuation instead of dead-ending with
|
|
650
|
+
// automatic continuation instead of dead-ending with a placeholder.
|
|
615
651
|
if (!text.trim() && ranTools && !nudged && !this.interrupted) {
|
|
616
652
|
nudged = true;
|
|
617
|
-
this.addMessage({
|
|
653
|
+
this.addMessage({
|
|
654
|
+
role: 'user',
|
|
655
|
+
content: '(the model returned an empty reply after ' + toolCallsThisTurn +
|
|
656
|
+
' tool call(s) — continue where you left off and finish the task)',
|
|
657
|
+
});
|
|
618
658
|
continue;
|
|
619
659
|
}
|
|
620
|
-
|
|
660
|
+
// Drive-to-completion guard: the model stopped calling tools in the
|
|
661
|
+
// middle of tracked work (todos written this turn still
|
|
662
|
+
// pending/in_progress). Inject one continuation that quotes the
|
|
663
|
+
// model's ACTUAL last message and lists the unfinished items — up to
|
|
664
|
+
// MAX_DRIVE_NUDGES times — so a premature summary does not strand the
|
|
665
|
+
// task. The model can stop cleanly by completing or cancelling the
|
|
666
|
+
// items via todowrite. Plan/chat modes are excluded (plan drafts are
|
|
667
|
+
// meant to end with pending steps).
|
|
668
|
+
if (!this.interrupted && this.mode === 'build' && ranTools && todowroteThisTurn && driveNudges < MAX_DRIVE_NUDGES) {
|
|
669
|
+
const unfinished = (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress');
|
|
670
|
+
if (unfinished.length) {
|
|
671
|
+
driveNudges++;
|
|
672
|
+
const list = unfinished.slice(0, 20).map((t) => '- [' + t.status + '] ' + t.content).join('\n');
|
|
673
|
+
// Quote what the model actually said — a canned nudge alone gives
|
|
674
|
+
// the user (and the model) no clue WHERE the turn went off the rails.
|
|
675
|
+
const lastReply = text.replace(/\s+/g, ' ').trim();
|
|
676
|
+
const snippet = lastReply
|
|
677
|
+
? '\nYour last message was: "' + (lastReply.length > 240 ? lastReply.slice(0, 240) + '…' : lastReply) + '"'
|
|
678
|
+
: '';
|
|
679
|
+
this.addMessage({
|
|
680
|
+
role: 'user',
|
|
681
|
+
content: '(unfinished work detected: ' + unfinished.length + ' todo(s) still open:\n' + list + snippet +
|
|
682
|
+
'\nYour reply stopped the turn, but the work above is not done — continue exactly where you left off and finish it. ' +
|
|
683
|
+
'Only stop when every item is completed or cancelled via todowrite, or when something concrete blocks you — then name the blocker.)',
|
|
684
|
+
});
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// Surface WHAT is still open when a build-mode turn that WROTE todos
|
|
689
|
+
// ends with work remaining (same signal as the nudge above), so the
|
|
690
|
+
// UI can show the real leftovers instead of a generic "stopped"
|
|
691
|
+
// placeholder. Stale todos from older turns stay silent.
|
|
692
|
+
const openTodos = (this.mode === 'build' && todowroteThisTurn)
|
|
693
|
+
? (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress')
|
|
694
|
+
: [];
|
|
695
|
+
return {
|
|
696
|
+
type: 'text',
|
|
697
|
+
content: text || '(the model ended the turn without any text)',
|
|
698
|
+
openTodos: openTodos.length ? openTodos : undefined,
|
|
699
|
+
};
|
|
621
700
|
}
|
|
622
701
|
ranTools = true;
|
|
702
|
+
toolCallsThisTurn += toolCalls.length;
|
|
623
703
|
|
|
624
704
|
// Execute independent tool calls in parallel; results are appended in the
|
|
625
705
|
// original call order so the conversation history stays deterministic.
|
|
@@ -659,7 +739,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
659
739
|
// are checked separately where they apply.
|
|
660
740
|
const permKey = { read:'read', edit:'edit', write:'edit', glob:'glob', grep:'grep', bash:'bash',
|
|
661
741
|
task:'task', skill:'skill', lsp:'lsp', question:'question', ask:'question', webfetch:'webfetch',
|
|
662
|
-
websearch:'websearch' }[tc.name];
|
|
742
|
+
websearch:'websearch', browser:'browser' }[tc.name];
|
|
663
743
|
const isMcpAdd = tc.name === 'mcp' && tc.input && tc.input.action === 'add';
|
|
664
744
|
if (permKey || isMcpAdd) {
|
|
665
745
|
const arg = this.permissions.permissionArg(tc.name, tc.input || {});
|
|
@@ -734,6 +814,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
734
814
|
this.addMessage({ role: 'tool', toolCallId: tc.id, content: text });
|
|
735
815
|
// Persist real todo state when the model uses the todowrite tool.
|
|
736
816
|
if (tc.name === 'todowrite' && !outcome.error) {
|
|
817
|
+
todowroteThisTurn = true;
|
|
737
818
|
this.setTodos(tc.input && tc.input.todos);
|
|
738
819
|
// Notify observers (sidebar, etc.) over the tiny event bus — the TUI
|
|
739
820
|
// subscribes once and the panel updates without a re-render cycle.
|
|
@@ -805,7 +886,7 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
|
|
|
805
886
|
maxTokens: this.config.maxTokens || 8192,
|
|
806
887
|
temperature: this.config.temperature ?? 0.7,
|
|
807
888
|
tools: toolDefs,
|
|
808
|
-
system: this.systemPrompt + (this._skillBlock || '') + (this._agentBlock || ''),
|
|
889
|
+
system: this.systemPrompt + (this._skillBlock || '') + (this._agentBlock || '') + this._primaryBlock(),
|
|
809
890
|
signal: this.abortController?.signal,
|
|
810
891
|
// Extended thinking: models tagged 'reasoning' reason BEFORE every
|
|
811
892
|
// reply — including after each tool result — so complex tasks get
|
|
@@ -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
|
@@ -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.
|
|
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
|
-
|
|
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 || "(
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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:#
|
|
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);
|