loom-agent 1.2.0

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 (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,361 @@
1
+ // Agent registry — OpenCode-style specialized agents.
2
+ //
3
+ // Two kinds:
4
+ // primary — full sessions the user talks to (build / plan / chat). The
5
+ // active primary is selected by the session mode, so these exist
6
+ // here mostly for documentation, config and tool-filtering.
7
+ // subagent — focused assistants the main agent delegates to AUTOMATICALLY
8
+ // via the `task` tool, or manually via "@agent" mentions.
9
+ //
10
+ // Every agent carries:
11
+ // id, name, mode, description, tools (last-match-wins pattern list),
12
+ // optional model ("provider/model-id"), prompt (extra system text), and
13
+ // temperature. User config (~/.loom/config.json → config.agents) merges over
14
+ // the built-ins and can add custom subagents or disable any agent.
15
+ const { loadConfig } = require('../config/settings');
16
+
17
+ // Registry of live child sessions keyed by runId — the parent TUI and the
18
+ // /subagents panel cancel a subagent by runId. Cleaned on natural completion.
19
+ const subagentChildren = new Map();
20
+
21
+ function newRunId() {
22
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
23
+ return 'sub-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
24
+ }
25
+
26
+ // Cancel a live subagent by runId. Returns true if a running child was found
27
+ // and interrupted; false if the runId is unknown (already finished/never ran).
28
+ function cancelSubagent(runId) {
29
+ const child = subagentChildren.get(runId);
30
+ if (!child) return false;
31
+ try { child.interrupt(); } catch {}
32
+ return true;
33
+ }
34
+
35
+ // List live runIds (for diagnostics / tests).
36
+ function liveSubagentRunIds() {
37
+ return Array.from(subagentChildren.keys());
38
+ }
39
+
40
+ // Pattern semantics (last match wins):
41
+ // ['*'] → everything allowed (default for primaries)
42
+ // ['read','glob'] → only those tools
43
+ // ['*','!task'] → everything except task (default for subagents)
44
+ // ['mcp__*','!task'] → wildcards work too
45
+ function matchPattern(pat, name) {
46
+ if (pat === name) return true;
47
+ if (pat.includes('*')) {
48
+ const re = new RegExp('^' + pat.split('*').map(escapeRegExp).join('.*') + '$');
49
+ return re.test(name);
50
+ }
51
+ return false;
52
+ }
53
+
54
+ function escapeRegExp(s) {
55
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
56
+ }
57
+
58
+ // Built-in registry. Tool lists are resolved lazily so requiring this module
59
+ // never drags in the tools layer (no circular imports).
60
+ const BUILTIN_AGENTS = {
61
+ build: {
62
+ id: 'build', name: 'Build', mode: 'primary',
63
+ description: 'Full development work with all tools enabled.',
64
+ tools: ['*'],
65
+ prompt: null,
66
+ },
67
+ plan: {
68
+ id: 'plan', name: 'Plan', mode: 'primary',
69
+ description: 'Read-only analysis and planning. Can delegate subagents but never edits files or runs shell commands.',
70
+ tools: () => readOnlyTools(),
71
+ prompt: 'You are the Plan agent. Analyze the request and produce a concrete, ordered plan (exact file paths + what changes). Do not modify anything; delegate heavy investigation to subagents when useful.',
72
+ },
73
+ chat: {
74
+ id: 'chat', name: 'Chat', mode: 'primary',
75
+ description: 'Conversation only, no tools.',
76
+ tools: [],
77
+ prompt: 'You are the Chat agent. Answer conversationally; you have no tools.',
78
+ },
79
+ general: {
80
+ id: 'general', name: 'General', mode: 'subagent',
81
+ description: 'General-purpose subagent with the full toolset (except delegation). Use for self-contained implementation tasks, bug fixes, and multi-step work.',
82
+ tools: ['*', '!task'],
83
+ prompt: null,
84
+ },
85
+ explore: {
86
+ id: 'explore', name: 'Explore', mode: 'subagent',
87
+ description: 'Fast read-only codebase exploration: search symbols, read files, list files. Never modifies anything.',
88
+ // read-only tools, but WITHOUT task — subagents must not delegate (no recursion)
89
+ tools: () => readOnlyTools().filter((t) => t !== 'task'),
90
+ prompt: null,
91
+ },
92
+ scout: {
93
+ id: 'scout', name: 'Scout', mode: 'subagent',
94
+ description: 'External research: fetch docs, check APIs and dependencies. Read-only.',
95
+ tools: ['read', 'glob', 'grep', 'webfetch'],
96
+ prompt: null,
97
+ },
98
+ };
99
+
100
+ function readOnlyTools() {
101
+ const { READ_ONLY_TOOLS } = require('../tools');
102
+ return READ_ONLY_TOOLS.slice();
103
+ }
104
+
105
+ function resolveTools(def) {
106
+ const v = typeof def.tools === 'function' ? def.tools() : def.tools;
107
+ return Array.isArray(v) ? v.slice() : null;
108
+ }
109
+
110
+ function normalizeAgent(id, def) {
111
+ const tools = resolveTools(def);
112
+ return {
113
+ id,
114
+ name: def.name || (id.charAt(0).toUpperCase() + id.slice(1)),
115
+ mode: def.mode === 'primary' ? 'primary' : 'subagent',
116
+ description: String(def.description || ''),
117
+ tools, // null → everything allowed
118
+ model: def.model || null,
119
+ prompt: def.prompt || null,
120
+ temperature: def.temperature != null ? def.temperature : null,
121
+ color: def.color || null,
122
+ };
123
+ }
124
+
125
+ // Built-ins + user config merged (per-agent override, disable, custom agents).
126
+ function loadAgents() {
127
+ const cfg = loadConfig();
128
+ const userAgents = (cfg.agents && typeof cfg.agents === 'object') ? cfg.agents : {};
129
+ const out = {};
130
+
131
+ for (const [id, base] of Object.entries(BUILTIN_AGENTS)) {
132
+ const u = userAgents[id] || {};
133
+ if (u.disable === true) continue;
134
+ out[id] = normalizeAgent(id, { ...base, ...u });
135
+ }
136
+ // Custom subagents from config (need a description + subagent mode).
137
+ for (const [id, u] of Object.entries(userAgents)) {
138
+ if (out[id] || !u || typeof u !== 'object') continue;
139
+ if (u.disable === true) continue;
140
+ if (u.mode && u.mode !== 'subagent' && u.mode !== 'primary') continue;
141
+ if (!u.description) continue;
142
+ out[id] = normalizeAgent(id, {
143
+ mode: u.mode || 'subagent',
144
+ tools: u.tools || ['*', '!task'],
145
+ description: u.description,
146
+ name: u.name,
147
+ model: u.model,
148
+ prompt: u.prompt,
149
+ temperature: u.temperature,
150
+ color: u.color,
151
+ });
152
+ }
153
+ return out;
154
+ }
155
+
156
+ function resolveAgent(id) {
157
+ return loadAgents()[id] || null;
158
+ }
159
+
160
+ // True when the agent may call the given tool (mcp__server__tool works too).
161
+ function agentToolAllowed(agent, toolName) {
162
+ if (!agent) return true;
163
+ const tools = agent.tools;
164
+ if (!tools || !tools.length) return true;
165
+ let allowed = false;
166
+ for (const pat of tools) {
167
+ const p = String(pat);
168
+ if (p === '*') allowed = true;
169
+ else if (p.startsWith('!')) {
170
+ if (matchPattern(p.slice(1), toolName)) allowed = false;
171
+ } else if (matchPattern(p, toolName)) allowed = true;
172
+ }
173
+ return allowed;
174
+ }
175
+
176
+ // Filter a provider tool-definition list down to the agent's allowed set.
177
+ function filterToolDefs(agent, defs) {
178
+ if (!agent || !agent.tools || !agent.tools.length) return defs || [];
179
+ return (defs || []).filter((d) => agentToolAllowed(agent, d && d.name));
180
+ }
181
+
182
+ // "You are now <name>" block for a delegateTurn (@agent mention) — the MAIN
183
+ // session runs one turn as that agent.
184
+ function buildAgentTurnBlock(agent) {
185
+ return '\n\n## Acting as the "' + agent.name + '" agent\n' +
186
+ (agent.description ? agent.description + '\n' : '') +
187
+ 'For this turn only you are "' + agent.name + '".\n' +
188
+ (agent.tools && agent.tools.length
189
+ ? '- Allowed tools this turn: ' + agent.tools.join(', ') + '\n'
190
+ : '- No tools are available this turn — answer directly.\n') +
191
+ (agent.prompt ? '- ' + agent.prompt + '\n' : '') +
192
+ '- When done, answer in Markdown with a complete, self-contained result (the delegator cannot see your internal steps).';
193
+ }
194
+
195
+ // Standalone system-prompt block for a child subagent session (task tool).
196
+ function buildSubagentBlock(agent) {
197
+ return '\n\n## You are the "' + agent.name + '" subagent\n' +
198
+ (agent.description ? agent.description + '\n' : '') +
199
+ 'You were delegated this task by the main Loom agent. Complete it fully and autonomously.\n' +
200
+ (agent.tools && agent.tools.length
201
+ ? '- Allowed tools: ' + agent.tools.join(', ') + '\n'
202
+ : '- No tools are available — answer directly.\n') +
203
+ '- You cannot delegate further (no task tool).\n' +
204
+ (agent.prompt ? '- ' + agent.prompt + '\n' : '') +
205
+ '- Never ask the user questions; work autonomously and report back.\n' +
206
+ '- Your final message is returned verbatim to the delegator: be complete and self-contained. Include exact file paths, line numbers, and code blocks.';
207
+ }
208
+
209
+ // Result of a subagent run (see SpeedStats in session.js for the typedef pattern).
210
+ /** @typedef {object} SubagentResult
211
+ * @property {string} agent display name of the subagent
212
+ * @property {string} id agent id
213
+ * @property {string} [runId] unique id of this run (set on every successful or failed delegation)
214
+ * @property {string} content final answer (capped at 8000 chars)
215
+ * @property {number} tokensIn child session input tokens
216
+ * @property {number} tokensOut child session output tokens
217
+ * @property {number} costUsd child session cost in USD
218
+ * @property {number} durationMs wall time of the run
219
+ * @property {boolean} interrupted true when aborted mid-run
220
+ * @property {string} [error] set when the child returned an error result
221
+ */
222
+ /** @typedef {{error: string}} SubagentError */
223
+
224
+ // Run a child subagent session to completion. Returns
225
+ // { agent, id, content, tokensIn, tokensOut, costUsd, durationMs, interrupted }
226
+ // or { error } for bad agent ids / non-subagents.
227
+ /** @param {object} opts
228
+ * @param {string} opts.agentId
229
+ * @param {string} [opts.prompt]
230
+ * @param {string} [opts.model]
231
+ * @param {import('./session').Session|null} [opts.parentSession]
232
+ * @param {AbortSignal|null} [opts.signal]
233
+ * @param {function} [opts.onProgress]
234
+ * @returns {Promise<SubagentResult|SubagentError>} */
235
+ async function runSubagent(opts) {
236
+ const runId = newRunId();
237
+ const agent = resolveAgent(opts.agentId);
238
+ if (!agent) {
239
+ return { error: 'Unknown agent: "' + opts.agentId + '". Available: ' + Object.keys(loadAgents()).join(', ') };
240
+ }
241
+ if (agent.mode !== 'subagent') {
242
+ return { error: '"' + agent.name + '" is a primary agent — it cannot be delegated to. Use it directly (Tab / /' + agent.id + ').' };
243
+ }
244
+ const prompt = String(opts.prompt || '').trim();
245
+ if (!prompt) return { error: 'task needs a non-empty prompt.' };
246
+
247
+ const parent = opts.parentSession || null;
248
+ const { Session } = require('./session');
249
+ const child = new Session();
250
+ child._isChild = true;
251
+ child.agent = agent;
252
+ if (parent) {
253
+ child.config = {
254
+ ...parent.config,
255
+ model: { ...(parent.config.model || {}) },
256
+ apiKeys: { ...(parent.config.apiKeys || {}) },
257
+ };
258
+ }
259
+ if (agent.temperature != null) child.config.temperature = agent.temperature;
260
+
261
+ // Per-agent / per-call model override ("provider/model-id" or bare model id
262
+ // on the current provider).
263
+ const spec = String(opts.model || agent.model || '');
264
+ if (spec && spec.includes('/')) {
265
+ const slash = spec.indexOf('/');
266
+ const prov = spec.slice(0, slash);
267
+ const mid = spec.slice(slash + 1);
268
+ if (prov && mid) {
269
+ try { child.provider.use(prov); } catch {}
270
+ child.config.model[prov] = mid;
271
+ }
272
+ } else if (spec) {
273
+ const prov = child.config.provider || child.provider.active?.name;
274
+ if (prov) child.config.model[prov] = spec;
275
+ }
276
+
277
+ child._agentBlock = buildSubagentBlock(agent);
278
+
279
+ if (opts.signal) {
280
+ try { opts.signal.addEventListener('abort', () => child.interrupt()); } catch {}
281
+ }
282
+
283
+ // Register for cancellation by runId; unregister on natural completion.
284
+ subagentChildren.set(runId, child);
285
+ const progress = (type, text) => {
286
+ try {
287
+ if (opts.onProgress) opts.onProgress({ runId, id: agent.id, agent: agent.name, type, text });
288
+ } catch {}
289
+ };
290
+
291
+ const t0 = Date.now();
292
+ let resp;
293
+ try {
294
+ // 'start' carries the prompt so the tracker can show what was delegated.
295
+ progress('start', prompt);
296
+ resp = await child.sendUserMessage(prompt, {
297
+ onDelta: (t) => progress('delta', t),
298
+ onReasoning: (t) => progress('reasoning', t),
299
+ onTool: (name, inp) => progress('tool', name),
300
+ onToolResult: (name, out) => progress('toolResult', name),
301
+ });
302
+ } finally {
303
+ subagentChildren.delete(runId);
304
+ }
305
+ const durationMs = Date.now() - t0;
306
+ const interrupted = !!resp.interrupted;
307
+
308
+ if (parent) {
309
+ parent.tokensIn += child.tokensIn;
310
+ parent.tokensOut += child.tokensOut;
311
+ parent.tokensUsed += child.tokensUsed;
312
+ parent.sessionCost += child.sessionCost;
313
+ }
314
+
315
+ if (resp.type === 'error') {
316
+ const errMsg = '[subagent error] ' + String(resp.content || '');
317
+ progress('error', errMsg);
318
+ return {
319
+ agent: agent.name,
320
+ id: agent.id,
321
+ runId,
322
+ content: errMsg,
323
+ tokensIn: child.tokensIn,
324
+ tokensOut: child.tokensOut,
325
+ costUsd: child.sessionCost,
326
+ durationMs,
327
+ interrupted,
328
+ error: errMsg,
329
+ };
330
+ }
331
+ const content = String(resp.content || '(no response)');
332
+ // 'done' carries the final cost/tokens so the panel can render them. The
333
+ // interrupted flag distinguishes user-cancelled runs from clean completions.
334
+ if (opts.onProgress) {
335
+ try { opts.onProgress({ runId, id: agent.id, agent: agent.name, type: 'done', text: content, interrupted, durationMs, tokensIn: child.tokensIn, tokensOut: child.tokensOut, costUsd: child.sessionCost }); } catch {}
336
+ }
337
+ return {
338
+ agent: agent.name,
339
+ id: agent.id,
340
+ runId,
341
+ content: content.slice(0, 8000),
342
+ tokensIn: child.tokensIn,
343
+ tokensOut: child.tokensOut,
344
+ costUsd: child.sessionCost,
345
+ durationMs,
346
+ interrupted,
347
+ };
348
+ }
349
+
350
+ module.exports = {
351
+ BUILTIN_AGENTS,
352
+ loadAgents,
353
+ resolveAgent,
354
+ agentToolAllowed,
355
+ filterToolDefs,
356
+ buildAgentTurnBlock,
357
+ buildSubagentBlock,
358
+ runSubagent,
359
+ cancelSubagent,
360
+ liveSubagentRunIds,
361
+ };
@@ -0,0 +1,103 @@
1
+ // Background tasks — long-running shell commands that don't block the agent
2
+ // loop. The bash tool starts them with background:true; /tasks lists status.
3
+ // Output is capped per task; finished tasks linger until cleared.
4
+ const { spawn } = require('child_process');
5
+ const { emit } = require('./events');
6
+
7
+ /** @type {Map<string, object>} */
8
+ const tasks = new Map();
9
+ let seq = 0;
10
+ // Per-task output cap (matches the foreground 10 MB spirit; smaller here).
11
+ const MAX_BUF = 2 * 1024 * 1024;
12
+
13
+ /**
14
+ * Start a command in the background.
15
+ * @param {string} command
16
+ * @returns {{ id: string }|{ error: string }} or { error } on spawn failure
17
+ */
18
+ function startBackgroundTask(command) {
19
+ const id = 'bt-' + (++seq) + '-' + Date.now().toString(36);
20
+ /** @type {{id:string,command:string,status:string,startedAt:number,endedAt:number|null,exitCode:number|null,output:string,truncated:boolean,_child:any}} */
21
+ const entry = {
22
+ id,
23
+ command: String(command || ''),
24
+ status: 'running',
25
+ startedAt: Date.now(),
26
+ endedAt: null,
27
+ exitCode: null,
28
+ output: '',
29
+ truncated: false,
30
+ _child: null,
31
+ };
32
+ let child;
33
+ try {
34
+ child = spawn(entry.command, { shell: true, windowsHide: true, detached: process.platform !== 'win32' });
35
+ } catch (e) {
36
+ return { error: e.message };
37
+ }
38
+ const pushOut = (d) => {
39
+ if (entry.truncated) return;
40
+ if (entry.output.length + d.length > MAX_BUF) {
41
+ entry.output += d.slice(0, Math.max(0, MAX_BUF - entry.output.length));
42
+ entry.output += '\n[output truncated at 2 MB]';
43
+ entry.truncated = true;
44
+ return;
45
+ }
46
+ entry.output += d;
47
+ };
48
+ child.stdout.on('data', pushOut);
49
+ child.stderr.on('data', pushOut);
50
+ child.on('error', (e) => {
51
+ entry.status = 'error';
52
+ entry.endedAt = Date.now();
53
+ entry.output += '\n[spawn error] ' + e.message;
54
+ emit('tasks:changed', { id });
55
+ });
56
+ child.on('close', (code) => {
57
+ if (entry.status === 'killed') return;
58
+ entry.status = code === 0 ? 'done' : 'error';
59
+ entry.exitCode = code;
60
+ entry.endedAt = Date.now();
61
+ emit('tasks:changed', { id });
62
+ });
63
+ entry._child = child;
64
+ tasks.set(id, entry);
65
+ return { id };
66
+ }
67
+
68
+ /** Get one task (without the child handle). */
69
+ function getBackgroundTask(id) {
70
+ const t = tasks.get(id);
71
+ if (!t) return null;
72
+ const { _child, ...rest } = t;
73
+ return rest;
74
+ }
75
+
76
+ /**
77
+ * List tasks, newest first. opts.running only running ones.
78
+ * @param {{ running?: boolean }} [opts]
79
+ */
80
+ function listBackgroundTasks(opts) {
81
+ let all = [...tasks.values()].sort((a, b) => b.startedAt - a.startedAt);
82
+ if (opts && opts.running) all = all.filter(t => t.status === 'running');
83
+ return all.map(({ _child, ...rest }) => rest);
84
+ }
85
+
86
+ /** Kill a running task. Returns true when found+signalled. */
87
+ function killBackgroundTask(id) {
88
+ const t = tasks.get(id);
89
+ if (!t || t.status !== 'running') return false;
90
+ t.status = 'killed';
91
+ try { t._child.kill(); } catch {}
92
+ t.endedAt = Date.now();
93
+ return true;
94
+ }
95
+
96
+ /** Drop finished/errored/killed tasks from the list. */
97
+ function clearFinishedTasks() {
98
+ for (const [id, t] of tasks) {
99
+ if (t.status !== 'running') tasks.delete(id);
100
+ }
101
+ }
102
+
103
+ module.exports = { startBackgroundTask, getBackgroundTask, listBackgroundTasks, killBackgroundTask, clearFinishedTasks };