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.
- package/.env.example +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- 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,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 };
|