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,287 @@
1
+ // Agent Client Protocol (ACP) server — lets ACP-compatible editors (Zed,
2
+ // JetBrains, Avante.nvim, CodeCompanion.nvim, ...) drive Loom as a subprocess.
3
+ //
4
+ // Transport: JSON-RPC 2.0 messages, one per line (newline-delimited JSON) over
5
+ // stdio. The editor sends requests on stdin; Loom answers on stdout and queues
6
+ // agent events that the client retrieves with fetchAgentEvent.
7
+ //
8
+ // Implemented methods (subset of the ACP spec):
9
+ // initialize, connect, storeMessage, sendChatRequest, fetchAgentEvent,
10
+ // cancelCurrentTask, updateAgentConfig, changeDefaultMode, enableToolUse,
11
+ // disposeTool
12
+ //
13
+ // Run with: loom acp (or: bun src/acp/acp-server.js)
14
+ const readline = require('readline');
15
+ const os = require('os');
16
+ const path = require('path');
17
+ const { Session, isAbortError } = require('../core/session');
18
+ const { getToolDefinitions } = require('../tools');
19
+ const { loadConfig, saveConfig } = require('../config/settings');
20
+
21
+ const ACP_METHODS = new Set([
22
+ 'initialize',
23
+ 'connect',
24
+ 'storeMessage',
25
+ 'sendChatRequest',
26
+ 'fetchAgentEvent',
27
+ 'cancelCurrentTask',
28
+ 'updateAgentConfig',
29
+ 'changeDefaultMode',
30
+ 'enableToolUse',
31
+ 'disposeTool',
32
+ ]);
33
+
34
+ /** @typedef {Object} AcpTask
35
+ * @property {Session} session
36
+ * @property {boolean} active
37
+ * @property {Array<object>} events
38
+ * @property {string} mode
39
+ */
40
+
41
+ class AcpServer {
42
+ /**
43
+ * @param {object} [opts]
44
+ * @param {NodeJS.ReadableStream|any} [opts.input]
45
+ * @param {NodeJS.WritableStream|any} [opts.output]
46
+ */
47
+ constructor(opts = {}) {
48
+ this.input = opts.input || process.stdin;
49
+ this.output = opts.output || process.stdout;
50
+ /** @type {Map<string, AcpTask>} */
51
+ this.tasks = new Map();
52
+ this.rl = null;
53
+ }
54
+
55
+ start() {
56
+ this.rl = readline.createInterface({ input: this.input, terminal: false });
57
+ this.rl.on('line', (line) => {
58
+ const trimmed = String(line).trim();
59
+ if (!trimmed) return;
60
+ let req;
61
+ try { req = JSON.parse(trimmed); } catch { return; }
62
+ this.handleRequest(req).catch((e) => {
63
+ if (req && req.id != null) this.reply(req.id, null, { code: -32603, message: e && e.message ? e.message : String(e) });
64
+ });
65
+ });
66
+ }
67
+
68
+ /** @param {object} req */
69
+ async handleRequest(req) {
70
+ if (!req || typeof req.method !== 'string') return;
71
+ const method = req.method;
72
+ const params = req.params || {};
73
+ const id = req.id != null ? req.id : null;
74
+ if (!ACP_METHODS.has(method)) {
75
+ if (id != null) this.reply(id, null, { code: -32601, message: 'Method not found: ' + method });
76
+ return;
77
+ }
78
+ if (id == null) {
79
+ // Notification — methods that expect a response still work; ignore result.
80
+ try { await this[method](params); } catch {}
81
+ return;
82
+ }
83
+ try {
84
+ const result = await this[method](params);
85
+ this.reply(id, result === undefined ? null : result, null);
86
+ } catch (e) {
87
+ this.reply(id, null, { code: -32603, message: e && e.message ? e.message : String(e) });
88
+ }
89
+ }
90
+
91
+ /** @param {number|string|null} id
92
+ * @param {*} result
93
+ * @param {{code:number, message:string}|null} error */
94
+ reply(id, result, error) {
95
+ const msg = { jsonrpc: '2.0', id };
96
+ if (error) msg.error = error;
97
+ else msg.result = result !== undefined ? result : null;
98
+ this.write(msg);
99
+ }
100
+
101
+ /** @param {object} obj */
102
+ write(obj) {
103
+ try { this.output.write(JSON.stringify(obj) + '\n'); } catch {}
104
+ }
105
+
106
+ // ── ACP methods ──
107
+
108
+ initialize() {
109
+ const defs = getToolDefinitions('build');
110
+ return {
111
+ protocolVersion: 1,
112
+ capabilities: { openai: true, customInstructions: true },
113
+ toolSchemas: defs.map((d) => ({
114
+ type: 'function',
115
+ function: {
116
+ name: d.name,
117
+ description: d.description,
118
+ parameters: d.input_schema || { type: 'object', properties: {} },
119
+ },
120
+ })),
121
+ agentConfig: {
122
+ builtInTools: defs.map((d) => d.name),
123
+ customInstructions: null,
124
+ includeAgentContext: false,
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * @param {{taskId?: string, agentConfig?: object}} params
131
+ */
132
+ connect(params) {
133
+ const taskId = params.taskId || 'task-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
134
+ const session = new Session();
135
+ const agentConfig = params.agentConfig || {};
136
+ if (agentConfig.mode === 'plan' || agentConfig.mode === 'chat' || agentConfig.mode === 'build') {
137
+ session.setMode(agentConfig.mode);
138
+ }
139
+ if (typeof agentConfig.instructions === 'string' && agentConfig.instructions.trim()) {
140
+ session._agentBlock = '\n\n[ACP session instructions]\n' + agentConfig.instructions.trim() + '\n';
141
+ }
142
+ /** @type {AcpTask} */
143
+ const task = { session, active: false, events: [], mode: session.mode };
144
+ this.tasks.set(taskId, task);
145
+ this.pushEvent(task, 'session.updated', { type: 'created' });
146
+ return { taskId };
147
+ }
148
+
149
+ /**
150
+ * @param {{taskId: string, message: object}} params
151
+ */
152
+ storeMessage(params) {
153
+ const task = this.getTask(params.taskId);
154
+ const msg = params.message || {};
155
+ task.session.addMessage({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: String(msg.content || '') });
156
+ this.pushEvent(task, 'session.updated', { type: 'expanded' });
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * @param {{taskId: string, message: {content?: string}}} params
162
+ */
163
+ async sendChatRequest(params) {
164
+ const task = this.getTask(params.taskId);
165
+ if (task.active) throw new Error('Task already has an active request; wait for it to complete or cancel it.');
166
+ const text = String((params.message && params.message.content) || '');
167
+ if (!text.trim()) throw new Error('Message content is empty.');
168
+ const requestId = 'req-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1e6).toString(36);
169
+ const messageId = 'msg-' + Date.now().toString(36);
170
+ task.active = true;
171
+ this.pushEvent(task, 'session.updated', { type: 'expanded' });
172
+ (() => {
173
+ task.session.sendUserMessage(text, {
174
+ onDelta: (t) => this.pushEvent(task, 'agent.message', { messageId, content: { type: 'text', content: t } }),
175
+ onReasoning: (t) => this.pushEvent(task, 'agent.message', { messageId: messageId + '-r', content: { type: 'reasoning', content: t } }),
176
+ onTool: (toolName, input) => this.pushEvent(task, 'tool.use', { toolName, id: 'tool-' + Date.now().toString(36), input }),
177
+ onToolResult: (toolName, out, input) =>
178
+ this.pushEvent(task, 'tool.result', { toolName, input, result: out && out.result != null ? out.result : (out && out.error) }),
179
+ }).then((resp) => {
180
+ this.pushEvent(task, 'session.updated', { type: 'expanded' });
181
+ if (resp.interrupted) {
182
+ this.pushEvent(task, 'request.error', { requestId, message: '(interrupted)' });
183
+ } else if (resp.type === 'error') {
184
+ this.pushEvent(task, 'request.error', { requestId, message: String(resp.content || '') });
185
+ } else {
186
+ this.pushEvent(task, 'agent.message.completed', { messageId });
187
+ this.pushEvent(task, 'request.completed', { requestId, response: { type: 'text', text: String(resp.content || '') } });
188
+ }
189
+ }).catch((e) => {
190
+ this.pushEvent(task, 'session.updated', { type: 'expanded' });
191
+ if (isAbortError(e)) {
192
+ this.pushEvent(task, 'request.error', { requestId, message: '(interrupted)' });
193
+ } else {
194
+ this.pushEvent(task, 'request.error', { requestId, message: e && e.message ? e.message : String(e) });
195
+ }
196
+ }).finally(() => {
197
+ task.active = false;
198
+ });
199
+ })();
200
+ return { requestId };
201
+ }
202
+
203
+ /**
204
+ * @param {{taskId: string, cursor?: number}} params
205
+ */
206
+ fetchAgentEvent(params) {
207
+ const task = this.getTask(params.taskId);
208
+ const cursor = Number(params.cursor || 0);
209
+ const events = task.events.slice(cursor);
210
+ return { events, cursor: cursor + events.length };
211
+ }
212
+
213
+ /**
214
+ * @param {{taskId: string}} params
215
+ */
216
+ cancelCurrentTask(params) {
217
+ const task = this.getTask(params.taskId);
218
+ try { task.session.interrupt(); } catch {}
219
+ return null;
220
+ }
221
+
222
+ /**
223
+ * @param {{taskId: string, agentConfig?: object}} params
224
+ */
225
+ updateAgentConfig(params) {
226
+ const task = this.getTask(params.taskId);
227
+ const agentConfig = params.agentConfig || {};
228
+ if (typeof agentConfig.instructions === 'string' && agentConfig.instructions.trim()) {
229
+ task.session._agentBlock = '\n\n[ACP session instructions]\n' + agentConfig.instructions.trim() + '\n';
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /**
235
+ * @param {{taskId: string, mode?: string}} params
236
+ */
237
+ changeDefaultMode(params) {
238
+ const task = this.getTask(params.taskId);
239
+ if (params.mode === 'plan' || params.mode === 'chat' || params.mode === 'build') {
240
+ task.session.setMode(params.mode);
241
+ task.mode = params.mode;
242
+ }
243
+ return null;
244
+ }
245
+
246
+ enableToolUse() {
247
+ // All built-in tools + MCP tools are enabled by default; nothing to gate.
248
+ return null;
249
+ }
250
+
251
+ disposeTool() {
252
+ return null;
253
+ }
254
+
255
+ /** @param {string} taskId
256
+ * @returns {AcpTask} */
257
+ getTask(taskId) {
258
+ const task = this.tasks.get(String(taskId));
259
+ if (!task) throw new Error('Unknown task: ' + taskId);
260
+ return task;
261
+ }
262
+
263
+ /** @param {AcpTask} task
264
+ * @param {string} event
265
+ * @param {object} payload */
266
+ pushEvent(task, event, payload) {
267
+ const item = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), event, ...payload };
268
+ task.events.push(item);
269
+ return item;
270
+ }
271
+ }
272
+
273
+ /** Print a tiny readiness line to stderr (never corrupts the stdio protocol). */
274
+ function announce() {
275
+ const c = loadConfig();
276
+ const loc = process.env.LOOM_CONFIG_DIR || path.join(os.homedir(), '.loom', 'config.json');
277
+ process.stderr.write(`[loom acp] started — provider: ${c.provider}, config: ${loc}\n`);
278
+ }
279
+
280
+ function main() {
281
+ announce();
282
+ const server = new AcpServer();
283
+ server.start();
284
+ process.stdin.resume();
285
+ }
286
+
287
+ module.exports = { AcpServer, main };
@@ -0,0 +1,37 @@
1
+ const { loadConfig, saveConfig } = require('./settings');
2
+ const { loadRegistry, envNamesFor } = require('../providers/registry');
3
+
4
+ function connect(provider, apiKey) {
5
+ const valid = ['anthropic', 'openai', 'nvidia', 'google', 'local'];
6
+ const reg = loadRegistry() || {};
7
+ if (!valid.includes(provider) && !reg[provider]) {
8
+ throw new Error(`Unknown provider: ${provider}. Run /providers to list every supported provider.`);
9
+ }
10
+ const config = loadConfig();
11
+ config.provider = provider;
12
+ config.apiKeys = config.apiKeys || {};
13
+ if (apiKey) config.apiKeys[provider] = apiKey;
14
+ saveConfig(config);
15
+ return `Connected to ${provider}.`;
16
+ }
17
+
18
+ function disconnect() {
19
+ const config = loadConfig();
20
+ const old = config.provider;
21
+ config.provider = '';
22
+ config.apiKeys = config.apiKeys || {};
23
+ delete config.apiKeys[old];
24
+ saveConfig(config);
25
+ return `Disconnected from ${old}.`;
26
+ }
27
+
28
+ function status() {
29
+ const config = loadConfig();
30
+ return {
31
+ provider: config.provider,
32
+ model: config.model?.[config.provider] || 'default',
33
+ hasKey: !!(config.apiKeys?.[config.provider] || (envNamesFor(config.provider) || []).some(n => !!process.env[n]))
34
+ };
35
+ }
36
+
37
+ module.exports = { connect, disconnect, status };
@@ -0,0 +1,164 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const LOOM_DIR = path.join(os.homedir(), '.loom');
6
+ const CONFIG_FILE = path.join(LOOM_DIR, 'config.json');
7
+ const GLOBAL_LOOM_MD = path.join(LOOM_DIR, 'LOOM.md');
8
+
9
+ // Re-evaluated on every call so tests/CI can point config elsewhere with
10
+ // LOOM_CONFIG_DIR; the exported LOOM_DIR/CONFIG_FILE constants keep the
11
+ // default for display purposes.
12
+ function loomDir() {
13
+ return process.env.LOOM_CONFIG_DIR || LOOM_DIR;
14
+ }
15
+
16
+ function configFile() {
17
+ return path.join(loomDir(), 'config.json');
18
+ }
19
+
20
+ const DEFAULTS = {
21
+ provider: 'anthropic',
22
+ model: {
23
+ anthropic: 'claude-sonnet-4-20250514',
24
+ openai: 'gpt-5-fast',
25
+ nvidia: 'meta/llama-3.1-8b-instruct',
26
+ google: 'gemini-2.5-flash',
27
+ openrouter: 'anthropic/claude-sonnet-4',
28
+ tokenrouter: 'moonshotai/kimi-k3-free',
29
+ local: 'llama3.2',
30
+ },
31
+ maxTokens: 16384,
32
+ temperature: 0.3,
33
+ compactThreshold: 0.75,
34
+ budgetLevel: 'auto', // free | cheap | best | auto (explicit picks)
35
+ apiKeys: {},
36
+ customEndpoints: {},
37
+ recentModels: [],
38
+ skillDisabled: [],
39
+ // OpenCode-style formatters / LSP. false = disabled (default). true = enable
40
+ // all built-ins. object = built-ins + per-id overrides/customs.
41
+ formatter: false,
42
+ lsp: false,
43
+ baseUrls: {
44
+ anthropic: 'https://api.anthropic.com',
45
+ openai: 'https://api.openai.com/v1',
46
+ nvidia: 'https://integrate.api.nvidia.com/v1',
47
+ google: 'https://generativelanguage.googleapis.com/v1beta/openai/',
48
+ openrouter: 'https://openrouter.ai/api/v1',
49
+ tokenrouter: 'https://api.tokenrouter.com/v1',
50
+ local: 'http://localhost:11434/v1',
51
+ },
52
+ };
53
+
54
+ function ensureLoomDir() {
55
+ const dir = loomDir();
56
+ if (!fs.existsSync(dir)) {
57
+ fs.mkdirSync(dir, { recursive: true });
58
+ }
59
+ }
60
+
61
+ function loadConfig() {
62
+ ensureLoomDir();
63
+ const file = configFile();
64
+ if (fs.existsSync(file)) {
65
+ try { fs.chmodSync(file, 0o600); } catch {}
66
+ try {
67
+ const raw = fs.readFileSync(file, 'utf8');
68
+ return { ...DEFAULTS, ...JSON.parse(raw) };
69
+ } catch {
70
+ return { ...DEFAULTS };
71
+ }
72
+ }
73
+ return { ...DEFAULTS };
74
+ }
75
+
76
+ function saveConfig(config) {
77
+ ensureLoomDir();
78
+ const file = configFile();
79
+ fs.writeFileSync(file, JSON.stringify(config, null, 2));
80
+ try { fs.chmodSync(file, 0o600); } catch {}
81
+ }
82
+
83
+ function getApiKey(provider) {
84
+ const { envNamesFor } = require('../providers/registry');
85
+ const envNames = envNamesFor(provider) || [];
86
+ for (const key of envNames) {
87
+ if (process.env[key]) return process.env[key];
88
+ }
89
+
90
+ const config = loadConfig();
91
+ return config.apiKeys?.[provider] || null;
92
+ }
93
+
94
+ function setApiKey(provider, key) {
95
+ const config = loadConfig();
96
+ config.apiKeys = config.apiKeys || {};
97
+ config.apiKeys[provider] = key;
98
+ saveConfig(config);
99
+ }
100
+
101
+ function resolveApiKey(provider) {
102
+ const key = getApiKey(provider);
103
+ if (!key) {
104
+ throw new Error(
105
+ `No API key found for ${provider}. Set the env variable or run: loom connect ${provider} <key>`
106
+ );
107
+ }
108
+ return key;
109
+ }
110
+
111
+ function getBaseUrl(provider) {
112
+ const envMap = {
113
+ anthropic: ['ANTHROPIC_BASE_URL'],
114
+ openai: ['OPENAI_BASE_URL'],
115
+ nvidia: ['NVIDIA_BASE_URL'],
116
+ google: ['GOOGLE_BASE_URL'],
117
+ openrouter: ['OPENROUTER_BASE_URL'],
118
+ tokenrouter: ['TOKENROUTER_BASE_URL'],
119
+ };
120
+ for (const env of (envMap[provider] || [])) {
121
+ if (process.env[env]) return process.env[env];
122
+ }
123
+ const cfg = loadConfig();
124
+ return cfg.baseUrls?.[provider] || cfg.customEndpoints?.[provider] || DEFAULTS.baseUrls?.[provider] || null;
125
+ }
126
+
127
+ function setBaseUrl(provider, url) {
128
+ const config = loadConfig();
129
+ config.baseUrls = config.baseUrls || {};
130
+ config.baseUrls[provider] = url;
131
+ saveConfig(config);
132
+ }
133
+
134
+ // Remember which provider+model was used, most recent first (deduped, capped).
135
+ function recordModelUse(provider, modelId) {
136
+ if (!provider || !modelId) return;
137
+ const config = loadConfig();
138
+ const list = (config.recentModels || []).filter(r => !(r.provider === provider && r.model === modelId));
139
+ list.unshift({ provider, model: modelId, at: Date.now() });
140
+ config.recentModels = list.slice(0, 8);
141
+ saveConfig(config);
142
+ }
143
+
144
+ function getRecentModels() {
145
+ const cfg = loadConfig();
146
+ return (cfg.recentModels || []).slice(0, 8);
147
+ }
148
+
149
+ // Whether a usable key exists for a provider (or it's a keyless local backend).
150
+ function hasApiKey(provider) {
151
+ if (provider === 'local') return true;
152
+ const { envNamesFor } = require('../providers/registry');
153
+ const cfg = loadConfig();
154
+ if (cfg.apiKeys?.[provider]) return true;
155
+ return (envNamesFor(provider) || []).some(n => !!process.env[n]);
156
+ }
157
+
158
+ module.exports = {
159
+ LOOM_DIR, CONFIG_FILE, GLOBAL_LOOM_MD, DEFAULTS,
160
+ loadConfig, saveConfig,
161
+ getApiKey, setApiKey, resolveApiKey,
162
+ ensureLoomDir, getBaseUrl, setBaseUrl,
163
+ recordModelUse, getRecentModels, hasApiKey
164
+ };