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,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 };
|