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,134 @@
1
+ // Subagent run log — append-only JSON store of completed subagent runs so the
2
+ // /subagents panel (and future analytics) can show history across sessions.
3
+ //
4
+ // File: <LOOM_CONFIG_DIR or ~/.loom>/subagents.json — a JSON array of entries
5
+ // (one per completed run). Pruned to a sliding window so the file stays small.
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+
10
+ const FILE_NAME = 'subagents.json';
11
+ // Cap the on-disk log so the file doesn't grow unbounded; pruning drops the
12
+ // oldest entries first when this threshold is exceeded.
13
+ const MAX_ENTRIES = 500;
14
+ // Default age window: drop anything older than this many ms (30 days).
15
+ const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
16
+
17
+ function subagentLogDir() {
18
+ return process.env.LOOM_CONFIG_DIR || path.join(os.homedir(), '.loom');
19
+ }
20
+
21
+ function subagentLogPath() {
22
+ return path.join(subagentLogDir(), FILE_NAME);
23
+ }
24
+
25
+ function ensureDir() {
26
+ try { fs.mkdirSync(subagentLogDir(), { recursive: true }); } catch {}
27
+ }
28
+
29
+ // Read-modify-write: safe for concurrent appends within a single process
30
+ // (the TUI is the only writer). External writers can corrupt the file but
31
+ // that matches the pattern used by keybinds/theme tui.json.
32
+ function readAll() {
33
+ try {
34
+ const raw = fs.readFileSync(subagentLogPath(), 'utf8');
35
+ const arr = JSON.parse(raw);
36
+ return Array.isArray(arr) ? arr : [];
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ function writeAll(entries) {
43
+ ensureDir();
44
+ try {
45
+ fs.writeFileSync(subagentLogPath(), JSON.stringify(entries, null, 0));
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Append a completed subagent run to the log.
54
+ * @param {object} entry
55
+ * @param {string} entry.runId
56
+ * @param {string} entry.agent display name
57
+ * @param {string} entry.agentId id (e.g. 'general')
58
+ * @param {string} entry.prompt delegated instruction
59
+ * @param {'done'|'error'|'cancelled'} entry.status
60
+ * @param {number} entry.startTime ms since epoch
61
+ * @param {number} entry.endTime ms since epoch
62
+ * @param {number} entry.durationMs
63
+ * @param {number} entry.tokensIn
64
+ * @param {number} entry.tokensOut
65
+ * @param {number} entry.costUsd
66
+ * @param {boolean} [entry.interrupted]
67
+ * @param {string} [entry.content] final answer
68
+ * @param {string[]} [entry.toolLog]
69
+ * @param {string} [entry.sessionId] parent conversation id
70
+ * @returns {boolean} true if written
71
+ */
72
+ function saveSubagentRun(entry) {
73
+ if (!entry || !entry.runId) return false;
74
+ const all = readAll();
75
+ all.push(entry);
76
+ // Cap the log size — drop oldest first.
77
+ if (all.length > MAX_ENTRIES) all.splice(0, all.length - MAX_ENTRIES);
78
+ return writeAll(all);
79
+ }
80
+
81
+ /**
82
+ * Load subagent runs. Optional filters narrow the result.
83
+ * @param {object} [opts]
84
+ * @param {number} [opts.since] only entries with startTime >= since
85
+ * @param {string} [opts.sessionId] only entries from this parent session
86
+ * @param {number} [opts.limit] cap on returned entries (newest first)
87
+ * @returns {Array<object>} newest first
88
+ */
89
+ function loadSubagentRuns(opts) {
90
+ const all = readAll();
91
+ let out = all;
92
+ if (opts && opts.since != null) {
93
+ const since = Number(opts.since);
94
+ out = out.filter(e => e && Number(e.startTime) >= since);
95
+ }
96
+ if (opts && opts.sessionId) {
97
+ out = out.filter(e => e && e.sessionId === opts.sessionId);
98
+ }
99
+ // Newest first.
100
+ out.sort((a, b) => Number(b.startTime) - Number(a.startTime));
101
+ if (opts && opts.limit != null && opts.limit > 0) {
102
+ out = out.slice(0, opts.limit);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Drop entries older than maxAgeMs (default 30 days). Rewrites the file.
109
+ * @param {number} [maxAgeMs]
110
+ * @returns {number} count of remaining entries
111
+ */
112
+ function pruneSubagentRuns(maxAgeMs) {
113
+ const cutoff = Date.now() - (maxAgeMs || DEFAULT_MAX_AGE_MS);
114
+ const all = readAll();
115
+ const kept = all.filter(e => e && Number(e.startTime) >= cutoff);
116
+ writeAll(kept);
117
+ return kept.length;
118
+ }
119
+
120
+ /** Test helper — wipe the log entirely. */
121
+ function clearSubagentRuns() {
122
+ return writeAll([]);
123
+ }
124
+
125
+ module.exports = {
126
+ subagentLogPath,
127
+ loadSubagentRuns,
128
+ saveSubagentRun,
129
+ pruneSubagentRuns,
130
+ clearSubagentRuns,
131
+ // Exposed for tests / callers that want the window.
132
+ MAX_ENTRIES,
133
+ DEFAULT_MAX_AGE_MS,
134
+ };
@@ -0,0 +1,31 @@
1
+ const { encoding_for_model, get_encoding } = require('tiktoken');
2
+
3
+ const FALLBACK = 'cl100k_base';
4
+
5
+ function getEncoder(modelId) {
6
+ try {
7
+ if (modelId && modelId.indexOf('claude') >= 0) return get_encoding(FALLBACK);
8
+ if (modelId) return encoding_for_model(modelId);
9
+ return get_encoding(FALLBACK);
10
+ } catch (e) {
11
+ return get_encoding(FALLBACK);
12
+ }
13
+ }
14
+
15
+ function countTokens(text, modelId) {
16
+ if (!text) return 0;
17
+ const enc = getEncoder(modelId);
18
+ return enc.encode(text).length;
19
+ }
20
+
21
+ function countMessages(messages, modelId) {
22
+ let total = 0;
23
+ for (const m of messages) {
24
+ const text = (m.content || '') + '';
25
+ if (text) total += countTokens(text, modelId);
26
+ }
27
+ total += messages.length * 3;
28
+ return total;
29
+ }
30
+
31
+ module.exports = { countTokens, countMessages };
@@ -0,0 +1,6 @@
1
+ async function updateCheck() {
2
+ if (process.env.LOOM_NO_UPDATE_CHECK) return;
3
+ return;
4
+ }
5
+
6
+ module.exports = { updateCheck };
@@ -0,0 +1,166 @@
1
+ // Usage & billing tracker -- persists cumulative token/cost stats to ~/.loom/usage.json.
2
+ // Overridable via LOOM_USAGE_FILE env var (used by tests). The env var is read
3
+ // lazily on each load/save so tests can redirect the ledger at any time.
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ const USAGE_FILE = process.env.LOOM_USAGE_FILE || path.join(os.homedir(), '.loom', 'usage.json');
9
+ function usageFile() { return process.env.LOOM_USAGE_FILE || USAGE_FILE; }
10
+
11
+ function monthKey(d) {
12
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
13
+ }
14
+
15
+ function dayKey(d) {
16
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
17
+ }
18
+
19
+ function load() {
20
+ try { return JSON.parse(fs.readFileSync(usageFile(), 'utf8')); } catch { return {}; }
21
+ }
22
+
23
+ function save(data) {
24
+ try {
25
+ fs.mkdirSync(path.dirname(usageFile()), { recursive: true });
26
+ fs.writeFileSync(usageFile(), JSON.stringify(data, null, 2));
27
+ } catch {}
28
+ }
29
+
30
+ function normalize(data) {
31
+ const totals = data.totals || { inputTokens: 0, outputTokens: 0, costUsd: 0 };
32
+ const months = data.months || {};
33
+ const days = data.days || {};
34
+ const key = monthKey(new Date());
35
+ const dkey = dayKey(new Date());
36
+ const month = months[key] || { inputTokens: 0, outputTokens: 0, costUsd: 0 };
37
+ const day = days[dkey] || { costUsd: 0 };
38
+ return {
39
+ totals,
40
+ months,
41
+ days,
42
+ month,
43
+ day,
44
+ key,
45
+ dkey,
46
+ budgetUsd: typeof data.budgetUsd === 'number' ? data.budgetUsd : 25,
47
+ dailyAlertUsd: typeof data.dailyAlertUsd === 'number' ? data.dailyAlertUsd : 0,
48
+ override: data.override || null,
49
+ };
50
+ }
51
+
52
+ // Add a usage record (tokens + cost in USD) to the lifetime totals, current
53
+ // month, and current day.
54
+ function recordUsage({ inputTokens = 0, outputTokens = 0, costUsd = 0 } = {}) {
55
+ const data = normalize(load());
56
+ data.totals.inputTokens += inputTokens;
57
+ data.totals.outputTokens += outputTokens;
58
+ data.totals.costUsd += costUsd;
59
+ data.months[data.key] = data.month;
60
+ data.months[data.key].inputTokens += inputTokens;
61
+ data.months[data.key].outputTokens += outputTokens;
62
+ data.months[data.key].costUsd += costUsd;
63
+ data.days[data.dkey] = data.day;
64
+ data.days[data.dkey].costUsd += costUsd;
65
+ save({ totals: data.totals, months: data.months, days: data.days, budgetUsd: data.budgetUsd, dailyAlertUsd: data.dailyAlertUsd, override: data.override });
66
+ }
67
+
68
+ function getUsage() {
69
+ const data = normalize(load());
70
+ return {
71
+ totals: data.totals,
72
+ month: data.month,
73
+ monthKey: data.key,
74
+ budgetUsd: data.budgetUsd,
75
+ totalTokens: data.totals.inputTokens + data.totals.outputTokens,
76
+ monthTokens: data.month.inputTokens + data.month.outputTokens,
77
+ day: {
78
+ key: data.dkey,
79
+ costUsd: data.day.costUsd,
80
+ alertUsd: data.dailyAlertUsd,
81
+ alert: data.dailyAlertUsd > 0 && data.day.costUsd >= data.dailyAlertUsd,
82
+ },
83
+ };
84
+ }
85
+
86
+ function setMonthlyBudget(usd) {
87
+ const data = normalize(load());
88
+ // 0 is a valid value — it disables enforcement. Only fall back to 25 when
89
+ // the input isn't a finite non-negative number (NaN, Infinity, negative).
90
+ const n = Number(usd);
91
+ data.budgetUsd = Number.isFinite(n) && n >= 0 ? n : 25;
92
+ save({ totals: data.totals, months: data.months, days: data.days, budgetUsd: data.budgetUsd, dailyAlertUsd: data.dailyAlertUsd, override: data.override });
93
+ }
94
+
95
+ // Set the daily spend alert threshold in USD. 0 disables alerts. The alert is
96
+ // advisory (footer/status warning) — it never blocks a turn.
97
+ function setDailyAlert(usd) {
98
+ const data = normalize(load());
99
+ const n = Number(usd);
100
+ data.dailyAlertUsd = Number.isFinite(n) && n >= 0 ? n : 0;
101
+ save({ totals: data.totals, months: data.months, days: data.days, budgetUsd: data.budgetUsd, dailyAlertUsd: data.dailyAlertUsd, override: data.override });
102
+ }
103
+
104
+ function dayStatus() {
105
+ const data = normalize(load());
106
+ return {
107
+ dayKey: data.dkey,
108
+ dayCostUsd: data.day.costUsd || 0,
109
+ alertUsd: data.dailyAlertUsd,
110
+ alert: data.dailyAlertUsd > 0 && (data.day.costUsd || 0) >= data.dailyAlertUsd,
111
+ };
112
+ }
113
+
114
+ // Where we stand against the monthly spend cap. `over` is true once the
115
+ // month's cost has reached the cap — sessions use this to hard-block paid
116
+ // turns. A cap of 0 disables enforcement (treated as unlimited).
117
+ // `overrideUsed` is true when the user explicitly confirmed a one-shot
118
+ // override for the current month (/budget override) that has not been
119
+ // consumed yet.
120
+ function budgetStatus() {
121
+ const data = normalize(load());
122
+ const usd = data.month.costUsd || 0;
123
+ const cap = data.budgetUsd;
124
+ const overrideUsed = !!(data.override && data.override.month === data.key && !data.override.consumed);
125
+ return {
126
+ monthCostUsd: usd,
127
+ budgetUsd: cap,
128
+ pct: cap > 0 ? (usd / cap) * 100 : 0,
129
+ over: cap > 0 && usd >= cap,
130
+ overrideUsed,
131
+ };
132
+ }
133
+
134
+ // One-shot confirmation: lets exactly one paid turn through after the cap is
135
+ // reached. Consumed the first time a paid turn actually proceeds.
136
+ function requestOverride() {
137
+ const data = normalize(load());
138
+ data.override = { month: data.key, consumed: false };
139
+ save({ totals: data.totals, months: data.months, days: data.days, budgetUsd: data.budgetUsd, dailyAlertUsd: data.dailyAlertUsd, override: data.override });
140
+ return true;
141
+ }
142
+
143
+ function consumeOverride() {
144
+ const data = normalize(load());
145
+ if (!(data.override && data.override.month === data.key && !data.override.consumed)) return false;
146
+ data.override.consumed = true;
147
+ save({ totals: data.totals, months: data.months, days: data.days, budgetUsd: data.budgetUsd, dailyAlertUsd: data.dailyAlertUsd, override: data.override });
148
+ return true;
149
+ }
150
+
151
+ // 37283 -> "37.3K", 1500000 -> "1.5M", 512 -> "512"
152
+ function formatTokens(n) {
153
+ const v = Number(n) || 0;
154
+ if (v >= 1e9) return (v / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
155
+ if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
156
+ if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
157
+ return String(v);
158
+ }
159
+
160
+ // 4.843 -> "$4.84", 0.14 -> "$0.14", 120 -> "$120"
161
+ function formatUsd(n) {
162
+ const v = Number(n) || 0;
163
+ return '$' + (v >= 100 ? v.toFixed(0) : v.toFixed(2));
164
+ }
165
+
166
+ module.exports = { recordUsage, getUsage, setMonthlyBudget, setDailyAlert, dayStatus, budgetStatus, requestOverride, consumeOverride, formatTokens, formatUsd, usageFile, USAGE_FILE };
package/src/index.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ require('dotenv').config();
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ const localEnv = path.join(process.cwd(), '.env');
7
+ if (fs.existsSync(localEnv)) {
8
+ require('dotenv').config({ path: localEnv, override: false });
9
+ }
10
+
11
+ const { main } = require('./core/cli');
12
+ const { updateCheck } = require('./core/update');
13
+ const { LoomError } = require('./core/errors');
14
+
15
+ process.title = 'loom-code';
16
+
17
+ process.on('uncaughtException', (err) => {
18
+ if (err instanceof LoomError) {
19
+ console.error(`\n[Loom Error] ${err.message}`);
20
+ process.exit(1);
21
+ }
22
+ console.error(`\n[Unexpected Error] ${err.message}`);
23
+ if (process.env.LOOM_DEBUG) console.error(err.stack);
24
+ process.exit(1);
25
+ });
26
+
27
+ process.on('unhandledRejection', (reason) => {
28
+ console.error(`\n[Unhandled Promise]`, reason);
29
+ if (process.env.LOOM_DEBUG) console.error(reason?.stack);
30
+ process.exit(1);
31
+ });
32
+
33
+ (async () => {
34
+ try {
35
+ await updateCheck();
36
+ await main();
37
+ } catch (err) {
38
+ console.error(err?.message || err);
39
+ process.exit(1);
40
+ }
41
+ })();
@@ -0,0 +1,201 @@
1
+ const { spawn } = require('child_process');
2
+ const { loadServers } = require('./mcp-manager');
3
+
4
+ let toolCachePromise = null;
5
+
6
+ // Kill the whole process tree. On Windows, spawn kill leaves grandchildren
7
+ // (npx -> node) running as orphans that hold ports and memory. On POSIX the
8
+ // child is spawned detached as a process-group leader, so killing the group
9
+ // (-pid) takes down every descendant.
10
+ function killTree(child) {
11
+ if (!child || child.killed) return;
12
+ if (process.platform === 'win32') {
13
+ try {
14
+ const { execSync } = require('child_process');
15
+ execSync('taskkill /PID ' + child.pid + ' /T /F', { stdio: 'ignore', windowsHide: true });
16
+ return;
17
+ } catch {}
18
+ }
19
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
20
+ child.kill('SIGKILL');
21
+ }
22
+
23
+ function connectToJson(cfg, timeoutMs) {
24
+ return new Promise((resolve, reject) => {
25
+ const child = spawn(cfg.command, cfg.args || [], {
26
+ stdio: ['pipe', 'pipe', 'pipe'],
27
+ env: Object.assign({}, process.env, cfg.env || {}),
28
+ // No console window for stdio MCP servers: on Windows spawn() would
29
+ // otherwise pop a flashing console up and down while chats happen.
30
+ windowsHide: true,
31
+ // POSIX only: make the server a process-group leader so killTree can
32
+ // kill the whole tree (npx + its node child) with one -pid signal.
33
+ detached: process.platform !== 'win32',
34
+ });
35
+ // A server that dies mid-conversation can emit EPIPE on stdin — without
36
+ // a listener that 'error' would crash the whole app.
37
+ child.stdin.on('error', () => {});
38
+ const timeout = setTimeout(() => {
39
+ killTree(child);
40
+ reject(new Error('Timed out connecting to ' + (cfg.command || '') + ' (is it installed?)'));
41
+ }, timeoutMs || 8000);
42
+ child.on('error', (e) => {
43
+ clearTimeout(timeout);
44
+ reject(e);
45
+ });
46
+ child.once('spawn', () => {
47
+ clearTimeout(timeout);
48
+ resolve(child);
49
+ });
50
+ });
51
+ }
52
+
53
+ let rpcSeq = 0;
54
+ const DEFAULT_RPC_TIMEOUT = 60000;
55
+
56
+ function callRpc(child, method, params, timeoutMs) {
57
+ return new Promise((resolve, reject) => {
58
+ const id = (rpcSeq += 1);
59
+ let buf = '';
60
+ let timer = null;
61
+ let settled = false;
62
+ const onStderr = () => {};
63
+ const cleanup = () => {
64
+ if (timer) clearTimeout(timer);
65
+ child.stdout.off('data', onData);
66
+ child.stderr.off('data', onStderr);
67
+ child.off('close', onClose);
68
+ child.off('error', onChildError);
69
+ };
70
+ const onClose = (code, signal) => {
71
+ if (settled) return;
72
+ settled = true;
73
+ cleanup();
74
+ reject(new Error('MCP server exited (code ' + code + (signal ? ', signal ' + signal : '') + ') during ' + method));
75
+ };
76
+ const onChildError = (e) => {
77
+ if (settled) return;
78
+ settled = true;
79
+ cleanup();
80
+ reject(e);
81
+ };
82
+ const onData = (chunk) => {
83
+ if (settled) return; // safely ignore anything after resolve/reject
84
+ let text = chunk.toString();
85
+ // Some Windows stdio servers emit a UTF-8 BOM on the first line, which
86
+ // would break JSON.parse and stall the RPC until timeout.
87
+ if (!buf) text = text.replace(/^/, '');
88
+ buf += text;
89
+ let idx;
90
+ while ((idx = buf.indexOf('\n')) >= 0) {
91
+ const line = buf.slice(0, idx).trim();
92
+ buf = buf.slice(idx + 1);
93
+ if (!line) continue;
94
+ let msg;
95
+ try { msg = JSON.parse(line); } catch { continue; }
96
+ // Some servers (or broken MCP packages) send non-object lines like
97
+ // `null`, `[]`, or strings — never crash on them.
98
+ if (msg && typeof msg === 'object' && msg.id === id) {
99
+ settled = true;
100
+ cleanup();
101
+ if (msg.error) reject(new Error((msg.error && msg.error.message) || 'MCP error'));
102
+ else resolve(msg.result);
103
+ }
104
+ }
105
+ };
106
+ timer = setTimeout(() => {
107
+ settled = true;
108
+ cleanup();
109
+ reject(new Error('MCP RPC timed out: ' + method));
110
+ }, timeoutMs || DEFAULT_RPC_TIMEOUT);
111
+ child.stdout.on('data', onData);
112
+ child.stderr.on('data', onStderr);
113
+ child.once('close', onClose);
114
+ child.once('error', onChildError);
115
+ try {
116
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
117
+ } catch (e) {
118
+ settled = true;
119
+ cleanup();
120
+ reject(e);
121
+ }
122
+ });
123
+ }
124
+
125
+ function notify(child, method, params) {
126
+ try {
127
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
128
+ } catch {}
129
+ }
130
+
131
+ async function getTools() {
132
+ const { servers } = loadServers();
133
+ const out = [];
134
+ const failed = [];
135
+ for (const [name, cfg] of Object.entries(servers)) {
136
+ if (cfg.enabled === false) continue;
137
+ const entry = { server: name };
138
+ let child;
139
+ try {
140
+ child = await connectToJson(cfg, 8000);
141
+ await callRpc(child, 'initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'loom', version: '1.0.0' } }, 10000).catch(() => ({}));
142
+ notify(child, 'notifications/initialized', {});
143
+ const toolsRes = await callRpc(child, 'tools/list', {}, 10000).catch(() => ({ tools: [] }));
144
+ entry.tools = (toolsRes && toolsRes.tools) || [];
145
+ } catch (e) {
146
+ entry.error = e.message;
147
+ failed.push({ server: name, error: e.message });
148
+ } finally {
149
+ killTree(child);
150
+ }
151
+ out.push(entry);
152
+ }
153
+ if (failed.length) {
154
+ try { require('../core/events').emit('mcp:failed', { servers: failed }); } catch {}
155
+ }
156
+ return out;
157
+ }
158
+
159
+ async function callTool(server, toolName, input) {
160
+ const cfg = loadServers().servers[server];
161
+ if (!cfg) return { error: 'MCP server not found: ' + server };
162
+ let child;
163
+ try {
164
+ child = await connectToJson(cfg, 8000);
165
+ await callRpc(child, 'initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'loom', version: '1.0.0' } }, 10000).catch(() => ({}));
166
+ notify(child, 'notifications/initialized', {});
167
+ const res = await callRpc(child, 'tools/call', { name: toolName, arguments: input }, DEFAULT_RPC_TIMEOUT).catch(() => ({ isError: true, content: [] }));
168
+ if (res === undefined || res.isError) {
169
+ const text = ((res && res.content) || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
170
+ return { error: text || 'MCP tool error' };
171
+ }
172
+ const text = ((res && res.content) || []).map((c) => (c.type === 'text' ? c.text : JSON.stringify(c))).join('\n');
173
+ return { result: text || '(empty result)' };
174
+ } catch (e) {
175
+ return { error: e.message };
176
+ } finally {
177
+ killTree(child);
178
+ }
179
+ }
180
+
181
+ function clearCache() {
182
+ toolCachePromise = null;
183
+ }
184
+
185
+ // Kick off tool discovery in the background (spawning servers can take seconds
186
+ // on first run). The first turn awaits it with a deadline via getAllToolDefinitions.
187
+ function warm() {
188
+ if (process.env.LOOM_MCP_NO_WARM) return Promise.resolve([]);
189
+ if (!toolCachePromise) toolCachePromise = getTools();
190
+ return toolCachePromise;
191
+ }
192
+
193
+ function buildToolName(server, tool) {
194
+ return 'mcp__' + server + '__' + tool;
195
+ }
196
+
197
+ function getCachedTools() {
198
+ return warm();
199
+ }
200
+
201
+ module.exports = { getTools, getCachedTools, clearCache, buildToolName, callTool, warm, callRpc, killTree };