@agentproto/runtime 0.1.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/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/config.d.ts +144 -0
- package/dist/config.mjs +76 -0
- package/dist/config.mjs.map +1 -0
- package/dist/conversations.d.ts +60 -0
- package/dist/conversations.mjs +146 -0
- package/dist/conversations.mjs.map +1 -0
- package/dist/heartbeat-COGpMrJS.d.ts +120 -0
- package/dist/heartbeat.d.ts +2 -0
- package/dist/heartbeat.mjs +185 -0
- package/dist/heartbeat.mjs.map +1 -0
- package/dist/index.d.ts +546 -0
- package/dist/index.mjs +4350 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mcp-imports.d.ts +117 -0
- package/dist/mcp-imports.mjs +82 -0
- package/dist/mcp-imports.mjs.map +1 -0
- package/dist/resume-strategies.d.ts +66 -0
- package/dist/resume-strategies.mjs +64 -0
- package/dist/resume-strategies.mjs.map +1 -0
- package/dist/workspace-fs.d.ts +26 -0
- package/dist/workspace-fs.mjs +60 -0
- package/dist/workspace-fs.mjs.map +1 -0
- package/dist/workspaces-config.d.ts +81 -0
- package/dist/workspaces-config.mjs +136 -0
- package/dist/workspaces-config.mjs.map +1 -0
- package/package.json +103 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,4350 @@
|
|
|
1
|
+
import { randomUUID, randomBytes, createHash } from 'crypto';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, promises, readFileSync } from 'fs';
|
|
3
|
+
import { unlink, readFile, stat, readdir, writeFile, mkdir, chmod, rm } from 'fs/promises';
|
|
4
|
+
import { join, resolve, dirname, isAbsolute, normalize, relative, basename } from 'path';
|
|
5
|
+
import { createMcpServer } from '@agentproto/mcp-server';
|
|
6
|
+
import { execFile, spawn } from 'child_process';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import matter from 'gray-matter';
|
|
11
|
+
import { createServer } from 'http';
|
|
12
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
13
|
+
import { WebSocketServer } from 'ws';
|
|
14
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
15
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
16
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
17
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
18
|
+
import { promisify } from 'util';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @agentproto/runtime v0.1.0-alpha
|
|
22
|
+
* Long-running gateway: MCP server + HTTP transport + HEARTBEAT autonomy + conversation persistence over a workspace dir.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
async function writeRuntimeMeta(workspace, meta) {
|
|
26
|
+
const dir = join(workspace, ".agentproto");
|
|
27
|
+
try {
|
|
28
|
+
await mkdir(dir, { recursive: true });
|
|
29
|
+
await writeFile(
|
|
30
|
+
join(dir, "runtime.json"),
|
|
31
|
+
JSON.stringify(meta, null, 2) + "\n",
|
|
32
|
+
{ encoding: "utf8", mode: 384 }
|
|
33
|
+
);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error("[runtime] failed to write .agentproto/runtime.json:", err);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function unlinkRuntimeMeta(workspace) {
|
|
39
|
+
try {
|
|
40
|
+
await unlink(join(workspace, ".agentproto", "runtime.json"));
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function readRuntimeMeta(workspace) {
|
|
45
|
+
const path = join(workspace, ".agentproto", "runtime.json");
|
|
46
|
+
try {
|
|
47
|
+
const [raw, st] = await Promise.all([readFile(path, "utf8"), stat(path)]);
|
|
48
|
+
const meta = JSON.parse(raw);
|
|
49
|
+
return { meta, mtime: st.mtime };
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function sweepStaleRuntimeMetas(workspaces, currentWorkspace) {
|
|
55
|
+
const cleaned = [];
|
|
56
|
+
for (const ws of workspaces) {
|
|
57
|
+
if (ws === currentWorkspace) continue;
|
|
58
|
+
const meta = await readRuntimeMeta(ws);
|
|
59
|
+
if (!meta) continue;
|
|
60
|
+
const pid = meta.meta.pid;
|
|
61
|
+
if (typeof pid === "number" && !isPidAlive(pid)) {
|
|
62
|
+
try {
|
|
63
|
+
await unlink(join(ws, ".agentproto", "runtime.json"));
|
|
64
|
+
cleaned.push(join(ws, ".agentproto", "runtime.json"));
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return cleaned;
|
|
70
|
+
}
|
|
71
|
+
function isPidAlive(pid) {
|
|
72
|
+
try {
|
|
73
|
+
process.kill(pid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const code = err.code;
|
|
77
|
+
if (code === "EPERM") return true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
82
|
+
var MAX_TIMEOUT_MS = 6e5;
|
|
83
|
+
var ALLOWLIST_REL = ".agentproto/allowed-commands.json";
|
|
84
|
+
var allowlistCache = null;
|
|
85
|
+
async function loadAllowlist(workspace) {
|
|
86
|
+
const path = resolve(workspace, ALLOWLIST_REL);
|
|
87
|
+
if (!existsSync(path)) {
|
|
88
|
+
allowlistCache = null;
|
|
89
|
+
return /* @__PURE__ */ new Set();
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const s = await stat(path);
|
|
93
|
+
if (allowlistCache && allowlistCache.path === path && allowlistCache.entry.mtimeMs === s.mtimeMs) {
|
|
94
|
+
return allowlistCache.entry.commands;
|
|
95
|
+
}
|
|
96
|
+
const raw = await readFile(path, "utf8");
|
|
97
|
+
const parsed = JSON.parse(raw);
|
|
98
|
+
const list = Array.isArray(parsed.commands) ? parsed.commands : [];
|
|
99
|
+
const commands = new Set(
|
|
100
|
+
list.filter((x) => typeof x === "string" && x.length > 0).map((x) => x.trim())
|
|
101
|
+
);
|
|
102
|
+
allowlistCache = { path, entry: { mtimeMs: s.mtimeMs, commands } };
|
|
103
|
+
return commands;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error(
|
|
106
|
+
`[runtime] failed to load ${ALLOWLIST_REL} (will deny all):`,
|
|
107
|
+
err
|
|
108
|
+
);
|
|
109
|
+
allowlistCache = null;
|
|
110
|
+
return /* @__PURE__ */ new Set();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function makeCwdAnchor(workspace) {
|
|
114
|
+
const root = resolve(workspace);
|
|
115
|
+
return (input) => {
|
|
116
|
+
if (!input || input.length === 0) return root;
|
|
117
|
+
const candidate = isAbsolute(input) ? normalize(input) : normalize(join(root, input));
|
|
118
|
+
const rel = relative(root, candidate);
|
|
119
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`cwd escapes the workspace: '${input}' (workspace=${root})`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return candidate;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function registerCommandTools(server, opts) {
|
|
128
|
+
const anchorCwd = makeCwdAnchor(opts.workspace);
|
|
129
|
+
server.tool(
|
|
130
|
+
"execute_command",
|
|
131
|
+
"Run a shell command on the host running the runtime. The command basename must be in `<workspace>/.agentproto/allowed-commands.json`; default-deny otherwise. Captures stdout / stderr / exit code and returns them as JSON. Use this to drive local CLIs (Claude Code, gh, pnpm, \u2026) from a remote agent.",
|
|
132
|
+
{
|
|
133
|
+
command: z.string().min(1).describe(
|
|
134
|
+
"Executable name or absolute path. Must be allowlisted by basename in the workspace's allowed-commands.json."
|
|
135
|
+
),
|
|
136
|
+
args: z.array(z.string()).optional().describe(
|
|
137
|
+
"Argv array. Passed verbatim \u2014 no shell expansion (we spawn with shell:false so quoting doesn't bite)."
|
|
138
|
+
),
|
|
139
|
+
cwd: z.string().optional().describe(
|
|
140
|
+
"Working directory, workspace-relative or an absolute path inside the workspace. Defaults to the workspace root."
|
|
141
|
+
),
|
|
142
|
+
stdin: z.string().optional().describe("Optional input piped to the process's stdin."),
|
|
143
|
+
timeoutMs: z.number().int().positive().max(MAX_TIMEOUT_MS).optional().describe(
|
|
144
|
+
`Hard kill after this many ms. Defaults to ${DEFAULT_TIMEOUT_MS}; capped at ${MAX_TIMEOUT_MS}.`
|
|
145
|
+
)
|
|
146
|
+
},
|
|
147
|
+
async ({ command, args, cwd, stdin, timeoutMs }) => {
|
|
148
|
+
const allowlist = await loadAllowlist(opts.workspace);
|
|
149
|
+
const baseName = basename(command);
|
|
150
|
+
if (!allowlist.has(baseName)) {
|
|
151
|
+
const allowed = [...allowlist].sort().join(", ") || "(empty)";
|
|
152
|
+
throw new Error(
|
|
153
|
+
`command '${baseName}' is not in the allowlist. Add it to ${join(opts.workspace, ALLOWLIST_REL)} under "commands": [...]. Currently allowed: ${allowed}.`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const resolvedCwd = anchorCwd(cwd);
|
|
157
|
+
const limit = Math.min(timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
158
|
+
const result = await runCommand({
|
|
159
|
+
command,
|
|
160
|
+
args: args ?? [],
|
|
161
|
+
cwd: resolvedCwd,
|
|
162
|
+
stdin,
|
|
163
|
+
timeoutMs: limit
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
var STREAM_BUFFER_CAP = 1048576;
|
|
172
|
+
async function runCommand(input) {
|
|
173
|
+
return new Promise((resolvePromise) => {
|
|
174
|
+
const startedAt = Date.now();
|
|
175
|
+
const child = spawn(input.command, input.args, {
|
|
176
|
+
cwd: input.cwd,
|
|
177
|
+
shell: false,
|
|
178
|
+
// Inherit user env so PATH lookups for `claude`, `gh`, etc. work.
|
|
179
|
+
env: process.env,
|
|
180
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
181
|
+
});
|
|
182
|
+
let stdout = "";
|
|
183
|
+
let stderr = "";
|
|
184
|
+
let truncated = false;
|
|
185
|
+
let timedOut = false;
|
|
186
|
+
function appendCapped(buf, chunk) {
|
|
187
|
+
if (buf.length >= STREAM_BUFFER_CAP) {
|
|
188
|
+
truncated = true;
|
|
189
|
+
return buf;
|
|
190
|
+
}
|
|
191
|
+
const room = STREAM_BUFFER_CAP - buf.length;
|
|
192
|
+
const text3 = chunk.toString("utf8");
|
|
193
|
+
if (text3.length > room) {
|
|
194
|
+
truncated = true;
|
|
195
|
+
return buf + text3.slice(0, room);
|
|
196
|
+
}
|
|
197
|
+
return buf + text3;
|
|
198
|
+
}
|
|
199
|
+
child.stdout?.on("data", (d) => {
|
|
200
|
+
stdout = appendCapped(stdout, d);
|
|
201
|
+
});
|
|
202
|
+
child.stderr?.on("data", (d) => {
|
|
203
|
+
stderr = appendCapped(stderr, d);
|
|
204
|
+
});
|
|
205
|
+
if (input.stdin) {
|
|
206
|
+
child.stdin?.write(input.stdin);
|
|
207
|
+
}
|
|
208
|
+
child.stdin?.end();
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
timedOut = true;
|
|
211
|
+
try {
|
|
212
|
+
child.kill("SIGTERM");
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
setTimeout(() => {
|
|
216
|
+
try {
|
|
217
|
+
child.kill("SIGKILL");
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
}, 2e3).unref();
|
|
221
|
+
}, input.timeoutMs);
|
|
222
|
+
timer.unref();
|
|
223
|
+
child.on("error", (err) => {
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
resolvePromise({
|
|
226
|
+
exitCode: -1,
|
|
227
|
+
signal: null,
|
|
228
|
+
stdout,
|
|
229
|
+
stderr: stderr + (stderr ? "\n" : "") + err.message,
|
|
230
|
+
truncated,
|
|
231
|
+
durationMs: Date.now() - startedAt
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
child.on("close", (code, signal) => {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
resolvePromise({
|
|
237
|
+
exitCode: typeof code === "number" ? code : -1,
|
|
238
|
+
signal: signal ?? (timedOut ? "SIGTERM-timeout" : null),
|
|
239
|
+
stdout,
|
|
240
|
+
stderr,
|
|
241
|
+
truncated,
|
|
242
|
+
durationMs: Date.now() - startedAt
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
var FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
|
|
248
|
+
function fileConversationStore(opts) {
|
|
249
|
+
const dir = opts.dir ?? join(opts.workspace, "conversations");
|
|
250
|
+
const filePath = (id) => join(dir, `${id}.md`);
|
|
251
|
+
return {
|
|
252
|
+
pathFor: filePath,
|
|
253
|
+
async open(id, meta) {
|
|
254
|
+
await mkdir(dir, { recursive: true });
|
|
255
|
+
const path = filePath(id);
|
|
256
|
+
if (existsSync(path)) return;
|
|
257
|
+
const header = renderHeader({
|
|
258
|
+
id,
|
|
259
|
+
agent: meta.agent,
|
|
260
|
+
started: (/* @__PURE__ */ new Date()).toISOString(),
|
|
261
|
+
status: "open"
|
|
262
|
+
});
|
|
263
|
+
await writeFile(path, header, "utf8");
|
|
264
|
+
},
|
|
265
|
+
async appendTurn(id, role, content, options) {
|
|
266
|
+
const path = filePath(id);
|
|
267
|
+
if (!existsSync(path)) {
|
|
268
|
+
await this.open(id, { agent: options?.attribution ?? "unknown" });
|
|
269
|
+
}
|
|
270
|
+
const block = renderTurn({
|
|
271
|
+
role,
|
|
272
|
+
at: options?.at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
273
|
+
attribution: options?.attribution,
|
|
274
|
+
content
|
|
275
|
+
});
|
|
276
|
+
await writeFile(path, block, { encoding: "utf8", flag: "a" });
|
|
277
|
+
},
|
|
278
|
+
async read(id) {
|
|
279
|
+
const path = filePath(id);
|
|
280
|
+
const source = await readFile(path, "utf8");
|
|
281
|
+
return parseConversation(source, id);
|
|
282
|
+
},
|
|
283
|
+
async list() {
|
|
284
|
+
if (!existsSync(dir)) return [];
|
|
285
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
286
|
+
const out = [];
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
289
|
+
const id = entry.name.slice(0, -3);
|
|
290
|
+
try {
|
|
291
|
+
const source = await readFile(join(dir, entry.name), "utf8");
|
|
292
|
+
const { meta } = parseConversation(source, id);
|
|
293
|
+
out.push({ id, meta });
|
|
294
|
+
} catch {
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function renderHeader(meta) {
|
|
302
|
+
return [
|
|
303
|
+
"---",
|
|
304
|
+
`schema: conversation/v1`,
|
|
305
|
+
`id: ${meta.id}`,
|
|
306
|
+
`agent: ${meta.agent}`,
|
|
307
|
+
`started: ${meta.started}`,
|
|
308
|
+
`status: ${meta.status}`,
|
|
309
|
+
"---",
|
|
310
|
+
"",
|
|
311
|
+
""
|
|
312
|
+
].join("\n");
|
|
313
|
+
}
|
|
314
|
+
function renderTurn(turn) {
|
|
315
|
+
const heading = turn.attribution ? `## ${turn.role} \u2014 ${turn.at} (${turn.attribution})` : `## ${turn.role} \u2014 ${turn.at}`;
|
|
316
|
+
return `${heading}
|
|
317
|
+
|
|
318
|
+
${turn.content.trimEnd()}
|
|
319
|
+
|
|
320
|
+
`;
|
|
321
|
+
}
|
|
322
|
+
function parseConversation(source, fallbackId) {
|
|
323
|
+
const match = source.match(FRONTMATTER_RE);
|
|
324
|
+
let meta = {
|
|
325
|
+
id: fallbackId,
|
|
326
|
+
agent: "unknown",
|
|
327
|
+
started: "",
|
|
328
|
+
status: "open"
|
|
329
|
+
};
|
|
330
|
+
let body = source;
|
|
331
|
+
if (match) {
|
|
332
|
+
meta = parseFrontmatter(match[1] ?? "", fallbackId);
|
|
333
|
+
body = source.slice(match[0].length);
|
|
334
|
+
}
|
|
335
|
+
const turns = [];
|
|
336
|
+
const lines = body.split("\n");
|
|
337
|
+
let current = null;
|
|
338
|
+
let buffer = [];
|
|
339
|
+
const flush = () => {
|
|
340
|
+
if (!current) return;
|
|
341
|
+
current.content = buffer.join("\n").trim();
|
|
342
|
+
turns.push(current);
|
|
343
|
+
current = null;
|
|
344
|
+
buffer = [];
|
|
345
|
+
};
|
|
346
|
+
const headingRe = /^##\s+(user|assistant|system)\s+—\s+(\S+)(?:\s+\(([^)]+)\))?\s*$/;
|
|
347
|
+
for (const line of lines) {
|
|
348
|
+
const m = line.match(headingRe);
|
|
349
|
+
if (m) {
|
|
350
|
+
flush();
|
|
351
|
+
current = {
|
|
352
|
+
role: m[1],
|
|
353
|
+
at: m[2] ?? "",
|
|
354
|
+
attribution: m[3],
|
|
355
|
+
content: ""
|
|
356
|
+
};
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (current) buffer.push(line);
|
|
360
|
+
}
|
|
361
|
+
flush();
|
|
362
|
+
return { meta, turns };
|
|
363
|
+
}
|
|
364
|
+
function parseFrontmatter(raw, fallbackId) {
|
|
365
|
+
const out = {};
|
|
366
|
+
for (const line of raw.split("\n")) {
|
|
367
|
+
const colon = line.indexOf(":");
|
|
368
|
+
if (colon < 1) continue;
|
|
369
|
+
const key = line.slice(0, colon).trim();
|
|
370
|
+
const value = line.slice(colon + 1).trim();
|
|
371
|
+
out[key] = value;
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
id: out.id ?? fallbackId,
|
|
375
|
+
agent: out.agent ?? "unknown",
|
|
376
|
+
started: out.started ?? "",
|
|
377
|
+
status: out.status === "closed" ? "closed" : "open"
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function createRuntimeEvents() {
|
|
381
|
+
const ee = new EventEmitter();
|
|
382
|
+
ee.setMaxListeners(50);
|
|
383
|
+
return {
|
|
384
|
+
on(type, handler) {
|
|
385
|
+
const wrapped = (ev) => {
|
|
386
|
+
if (ev.type === type) handler(ev);
|
|
387
|
+
};
|
|
388
|
+
ee.on("event", wrapped);
|
|
389
|
+
return () => ee.off("event", wrapped);
|
|
390
|
+
},
|
|
391
|
+
onAny(handler) {
|
|
392
|
+
ee.on("event", handler);
|
|
393
|
+
return () => ee.off("event", handler);
|
|
394
|
+
},
|
|
395
|
+
emit(ev) {
|
|
396
|
+
ee.emit("event", ev);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
var FsPathError = class extends Error {
|
|
401
|
+
constructor(message) {
|
|
402
|
+
super(message);
|
|
403
|
+
this.name = "FsPathError";
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
function makeAnchor(workspace) {
|
|
407
|
+
const root = resolve(workspace);
|
|
408
|
+
return (input) => {
|
|
409
|
+
if (typeof input !== "string" || input.length === 0) {
|
|
410
|
+
throw new FsPathError("path must be a non-empty string");
|
|
411
|
+
}
|
|
412
|
+
const candidate = isAbsolute(input) ? normalize(input) : normalize(join(root, input));
|
|
413
|
+
const rel = relative(root, candidate);
|
|
414
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
415
|
+
throw new FsPathError(`path escapes the workspace: '${input}'`);
|
|
416
|
+
}
|
|
417
|
+
return candidate;
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function text(value) {
|
|
421
|
+
return {
|
|
422
|
+
content: [
|
|
423
|
+
{
|
|
424
|
+
type: "text",
|
|
425
|
+
text: typeof value === "string" ? value : JSON.stringify(value)
|
|
426
|
+
}
|
|
427
|
+
]
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
function registerFsTools(server, opts) {
|
|
431
|
+
const anchor = makeAnchor(opts.workspace);
|
|
432
|
+
server.tool(
|
|
433
|
+
"read_file",
|
|
434
|
+
"Read a UTF-8 file from the workspace.",
|
|
435
|
+
{ path: z.string().describe("Workspace-relative path to the file.") },
|
|
436
|
+
async ({ path }) => {
|
|
437
|
+
const abs = anchor(path);
|
|
438
|
+
const buf = await readFile(abs);
|
|
439
|
+
return text(buf.toString("utf8"));
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
server.tool(
|
|
443
|
+
"write_file",
|
|
444
|
+
"Write a file to the workspace. Parent directories are created on demand.",
|
|
445
|
+
{
|
|
446
|
+
path: z.string().describe("Workspace-relative path to the file."),
|
|
447
|
+
content: z.string().describe("File body (UTF-8).")
|
|
448
|
+
},
|
|
449
|
+
async ({ path, content }) => {
|
|
450
|
+
const abs = anchor(path);
|
|
451
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
452
|
+
await writeFile(abs, content, "utf8");
|
|
453
|
+
return text("ok");
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
server.tool(
|
|
457
|
+
"list_directory",
|
|
458
|
+
"List entries of a directory in the workspace. Returns one '[FILE]' or '[DIR]' line per entry, mirroring `@modelcontextprotocol/server-filesystem`.",
|
|
459
|
+
{
|
|
460
|
+
path: z.string().optional().describe("Workspace-relative directory (defaults to workspace root).")
|
|
461
|
+
},
|
|
462
|
+
async ({ path }) => {
|
|
463
|
+
const abs = anchor(path && path.length > 0 ? path : ".");
|
|
464
|
+
const entries = await readdir(abs, { withFileTypes: true });
|
|
465
|
+
const lines = entries.map(
|
|
466
|
+
(e) => e.isDirectory() ? `[DIR] ${e.name}` : `[FILE] ${e.name}`
|
|
467
|
+
);
|
|
468
|
+
return text(lines.join("\n"));
|
|
469
|
+
}
|
|
470
|
+
);
|
|
471
|
+
server.tool(
|
|
472
|
+
"get_file_info",
|
|
473
|
+
"Stat a file or directory in the workspace.",
|
|
474
|
+
{ path: z.string().describe("Workspace-relative path.") },
|
|
475
|
+
async ({ path }) => {
|
|
476
|
+
const abs = anchor(path);
|
|
477
|
+
const info = await stat(abs);
|
|
478
|
+
return text({
|
|
479
|
+
name: abs.split("/").pop() ?? path,
|
|
480
|
+
path,
|
|
481
|
+
type: info.isDirectory() ? "directory" : "file",
|
|
482
|
+
size: info.size,
|
|
483
|
+
modified: info.mtime.toISOString(),
|
|
484
|
+
created: info.birthtime.toISOString()
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
server.tool(
|
|
489
|
+
"create_directory",
|
|
490
|
+
"Create a directory (recursive) in the workspace.",
|
|
491
|
+
{ path: z.string().describe("Workspace-relative directory path.") },
|
|
492
|
+
async ({ path }) => {
|
|
493
|
+
const abs = anchor(path);
|
|
494
|
+
await mkdir(abs, { recursive: true });
|
|
495
|
+
return text("ok");
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
server.tool(
|
|
499
|
+
"delete_file",
|
|
500
|
+
"Delete a file or empty directory in the workspace.",
|
|
501
|
+
{ path: z.string().describe("Workspace-relative path.") },
|
|
502
|
+
async ({ path }) => {
|
|
503
|
+
const abs = anchor(path);
|
|
504
|
+
await rm(abs, { recursive: true, force: true });
|
|
505
|
+
return text("ok");
|
|
506
|
+
}
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
var WORKSPACES_CONFIG_VERSION = 1;
|
|
510
|
+
var DEFAULT_CONFIG_DIR = () => resolve(homedir(), ".agentproto");
|
|
511
|
+
var DEFAULT_CONFIG_PATH = () => resolve(DEFAULT_CONFIG_DIR(), "workspaces.json");
|
|
512
|
+
function sanitizeSlug(input) {
|
|
513
|
+
const trimmed = input.trim().toLowerCase();
|
|
514
|
+
const cleaned = trimmed.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
515
|
+
return cleaned || "workspace";
|
|
516
|
+
}
|
|
517
|
+
async function loadWorkspacesConfig(path = DEFAULT_CONFIG_PATH()) {
|
|
518
|
+
let raw;
|
|
519
|
+
try {
|
|
520
|
+
raw = await promises.readFile(path, "utf8");
|
|
521
|
+
} catch (err) {
|
|
522
|
+
if (err.code === "ENOENT") {
|
|
523
|
+
return { version: WORKSPACES_CONFIG_VERSION, workspaces: [] };
|
|
524
|
+
}
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
let parsed;
|
|
528
|
+
try {
|
|
529
|
+
parsed = JSON.parse(raw);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`agentproto: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete the file or fix it manually.`
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
return normalizeConfig(parsed);
|
|
536
|
+
}
|
|
537
|
+
function findWorkspace(config, slug) {
|
|
538
|
+
return config.workspaces.find((w) => w.slug === sanitizeSlug(slug));
|
|
539
|
+
}
|
|
540
|
+
function getActiveWorkspace(config) {
|
|
541
|
+
if (!config.active) return config.workspaces[0];
|
|
542
|
+
return findWorkspace(config, config.active) ?? config.workspaces[0];
|
|
543
|
+
}
|
|
544
|
+
function normalizeConfig(parsed) {
|
|
545
|
+
if (!parsed || typeof parsed !== "object") {
|
|
546
|
+
return { version: WORKSPACES_CONFIG_VERSION, workspaces: [] };
|
|
547
|
+
}
|
|
548
|
+
const obj = parsed;
|
|
549
|
+
const workspaces = [];
|
|
550
|
+
if (Array.isArray(obj.workspaces)) {
|
|
551
|
+
for (const entry of obj.workspaces) {
|
|
552
|
+
if (!entry || typeof entry !== "object") continue;
|
|
553
|
+
const e = entry;
|
|
554
|
+
const slug = typeof e.slug === "string" ? sanitizeSlug(e.slug) : "";
|
|
555
|
+
const path = typeof e.path === "string" ? e.path : "";
|
|
556
|
+
if (!slug || !path) continue;
|
|
557
|
+
const addedAt = typeof e.addedAt === "string" ? e.addedAt : (/* @__PURE__ */ new Date()).toISOString();
|
|
558
|
+
const updatedAt = typeof e.updatedAt === "string" ? e.updatedAt : addedAt;
|
|
559
|
+
const we = { slug, path, addedAt, updatedAt };
|
|
560
|
+
if (typeof e.label === "string" && e.label.trim()) {
|
|
561
|
+
we.label = e.label.trim();
|
|
562
|
+
}
|
|
563
|
+
workspaces.push(we);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const dedup = /* @__PURE__ */ new Map();
|
|
567
|
+
for (const w of workspaces) dedup.set(w.slug, w);
|
|
568
|
+
const finalList = Array.from(dedup.values());
|
|
569
|
+
const active = typeof obj.active === "string" && finalList.some((w) => w.slug === obj.active) ? obj.active : finalList[0]?.slug;
|
|
570
|
+
const out = {
|
|
571
|
+
version: WORKSPACES_CONFIG_VERSION,
|
|
572
|
+
workspaces: finalList
|
|
573
|
+
};
|
|
574
|
+
if (active !== void 0) out.active = active;
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
async function discoverMcps(opts = {}) {
|
|
578
|
+
const home = opts.home ?? homedir();
|
|
579
|
+
const out = [];
|
|
580
|
+
const errors = [];
|
|
581
|
+
await Promise.all([
|
|
582
|
+
scanClaudeCode(home, out, errors),
|
|
583
|
+
scanCursor(home, out, errors),
|
|
584
|
+
scanGoose(home, out),
|
|
585
|
+
scanRegisteredWorkspaces(out, errors)
|
|
586
|
+
]);
|
|
587
|
+
const seen = /* @__PURE__ */ new Set();
|
|
588
|
+
const dedup = [];
|
|
589
|
+
for (const m of out) {
|
|
590
|
+
const key = `${m.source}:${m.scope}:${m.name}`;
|
|
591
|
+
if (seen.has(key)) continue;
|
|
592
|
+
seen.add(key);
|
|
593
|
+
dedup.push(m);
|
|
594
|
+
}
|
|
595
|
+
if (errors.length > 0) {
|
|
596
|
+
for (const e of errors) console.warn(`[mcp-discovery] ${e}`);
|
|
597
|
+
}
|
|
598
|
+
return dedup.sort((a, b) => {
|
|
599
|
+
if (a.source !== b.source) return a.source.localeCompare(b.source);
|
|
600
|
+
if (a.scope !== b.scope) return a.scope.localeCompare(b.scope);
|
|
601
|
+
return a.name.localeCompare(b.name);
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
async function scanClaudeCode(home, out, errors) {
|
|
605
|
+
const path = resolve(home, ".claude.json");
|
|
606
|
+
let raw;
|
|
607
|
+
try {
|
|
608
|
+
raw = await promises.readFile(path, "utf8");
|
|
609
|
+
} catch (err) {
|
|
610
|
+
if (err.code === "ENOENT") return;
|
|
611
|
+
errors.push(`claude-code: read ${path} \u2014 ${err.message}`);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
let parsed;
|
|
615
|
+
try {
|
|
616
|
+
parsed = JSON.parse(raw);
|
|
617
|
+
} catch (err) {
|
|
618
|
+
errors.push(`claude-code: JSON parse ${path} \u2014 ${err.message}`);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
622
|
+
const root = parsed;
|
|
623
|
+
const top = root.mcpServers;
|
|
624
|
+
if (top && typeof top === "object") {
|
|
625
|
+
pushMcpMap({
|
|
626
|
+
out,
|
|
627
|
+
source: "claude-code",
|
|
628
|
+
scope: "global",
|
|
629
|
+
map: top
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
const projects = root.projects;
|
|
633
|
+
if (projects && typeof projects === "object") {
|
|
634
|
+
for (const [projPath, proj] of Object.entries(
|
|
635
|
+
projects
|
|
636
|
+
)) {
|
|
637
|
+
if (!proj || typeof proj !== "object") continue;
|
|
638
|
+
const projMcps = proj.mcpServers;
|
|
639
|
+
if (!projMcps || typeof projMcps !== "object") continue;
|
|
640
|
+
pushMcpMap({
|
|
641
|
+
out,
|
|
642
|
+
source: "claude-code",
|
|
643
|
+
scope: `project:${projPath}`,
|
|
644
|
+
map: projMcps
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async function scanCursor(home, out, errors) {
|
|
650
|
+
const path = resolve(home, ".cursor", "mcp.json");
|
|
651
|
+
let raw;
|
|
652
|
+
try {
|
|
653
|
+
raw = await promises.readFile(path, "utf8");
|
|
654
|
+
} catch (err) {
|
|
655
|
+
if (err.code === "ENOENT") return;
|
|
656
|
+
errors.push(`cursor: read ${path} \u2014 ${err.message}`);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
let parsed;
|
|
660
|
+
try {
|
|
661
|
+
parsed = JSON.parse(raw);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
errors.push(`cursor: JSON parse ${path} \u2014 ${err.message}`);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
667
|
+
const root = parsed;
|
|
668
|
+
const map = root.mcpServers;
|
|
669
|
+
if (map && typeof map === "object") {
|
|
670
|
+
pushMcpMap({
|
|
671
|
+
out,
|
|
672
|
+
source: "cursor",
|
|
673
|
+
scope: "global",
|
|
674
|
+
map
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async function scanGoose(home, out, errors) {
|
|
679
|
+
const path = resolve(home, ".config", "goose", "config.yaml");
|
|
680
|
+
try {
|
|
681
|
+
await promises.access(path);
|
|
682
|
+
} catch {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
out.push({
|
|
686
|
+
id: "goose:global:_detected",
|
|
687
|
+
source: "goose",
|
|
688
|
+
scope: "global",
|
|
689
|
+
name: "(goose config detected)",
|
|
690
|
+
type: "unknown",
|
|
691
|
+
parseNote: "Goose config found but YAML parsing is not yet implemented. Open ~/.config/goose/config.yaml to inspect MCP entries."
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
async function scanRegisteredWorkspaces(out, errors) {
|
|
695
|
+
let workspaces = [];
|
|
696
|
+
try {
|
|
697
|
+
const cfg = await loadWorkspacesConfig();
|
|
698
|
+
workspaces = cfg.workspaces;
|
|
699
|
+
} catch (err) {
|
|
700
|
+
errors.push(`workspaces: load failed \u2014 ${err.message}`);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
await Promise.all(
|
|
704
|
+
workspaces.map(async (ws) => {
|
|
705
|
+
const candidates = [
|
|
706
|
+
resolve(ws.path, ".mcp.json"),
|
|
707
|
+
resolve(ws.path, ".cursor", "mcp.json"),
|
|
708
|
+
resolve(ws.path, ".vscode", "mcp.json")
|
|
709
|
+
];
|
|
710
|
+
for (const path of candidates) {
|
|
711
|
+
let raw;
|
|
712
|
+
try {
|
|
713
|
+
raw = await promises.readFile(path, "utf8");
|
|
714
|
+
} catch (err) {
|
|
715
|
+
if (err.code === "ENOENT") continue;
|
|
716
|
+
errors.push(
|
|
717
|
+
`workspace ${ws.slug}: read ${path} \u2014 ${err.message}`
|
|
718
|
+
);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
let parsed;
|
|
722
|
+
try {
|
|
723
|
+
parsed = JSON.parse(raw);
|
|
724
|
+
} catch (err) {
|
|
725
|
+
errors.push(
|
|
726
|
+
`workspace ${ws.slug}: JSON parse ${path} \u2014 ${err.message}`
|
|
727
|
+
);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (!parsed || typeof parsed !== "object") continue;
|
|
731
|
+
const map = parsed.mcpServers;
|
|
732
|
+
if (!map || typeof map !== "object") continue;
|
|
733
|
+
pushMcpMap({
|
|
734
|
+
out,
|
|
735
|
+
source: "workspace",
|
|
736
|
+
scope: `workspace:${ws.slug}`,
|
|
737
|
+
map
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
})
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
function pushMcpMap(args) {
|
|
744
|
+
for (const [name, raw] of Object.entries(args.map)) {
|
|
745
|
+
if (!raw || typeof raw !== "object") continue;
|
|
746
|
+
const entry = raw;
|
|
747
|
+
const id = `${args.source}:${args.scope}:${name}`;
|
|
748
|
+
const declared = typeof entry.type === "string" ? entry.type : null;
|
|
749
|
+
const url = typeof entry.url === "string" ? entry.url : void 0;
|
|
750
|
+
const command = typeof entry.command === "string" ? entry.command : void 0;
|
|
751
|
+
let type;
|
|
752
|
+
if (declared === "http" || declared === "sse" || declared === "stdio") {
|
|
753
|
+
type = declared;
|
|
754
|
+
} else if (url) {
|
|
755
|
+
type = "http";
|
|
756
|
+
} else if (command) {
|
|
757
|
+
type = "stdio";
|
|
758
|
+
} else {
|
|
759
|
+
type = "unknown";
|
|
760
|
+
}
|
|
761
|
+
const m = {
|
|
762
|
+
id,
|
|
763
|
+
source: args.source,
|
|
764
|
+
scope: args.scope,
|
|
765
|
+
name,
|
|
766
|
+
type
|
|
767
|
+
};
|
|
768
|
+
if (command !== void 0) m.command = command;
|
|
769
|
+
if (Array.isArray(entry.args)) {
|
|
770
|
+
m.args = entry.args.filter((a) => typeof a === "string");
|
|
771
|
+
}
|
|
772
|
+
if (entry.env && typeof entry.env === "object") {
|
|
773
|
+
m.env = stringifyValues(entry.env);
|
|
774
|
+
}
|
|
775
|
+
if (url !== void 0) m.url = url;
|
|
776
|
+
if (entry.headers && typeof entry.headers === "object") {
|
|
777
|
+
m.headers = stringifyValues(entry.headers);
|
|
778
|
+
}
|
|
779
|
+
if (type === "unknown") {
|
|
780
|
+
m.parseNote = "Entry has neither `command` nor `url` \u2014 couldn't classify transport.";
|
|
781
|
+
}
|
|
782
|
+
args.out.push(m);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function stringifyValues(raw) {
|
|
786
|
+
const out = {};
|
|
787
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
788
|
+
if (typeof v === "string") out[k] = v;
|
|
789
|
+
else if (v != null) out[k] = String(v);
|
|
790
|
+
}
|
|
791
|
+
return out;
|
|
792
|
+
}
|
|
793
|
+
var IMPORTED_MCPS_PATH = () => resolve(homedir(), ".agentproto", "imported-mcps.json");
|
|
794
|
+
var IMPORTED_MCPS_VERSION = 1;
|
|
795
|
+
var EMPTY = {
|
|
796
|
+
version: IMPORTED_MCPS_VERSION,
|
|
797
|
+
imports: []
|
|
798
|
+
};
|
|
799
|
+
async function loadImportedMcps(path = IMPORTED_MCPS_PATH()) {
|
|
800
|
+
let raw;
|
|
801
|
+
try {
|
|
802
|
+
raw = await promises.readFile(path, "utf8");
|
|
803
|
+
} catch (err) {
|
|
804
|
+
if (err.code === "ENOENT") return EMPTY;
|
|
805
|
+
throw err;
|
|
806
|
+
}
|
|
807
|
+
let parsed;
|
|
808
|
+
try {
|
|
809
|
+
parsed = JSON.parse(raw);
|
|
810
|
+
} catch (err) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
`agentproto: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete or fix manually.`
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
return normalize3(parsed);
|
|
816
|
+
}
|
|
817
|
+
async function saveImportedMcps(config, path = IMPORTED_MCPS_PATH()) {
|
|
818
|
+
await promises.mkdir(dirname(path), { recursive: true });
|
|
819
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
820
|
+
await promises.writeFile(tmp, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
821
|
+
await promises.rename(tmp, path);
|
|
822
|
+
}
|
|
823
|
+
function addImport(config, input) {
|
|
824
|
+
const alias = (input.alias ?? input.snapshot.name).trim();
|
|
825
|
+
if (!alias) {
|
|
826
|
+
throw new Error("addImport: alias resolves to empty string");
|
|
827
|
+
}
|
|
828
|
+
const addedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
829
|
+
const next = {
|
|
830
|
+
id: input.snapshot.id,
|
|
831
|
+
alias,
|
|
832
|
+
addedAt,
|
|
833
|
+
snapshot: input.snapshot
|
|
834
|
+
};
|
|
835
|
+
const others = config.imports.filter((e) => e.id !== input.snapshot.id);
|
|
836
|
+
return { ...config, imports: [...others, next] };
|
|
837
|
+
}
|
|
838
|
+
function removeImport(config, id) {
|
|
839
|
+
return { ...config, imports: config.imports.filter((e) => e.id !== id) };
|
|
840
|
+
}
|
|
841
|
+
function normalize3(parsed) {
|
|
842
|
+
if (!parsed || typeof parsed !== "object") return EMPTY;
|
|
843
|
+
const obj = parsed;
|
|
844
|
+
const imports = [];
|
|
845
|
+
if (Array.isArray(obj.imports)) {
|
|
846
|
+
for (const entry of obj.imports) {
|
|
847
|
+
if (!entry || typeof entry !== "object") continue;
|
|
848
|
+
const e = entry;
|
|
849
|
+
const id = typeof e.id === "string" ? e.id : "";
|
|
850
|
+
const alias = typeof e.alias === "string" ? e.alias : id;
|
|
851
|
+
const addedAt = typeof e.addedAt === "string" ? e.addedAt : (/* @__PURE__ */ new Date()).toISOString();
|
|
852
|
+
const snapshot = e.snapshot;
|
|
853
|
+
if (!id || !snapshot) continue;
|
|
854
|
+
imports.push({ id, alias, addedAt, snapshot });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return { version: IMPORTED_MCPS_VERSION, imports };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/session-tools.ts
|
|
861
|
+
function registerSessionTools(server, opts) {
|
|
862
|
+
const { registry, resolveAgentAdapter, listAgentAdapters, mcpProxy } = opts;
|
|
863
|
+
const ptyEnabled = opts.ptyEnabled === true;
|
|
864
|
+
server.tool(
|
|
865
|
+
"start_agent_session",
|
|
866
|
+
"Spawn a long-running agent CLI (claude-code, hermes, \u2026) on the host. The session stays alive across multiple turns \u2014 call `prompt_agent_session` to continue the conversation. Returns the session id + initial descriptor. When `workspaceSlug` is set, resolves the cwd via `~/.agentproto/workspaces.json`; otherwise pass `cwd` explicitly or fall back to the active workspace.",
|
|
867
|
+
{
|
|
868
|
+
adapter: z.string().min(1).describe(
|
|
869
|
+
"Adapter slug \u2014 one of the installed `@agentproto/adapter-*` packages (e.g. 'claude-code', 'hermes', 'aider')."
|
|
870
|
+
),
|
|
871
|
+
workspaceSlug: z.string().optional().describe(
|
|
872
|
+
"Workspace slug from `agentproto workspace list`. The daemon resolves it to an absolute path. Omit to use the `cwd` field or the active workspace."
|
|
873
|
+
),
|
|
874
|
+
cwd: z.string().optional().describe(
|
|
875
|
+
"Absolute path to spawn the agent in. Wins over `workspaceSlug` when both are set."
|
|
876
|
+
),
|
|
877
|
+
prompt: z.string().optional().describe(
|
|
878
|
+
"Optional initial prompt. The session is spawned and the prompt dispatched in one shot \u2014 equivalent to `start` then `prompt` back-to-back. Skip to spawn idle."
|
|
879
|
+
),
|
|
880
|
+
label: z.string().optional().describe(
|
|
881
|
+
"Free-text label that surfaces in `list_agent_sessions` and the UI \u2014 useful for tagging sessions with a conversation id or operator name."
|
|
882
|
+
)
|
|
883
|
+
},
|
|
884
|
+
async (input) => {
|
|
885
|
+
if (!resolveAgentAdapter) {
|
|
886
|
+
return {
|
|
887
|
+
content: [
|
|
888
|
+
{
|
|
889
|
+
type: "text",
|
|
890
|
+
text: "start_agent_session is not enabled \u2014 the daemon was started without an adapter resolver. Re-run the daemon with the `@agentproto/cli` shim wired (see playground/scripts/gateway.ts)."
|
|
891
|
+
}
|
|
892
|
+
],
|
|
893
|
+
isError: true
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
let cwd = input.cwd;
|
|
897
|
+
let resolvedSlug = input.workspaceSlug ?? "default";
|
|
898
|
+
if (!cwd) {
|
|
899
|
+
try {
|
|
900
|
+
const config = await loadWorkspacesConfig();
|
|
901
|
+
const ws = input.workspaceSlug ? findWorkspace(config, input.workspaceSlug) : getActiveWorkspace(config);
|
|
902
|
+
if (ws) {
|
|
903
|
+
cwd = ws.path;
|
|
904
|
+
resolvedSlug = ws.slug;
|
|
905
|
+
}
|
|
906
|
+
} catch {
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (!cwd) {
|
|
910
|
+
return {
|
|
911
|
+
content: [
|
|
912
|
+
{
|
|
913
|
+
type: "text",
|
|
914
|
+
text: "start_agent_session: no cwd resolvable. Pass `cwd` explicitly, or pass `workspaceSlug` matching `agentproto workspace list`, or set an active workspace via `agentproto workspace use <slug>`."
|
|
915
|
+
}
|
|
916
|
+
],
|
|
917
|
+
isError: true
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
const resolved = await resolveAgentAdapter(input.adapter);
|
|
921
|
+
if (!resolved) {
|
|
922
|
+
return {
|
|
923
|
+
content: [
|
|
924
|
+
{
|
|
925
|
+
type: "text",
|
|
926
|
+
text: `start_agent_session: adapter "${input.adapter}" not found. Try \`agentproto install <slug>\` first.`
|
|
927
|
+
}
|
|
928
|
+
],
|
|
929
|
+
isError: true
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
const agentSession = await resolved.startSession({ cwd });
|
|
934
|
+
const desc = registry.spawnAgent({
|
|
935
|
+
workspaceSlug: resolvedSlug,
|
|
936
|
+
cwd,
|
|
937
|
+
agentSession,
|
|
938
|
+
adapterSlug: input.adapter,
|
|
939
|
+
...input.prompt ? { initialPrompt: input.prompt } : {},
|
|
940
|
+
...input.label ? { label: input.label } : {},
|
|
941
|
+
...resolved.commandPreview ? { commandPreview: resolved.commandPreview } : {}
|
|
942
|
+
});
|
|
943
|
+
return {
|
|
944
|
+
content: [{ type: "text", text: JSON.stringify(desc, null, 2) }]
|
|
945
|
+
};
|
|
946
|
+
} catch (err) {
|
|
947
|
+
return {
|
|
948
|
+
content: [
|
|
949
|
+
{
|
|
950
|
+
type: "text",
|
|
951
|
+
text: `start_agent_session: spawn failed \u2014 ${err instanceof Error ? err.message : String(err)}`
|
|
952
|
+
}
|
|
953
|
+
],
|
|
954
|
+
isError: true
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
);
|
|
959
|
+
server.tool(
|
|
960
|
+
"prompt_agent_session",
|
|
961
|
+
"Send a follow-up prompt to a live agent session \u2014 multi-turn continuity without re-spawning. The session id comes from `start_agent_session` (or `list_agent_sessions`). Returns immediately; tail output via `get_agent_session_output` or the SSE /sessions/:id/stream endpoint.",
|
|
962
|
+
{
|
|
963
|
+
sessionId: z.string().describe("Session id returned by start_agent_session."),
|
|
964
|
+
prompt: z.string().min(1).describe("The next user turn (plain text).")
|
|
965
|
+
},
|
|
966
|
+
async (input) => {
|
|
967
|
+
try {
|
|
968
|
+
void registry.sendPrompt(input.sessionId, input.prompt).catch(() => {
|
|
969
|
+
});
|
|
970
|
+
return {
|
|
971
|
+
content: [
|
|
972
|
+
{
|
|
973
|
+
type: "text",
|
|
974
|
+
text: JSON.stringify(
|
|
975
|
+
{ ok: true, sessionId: input.sessionId, queued: true },
|
|
976
|
+
null,
|
|
977
|
+
2
|
|
978
|
+
)
|
|
979
|
+
}
|
|
980
|
+
]
|
|
981
|
+
};
|
|
982
|
+
} catch (err) {
|
|
983
|
+
return {
|
|
984
|
+
content: [
|
|
985
|
+
{
|
|
986
|
+
type: "text",
|
|
987
|
+
text: `prompt_agent_session: ${err instanceof Error ? err.message : String(err)}`
|
|
988
|
+
}
|
|
989
|
+
],
|
|
990
|
+
isError: true
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
);
|
|
995
|
+
server.tool(
|
|
996
|
+
"list_sessions",
|
|
997
|
+
"List sessions tracked by the daemon \u2014 agent-CLI sessions (claude-code, hermes, \u2026), terminal/PTY sessions (claude TUI, bash, \u2026), and raw commands. Each entry includes `kind`, `pty` (true for real PTYs), `name` (when set at spawn), `status`, `command`, age + exit code. Use this when you need to know what's already running before spawning anything new, or to discover a session id by name.",
|
|
998
|
+
{
|
|
999
|
+
kind: z.enum(["terminal", "agent-cli", "command", "all"]).optional().describe(
|
|
1000
|
+
"Filter by session kind. `all` (default) returns every kind. Use `terminal` to list only PTY sessions, `agent-cli` for structured ACP agents."
|
|
1001
|
+
),
|
|
1002
|
+
onlyAlive: z.boolean().optional().describe("When true, only running/starting sessions. Default false."),
|
|
1003
|
+
status: z.enum(["starting", "running", "exited", "killed", "error"]).optional().describe("Filter by exact status (overrides onlyAlive).")
|
|
1004
|
+
},
|
|
1005
|
+
async (input) => {
|
|
1006
|
+
let rows = registry.list();
|
|
1007
|
+
if (input.kind && input.kind !== "all") {
|
|
1008
|
+
rows = rows.filter((s) => s.kind === input.kind);
|
|
1009
|
+
}
|
|
1010
|
+
if (input.status) {
|
|
1011
|
+
rows = rows.filter((s) => s.status === input.status);
|
|
1012
|
+
} else if (input.onlyAlive) {
|
|
1013
|
+
rows = rows.filter(
|
|
1014
|
+
(s) => s.status === "running" || s.status === "starting"
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
return {
|
|
1018
|
+
content: [
|
|
1019
|
+
{ type: "text", text: JSON.stringify({ sessions: rows }, null, 2) }
|
|
1020
|
+
]
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
);
|
|
1024
|
+
server.tool(
|
|
1025
|
+
"list_agent_sessions",
|
|
1026
|
+
"DEPRECATED \u2014 prefer `list_sessions` which returns ALL kinds + filters. Despite the name this tool already returns every kind, not just agent-cli sessions; the new tool's name reflects the actual surface.",
|
|
1027
|
+
{
|
|
1028
|
+
onlyAlive: z.boolean().optional().describe("Filter to status running/starting only. Default false.")
|
|
1029
|
+
},
|
|
1030
|
+
async (input) => {
|
|
1031
|
+
const all = registry.list();
|
|
1032
|
+
const filtered = input.onlyAlive ? all.filter((s) => s.status === "running" || s.status === "starting") : all;
|
|
1033
|
+
return {
|
|
1034
|
+
content: [
|
|
1035
|
+
{
|
|
1036
|
+
type: "text",
|
|
1037
|
+
text: JSON.stringify({ sessions: filtered }, null, 2)
|
|
1038
|
+
}
|
|
1039
|
+
]
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
);
|
|
1043
|
+
server.tool(
|
|
1044
|
+
"get_agent_session_output",
|
|
1045
|
+
"Tail the recent output of a session. Returns the last N lines of the ring buffer (stdout + stderr inter-leaved, newest last). Use this to read an agent's reply after `prompt_agent_session`.",
|
|
1046
|
+
{
|
|
1047
|
+
sessionId: z.string().describe("Session id."),
|
|
1048
|
+
lastN: z.number().int().min(1).max(500).optional().describe("Max lines to return. Default 80, max 500.")
|
|
1049
|
+
},
|
|
1050
|
+
async (input) => {
|
|
1051
|
+
const desc = registry.get(input.sessionId);
|
|
1052
|
+
if (!desc) {
|
|
1053
|
+
return {
|
|
1054
|
+
content: [
|
|
1055
|
+
{ type: "text", text: `get_agent_session_output: no session "${input.sessionId}"` }
|
|
1056
|
+
],
|
|
1057
|
+
isError: true
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
const limit = input.lastN ?? 80;
|
|
1061
|
+
const lines = [];
|
|
1062
|
+
const unsub = registry.attach(input.sessionId, (line, _stream) => {
|
|
1063
|
+
lines.push(line);
|
|
1064
|
+
});
|
|
1065
|
+
if (unsub) unsub();
|
|
1066
|
+
const tail = lines.slice(-limit);
|
|
1067
|
+
return {
|
|
1068
|
+
content: [
|
|
1069
|
+
{
|
|
1070
|
+
type: "text",
|
|
1071
|
+
text: JSON.stringify(
|
|
1072
|
+
{
|
|
1073
|
+
sessionId: input.sessionId,
|
|
1074
|
+
status: desc.status,
|
|
1075
|
+
lastOutputAt: desc.lastOutputAt,
|
|
1076
|
+
lines: tail
|
|
1077
|
+
},
|
|
1078
|
+
null,
|
|
1079
|
+
2
|
|
1080
|
+
)
|
|
1081
|
+
}
|
|
1082
|
+
]
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
);
|
|
1086
|
+
server.tool(
|
|
1087
|
+
"list_adapters",
|
|
1088
|
+
"Enumerate every agent CLI adapter installed on the host (claude-code, hermes, aider, \u2026). Returns slug + display name + version + protocol so callers can let users pick from the installed set instead of guessing. Use before `start_agent_session` when the model doesn't already know what's available.",
|
|
1089
|
+
{},
|
|
1090
|
+
async () => {
|
|
1091
|
+
if (!listAgentAdapters) {
|
|
1092
|
+
return {
|
|
1093
|
+
content: [
|
|
1094
|
+
{
|
|
1095
|
+
type: "text",
|
|
1096
|
+
text: "list_adapters is not enabled \u2014 the daemon was started without an adapter lister. Wire `@agentproto/cli`'s `listInstalledAdapters` via `createGateway({ listAgentAdapters })`."
|
|
1097
|
+
}
|
|
1098
|
+
],
|
|
1099
|
+
isError: true
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
try {
|
|
1103
|
+
const adapters = await listAgentAdapters();
|
|
1104
|
+
return {
|
|
1105
|
+
content: [{ type: "text", text: JSON.stringify({ adapters }, null, 2) }]
|
|
1106
|
+
};
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
return {
|
|
1109
|
+
content: [
|
|
1110
|
+
{
|
|
1111
|
+
type: "text",
|
|
1112
|
+
text: `list_adapters failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1113
|
+
}
|
|
1114
|
+
],
|
|
1115
|
+
isError: true
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
);
|
|
1120
|
+
server.tool(
|
|
1121
|
+
"list_discovered_mcps",
|
|
1122
|
+
"Discover MCP servers already configured in the user's other agent tooling (claude-code, cursor, goose). Returns the union with source attribution so the operator can suggest 'I see you have a chrome-devtools MCP set up in claude \u2014 want me to use it?' instead of asking the user to re-configure. Read-only \u2014 does not modify any host's config.",
|
|
1123
|
+
{},
|
|
1124
|
+
async () => {
|
|
1125
|
+
try {
|
|
1126
|
+
const mcps = await discoverMcps();
|
|
1127
|
+
return {
|
|
1128
|
+
content: [{ type: "text", text: JSON.stringify({ mcps }, null, 2) }]
|
|
1129
|
+
};
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
return {
|
|
1132
|
+
content: [
|
|
1133
|
+
{
|
|
1134
|
+
type: "text",
|
|
1135
|
+
text: `list_discovered_mcps failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1136
|
+
}
|
|
1137
|
+
],
|
|
1138
|
+
isError: true
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
);
|
|
1143
|
+
server.tool(
|
|
1144
|
+
"list_imported_mcps",
|
|
1145
|
+
"Return the user's curated set of MCP servers \u2014 the ones they've imported from claude / cursor / workspace configs into the daemon. Use to know which MCPs the operator may freely call vs. ones still showing up in `list_discovered_mcps` waiting on the user's blessing.",
|
|
1146
|
+
{},
|
|
1147
|
+
async () => {
|
|
1148
|
+
try {
|
|
1149
|
+
const config = await loadImportedMcps();
|
|
1150
|
+
return {
|
|
1151
|
+
content: [
|
|
1152
|
+
{ type: "text", text: JSON.stringify(config, null, 2) }
|
|
1153
|
+
]
|
|
1154
|
+
};
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
return {
|
|
1157
|
+
content: [
|
|
1158
|
+
{
|
|
1159
|
+
type: "text",
|
|
1160
|
+
text: `list_imported_mcps failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1161
|
+
}
|
|
1162
|
+
],
|
|
1163
|
+
isError: true
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
);
|
|
1168
|
+
server.tool(
|
|
1169
|
+
"import_mcp",
|
|
1170
|
+
"Import a discovered MCP into the daemon's curated set. The agent calls `list_discovered_mcps` first, asks the user, then commits the choice via this tool. The snapshot is captured at import time so the entry stays usable if the source config (claude/cursor) is later removed.",
|
|
1171
|
+
{
|
|
1172
|
+
sourceMcpId: z.string().min(1).describe(
|
|
1173
|
+
"The discovered MCP id from `list_discovered_mcps` (e.g. 'claude-code:project:/path:chrome-devtools')."
|
|
1174
|
+
),
|
|
1175
|
+
alias: z.string().optional().describe(
|
|
1176
|
+
"Optional friendly name to display. Defaults to the source MCP's name."
|
|
1177
|
+
)
|
|
1178
|
+
},
|
|
1179
|
+
async (input) => {
|
|
1180
|
+
try {
|
|
1181
|
+
const discovered = await discoverMcps();
|
|
1182
|
+
const snapshot = discovered.find((d) => d.id === input.sourceMcpId);
|
|
1183
|
+
if (!snapshot) {
|
|
1184
|
+
return {
|
|
1185
|
+
content: [
|
|
1186
|
+
{
|
|
1187
|
+
type: "text",
|
|
1188
|
+
text: `import_mcp: discovered MCP "${input.sourceMcpId}" not found. Re-run list_discovered_mcps to get current ids.`
|
|
1189
|
+
}
|
|
1190
|
+
],
|
|
1191
|
+
isError: true
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
const cfg = await loadImportedMcps();
|
|
1195
|
+
const next = addImport(cfg, {
|
|
1196
|
+
snapshot,
|
|
1197
|
+
...input.alias ? { alias: input.alias } : {}
|
|
1198
|
+
});
|
|
1199
|
+
await saveImportedMcps(next);
|
|
1200
|
+
const entry = next.imports.find((e) => e.id === snapshot.id);
|
|
1201
|
+
return {
|
|
1202
|
+
content: [{ type: "text", text: JSON.stringify(entry, null, 2) }]
|
|
1203
|
+
};
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
return {
|
|
1206
|
+
content: [
|
|
1207
|
+
{
|
|
1208
|
+
type: "text",
|
|
1209
|
+
text: `import_mcp failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1210
|
+
}
|
|
1211
|
+
],
|
|
1212
|
+
isError: true
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
);
|
|
1217
|
+
server.tool(
|
|
1218
|
+
"remove_imported_mcp",
|
|
1219
|
+
"Remove a previously-imported MCP from the daemon's curated set. Use when the user no longer wants the operator referencing it.",
|
|
1220
|
+
{
|
|
1221
|
+
id: z.string().min(1).describe(
|
|
1222
|
+
"The imported MCP id (matches the discovered MCP id at import time)."
|
|
1223
|
+
)
|
|
1224
|
+
},
|
|
1225
|
+
async (input) => {
|
|
1226
|
+
try {
|
|
1227
|
+
const cfg = await loadImportedMcps();
|
|
1228
|
+
if (!cfg.imports.some((e) => e.id === input.id)) {
|
|
1229
|
+
return {
|
|
1230
|
+
content: [
|
|
1231
|
+
{
|
|
1232
|
+
type: "text",
|
|
1233
|
+
text: `remove_imported_mcp: id "${input.id}" not in imports. Use list_imported_mcps to see current entries.`
|
|
1234
|
+
}
|
|
1235
|
+
],
|
|
1236
|
+
isError: true
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
await saveImportedMcps(removeImport(cfg, input.id));
|
|
1240
|
+
return {
|
|
1241
|
+
content: [
|
|
1242
|
+
{ type: "text", text: JSON.stringify({ ok: true, id: input.id }, null, 2) }
|
|
1243
|
+
]
|
|
1244
|
+
};
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
return {
|
|
1247
|
+
content: [
|
|
1248
|
+
{
|
|
1249
|
+
type: "text",
|
|
1250
|
+
text: `remove_imported_mcp failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1251
|
+
}
|
|
1252
|
+
],
|
|
1253
|
+
isError: true
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
);
|
|
1258
|
+
server.tool(
|
|
1259
|
+
"mcp_imported_status",
|
|
1260
|
+
"Snapshot every imported MCP server with its connection status, transport type, and tool count. Use this first when an operator wonders 'what MCPs do I actually have access to right now?' \u2014 the answer covers both 'imported but not yet connected' and 'connected with N tools'. Errors during connect surface in `lastError`.",
|
|
1261
|
+
{},
|
|
1262
|
+
async () => {
|
|
1263
|
+
if (!mcpProxy) {
|
|
1264
|
+
return {
|
|
1265
|
+
content: [
|
|
1266
|
+
{
|
|
1267
|
+
type: "text",
|
|
1268
|
+
text: "mcp_imported_status is not enabled \u2014 daemon was started without an MCP proxy. The host must wire `mcpProxy` in createGateway."
|
|
1269
|
+
}
|
|
1270
|
+
],
|
|
1271
|
+
isError: true
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
try {
|
|
1275
|
+
const aliases = await mcpProxy.listAliases();
|
|
1276
|
+
return {
|
|
1277
|
+
content: [
|
|
1278
|
+
{ type: "text", text: JSON.stringify({ imports: aliases }, null, 2) }
|
|
1279
|
+
]
|
|
1280
|
+
};
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
return {
|
|
1283
|
+
content: [
|
|
1284
|
+
{
|
|
1285
|
+
type: "text",
|
|
1286
|
+
text: `mcp_imported_status failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1287
|
+
}
|
|
1288
|
+
],
|
|
1289
|
+
isError: true
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
);
|
|
1294
|
+
server.tool(
|
|
1295
|
+
"mcp_imported_list_tools",
|
|
1296
|
+
"List the tools exposed by one imported MCP server. The proxy lazily connects on first call \u2014 first-use latency includes the transport handshake (stdio: ~1-2s for npx-spawned servers; http/sse: <100ms). Returns the upstream `inputSchema` (JSON Schema) verbatim so the operator can build a valid `arguments` object for the follow-up `mcp_imported_call` invocation.",
|
|
1297
|
+
{
|
|
1298
|
+
alias: z.string().min(1).describe(
|
|
1299
|
+
"Alias from `list_imported_mcps` / `mcp_imported_status` (typically the original MCP name, e.g. 'chrome-devtools')."
|
|
1300
|
+
)
|
|
1301
|
+
},
|
|
1302
|
+
async (input) => {
|
|
1303
|
+
if (!mcpProxy) {
|
|
1304
|
+
return {
|
|
1305
|
+
content: [
|
|
1306
|
+
{
|
|
1307
|
+
type: "text",
|
|
1308
|
+
text: "mcp_imported_list_tools is not enabled \u2014 see mcp_imported_status."
|
|
1309
|
+
}
|
|
1310
|
+
],
|
|
1311
|
+
isError: true
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
const out = await mcpProxy.listTools(input.alias);
|
|
1315
|
+
if (!out.ok) {
|
|
1316
|
+
return {
|
|
1317
|
+
content: [
|
|
1318
|
+
{
|
|
1319
|
+
type: "text",
|
|
1320
|
+
text: `mcp_imported_list_tools "${input.alias}": ${out.error}`
|
|
1321
|
+
}
|
|
1322
|
+
],
|
|
1323
|
+
isError: true
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
return {
|
|
1327
|
+
content: [
|
|
1328
|
+
{ type: "text", text: JSON.stringify({ alias: input.alias, tools: out.tools }, null, 2) }
|
|
1329
|
+
]
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
);
|
|
1333
|
+
server.tool(
|
|
1334
|
+
"mcp_imported_call",
|
|
1335
|
+
"Invoke a tool on an imported MCP server. The daemon proxies the call through the live client connection \u2014 the upstream server validates `arguments` against its own input schema (which you can fetch via `mcp_imported_list_tools`). The full upstream result is returned verbatim, including `isError` flags so the operator sees the original failure shape.",
|
|
1336
|
+
{
|
|
1337
|
+
alias: z.string().min(1).describe("Imported MCP alias."),
|
|
1338
|
+
toolName: z.string().min(1).describe(
|
|
1339
|
+
"Tool name as it appears in `mcp_imported_list_tools` (NOT a namespaced version \u2014 pass the upstream's own name)."
|
|
1340
|
+
),
|
|
1341
|
+
args: z.record(z.string(), z.unknown()).optional().describe(
|
|
1342
|
+
"Tool arguments as a JSON object. Schema is the upstream's \u2014 the proxy doesn't validate, only forwards. Default: empty object."
|
|
1343
|
+
)
|
|
1344
|
+
},
|
|
1345
|
+
async (input) => {
|
|
1346
|
+
if (!mcpProxy) {
|
|
1347
|
+
return {
|
|
1348
|
+
content: [
|
|
1349
|
+
{
|
|
1350
|
+
type: "text",
|
|
1351
|
+
text: "mcp_imported_call is not enabled \u2014 see mcp_imported_status."
|
|
1352
|
+
}
|
|
1353
|
+
],
|
|
1354
|
+
isError: true
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
const out = await mcpProxy.callTool(
|
|
1358
|
+
input.alias,
|
|
1359
|
+
input.toolName,
|
|
1360
|
+
input.args ?? {}
|
|
1361
|
+
);
|
|
1362
|
+
if (!out.ok) {
|
|
1363
|
+
return {
|
|
1364
|
+
content: [
|
|
1365
|
+
{
|
|
1366
|
+
type: "text",
|
|
1367
|
+
text: `mcp_imported_call "${input.alias}".${input.toolName}: ${out.error}`
|
|
1368
|
+
}
|
|
1369
|
+
],
|
|
1370
|
+
isError: true
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
return out.result;
|
|
1374
|
+
}
|
|
1375
|
+
);
|
|
1376
|
+
server.tool(
|
|
1377
|
+
"kill_agent_session",
|
|
1378
|
+
"Stop a session \u2014 SIGTERM the underlying child + close the agent protocol session. Use to free resources after the operator is done, or when a session is wedged.",
|
|
1379
|
+
{
|
|
1380
|
+
sessionId: z.string().describe("Session id.")
|
|
1381
|
+
},
|
|
1382
|
+
async (input) => {
|
|
1383
|
+
const ok = registry.kill(input.sessionId);
|
|
1384
|
+
return {
|
|
1385
|
+
content: [
|
|
1386
|
+
{
|
|
1387
|
+
type: "text",
|
|
1388
|
+
text: JSON.stringify({ ok, sessionId: input.sessionId }, null, 2)
|
|
1389
|
+
}
|
|
1390
|
+
]
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
);
|
|
1394
|
+
const ptyNotConfigured = (toolName) => ({
|
|
1395
|
+
content: [
|
|
1396
|
+
{
|
|
1397
|
+
type: "text",
|
|
1398
|
+
text: `${toolName}: PTY support not enabled \u2014 the daemon was started without a node-pty factory. Re-run \`agentproto serve\` from a build that ships node-pty (the optional dep ships with @agentproto/cli).`
|
|
1399
|
+
}
|
|
1400
|
+
],
|
|
1401
|
+
isError: true
|
|
1402
|
+
});
|
|
1403
|
+
server.tool(
|
|
1404
|
+
"start_terminal_session",
|
|
1405
|
+
"Spawn a process under a real PTY (node-pty) on the host. Bytes (including ANSI escapes, alt-screen sequences) flow through the daemon's byte ring buffer; subscribers attach via the WS at /sessions/:id/pty. Use for interactive TUIs (claude, vim, htop) or to orchestrate shells from another agent. Returns the session descriptor.",
|
|
1406
|
+
{
|
|
1407
|
+
argv: z.array(z.string()).min(1).describe(
|
|
1408
|
+
"Argv array. First element is the binary, rest are arguments. e.g. ['claude'] or ['bash', '-l']."
|
|
1409
|
+
),
|
|
1410
|
+
workspaceSlug: z.string().optional().describe(
|
|
1411
|
+
"Workspace slug from `agentproto workspace list`. Resolves cwd. Omit to use `cwd` explicitly or the active workspace."
|
|
1412
|
+
),
|
|
1413
|
+
cwd: z.string().optional().describe("Absolute cwd. Wins over workspaceSlug when both set."),
|
|
1414
|
+
cols: z.number().int().min(1).max(500).optional().describe("Initial cols. Default 80."),
|
|
1415
|
+
rows: z.number().int().min(1).max(200).optional().describe("Initial rows. Default 24."),
|
|
1416
|
+
name: z.string().optional().describe(
|
|
1417
|
+
"User-friendly slug. Becomes an alias for the session id in subsequent tool calls (read/write/kill accept either)."
|
|
1418
|
+
),
|
|
1419
|
+
label: z.string().optional().describe(
|
|
1420
|
+
"Free-text label surfaced in list_agent_sessions and the UI."
|
|
1421
|
+
)
|
|
1422
|
+
},
|
|
1423
|
+
async (input) => {
|
|
1424
|
+
if (!ptyEnabled) return ptyNotConfigured("start_terminal_session");
|
|
1425
|
+
let cwd = input.cwd;
|
|
1426
|
+
let resolvedSlug = input.workspaceSlug ?? "default";
|
|
1427
|
+
if (!cwd) {
|
|
1428
|
+
try {
|
|
1429
|
+
const config = await loadWorkspacesConfig();
|
|
1430
|
+
const ws = input.workspaceSlug ? findWorkspace(config, input.workspaceSlug) : getActiveWorkspace(config);
|
|
1431
|
+
if (ws) {
|
|
1432
|
+
cwd = ws.path;
|
|
1433
|
+
resolvedSlug = ws.slug;
|
|
1434
|
+
}
|
|
1435
|
+
} catch {
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
if (!cwd) {
|
|
1439
|
+
return {
|
|
1440
|
+
content: [
|
|
1441
|
+
{
|
|
1442
|
+
type: "text",
|
|
1443
|
+
text: "start_terminal_session: no cwd resolvable. Pass `cwd` explicitly or `workspaceSlug` matching `agentproto workspace list`."
|
|
1444
|
+
}
|
|
1445
|
+
],
|
|
1446
|
+
isError: true
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
try {
|
|
1450
|
+
const desc = registry.spawnPty({
|
|
1451
|
+
argv: input.argv,
|
|
1452
|
+
cwd,
|
|
1453
|
+
workspaceSlug: resolvedSlug,
|
|
1454
|
+
cols: input.cols ?? 80,
|
|
1455
|
+
rows: input.rows ?? 24,
|
|
1456
|
+
...input.name ? { name: input.name } : {},
|
|
1457
|
+
...input.label ? { label: input.label } : {}
|
|
1458
|
+
});
|
|
1459
|
+
return {
|
|
1460
|
+
content: [{ type: "text", text: JSON.stringify(desc, null, 2) }]
|
|
1461
|
+
};
|
|
1462
|
+
} catch (err) {
|
|
1463
|
+
return {
|
|
1464
|
+
content: [
|
|
1465
|
+
{
|
|
1466
|
+
type: "text",
|
|
1467
|
+
text: `start_terminal_session: ${err instanceof Error ? err.message : String(err)}`
|
|
1468
|
+
}
|
|
1469
|
+
],
|
|
1470
|
+
isError: true
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
);
|
|
1475
|
+
server.tool(
|
|
1476
|
+
"write_terminal_input",
|
|
1477
|
+
"Send keystrokes to a PTY session's stdin. The text is forwarded verbatim \u2014 include trailing newlines if the target needs them (e.g. shell commands). Use after `start_terminal_session` to drive an interactive CLI.",
|
|
1478
|
+
{
|
|
1479
|
+
sessionId: z.string().describe("Session id OR name from start_terminal_session."),
|
|
1480
|
+
text: z.string().describe("Text to write. Sent as-is to the PTY's stdin.")
|
|
1481
|
+
},
|
|
1482
|
+
async (input) => {
|
|
1483
|
+
if (!ptyEnabled) return ptyNotConfigured("write_terminal_input");
|
|
1484
|
+
const desc = registry.findByIdOrName(input.sessionId);
|
|
1485
|
+
if (!desc) {
|
|
1486
|
+
return {
|
|
1487
|
+
content: [
|
|
1488
|
+
{
|
|
1489
|
+
type: "text",
|
|
1490
|
+
text: `write_terminal_input: no session "${input.sessionId}"`
|
|
1491
|
+
}
|
|
1492
|
+
],
|
|
1493
|
+
isError: true
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
const ok = registry.writeTerminalInput(desc.id, input.text);
|
|
1497
|
+
return {
|
|
1498
|
+
content: [
|
|
1499
|
+
{
|
|
1500
|
+
type: "text",
|
|
1501
|
+
text: JSON.stringify({ ok, sessionId: desc.id }, null, 2)
|
|
1502
|
+
}
|
|
1503
|
+
],
|
|
1504
|
+
...ok ? {} : { isError: true }
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
);
|
|
1508
|
+
server.tool(
|
|
1509
|
+
"read_terminal_output",
|
|
1510
|
+
"Snapshot the recent byte buffer of a PTY session. Returns base64-encoded bytes (the buffer is RAW including ANSI escapes \u2014 strip with a regex if you want plain text). `lastBytes` caps the read from the tail.",
|
|
1511
|
+
{
|
|
1512
|
+
sessionId: z.string().describe("Session id OR name from start_terminal_session."),
|
|
1513
|
+
lastBytes: z.number().int().min(1).max(64 * 1024).optional().describe("Max bytes from the tail. Default: full ring buffer (~64 KiB).")
|
|
1514
|
+
},
|
|
1515
|
+
async (input) => {
|
|
1516
|
+
if (!ptyEnabled) return ptyNotConfigured("read_terminal_output");
|
|
1517
|
+
const desc = registry.findByIdOrName(input.sessionId);
|
|
1518
|
+
if (!desc) {
|
|
1519
|
+
return {
|
|
1520
|
+
content: [
|
|
1521
|
+
{
|
|
1522
|
+
type: "text",
|
|
1523
|
+
text: `read_terminal_output: no session "${input.sessionId}"`
|
|
1524
|
+
}
|
|
1525
|
+
],
|
|
1526
|
+
isError: true
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
const buf = registry.readTerminalOutput(
|
|
1530
|
+
desc.id,
|
|
1531
|
+
input.lastBytes
|
|
1532
|
+
);
|
|
1533
|
+
if (!buf) {
|
|
1534
|
+
return {
|
|
1535
|
+
content: [
|
|
1536
|
+
{
|
|
1537
|
+
type: "text",
|
|
1538
|
+
text: `read_terminal_output: session "${desc.id}" is not a PTY`
|
|
1539
|
+
}
|
|
1540
|
+
],
|
|
1541
|
+
isError: true
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
return {
|
|
1545
|
+
content: [
|
|
1546
|
+
{
|
|
1547
|
+
type: "text",
|
|
1548
|
+
text: JSON.stringify(
|
|
1549
|
+
{
|
|
1550
|
+
sessionId: desc.id,
|
|
1551
|
+
status: desc.status,
|
|
1552
|
+
bytes: buf.byteLength,
|
|
1553
|
+
b64: buf.toString("base64")
|
|
1554
|
+
},
|
|
1555
|
+
null,
|
|
1556
|
+
2
|
|
1557
|
+
)
|
|
1558
|
+
}
|
|
1559
|
+
]
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
);
|
|
1563
|
+
server.tool(
|
|
1564
|
+
"kill_terminal_session",
|
|
1565
|
+
"SIGTERM a PTY session and drop it from the alive set. Same effect as `kill_agent_session` for the PTY family \u2014 separate name so it's obvious what's being stopped.",
|
|
1566
|
+
{
|
|
1567
|
+
sessionId: z.string().describe("Session id OR name from start_terminal_session.")
|
|
1568
|
+
},
|
|
1569
|
+
async (input) => {
|
|
1570
|
+
if (!ptyEnabled) return ptyNotConfigured("kill_terminal_session");
|
|
1571
|
+
const desc = registry.findByIdOrName(input.sessionId);
|
|
1572
|
+
if (!desc) {
|
|
1573
|
+
return {
|
|
1574
|
+
content: [
|
|
1575
|
+
{
|
|
1576
|
+
type: "text",
|
|
1577
|
+
text: `kill_terminal_session: no session "${input.sessionId}"`
|
|
1578
|
+
}
|
|
1579
|
+
],
|
|
1580
|
+
isError: true
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
const ok = registry.kill(desc.id);
|
|
1584
|
+
return {
|
|
1585
|
+
content: [
|
|
1586
|
+
{
|
|
1587
|
+
type: "text",
|
|
1588
|
+
text: JSON.stringify({ ok, sessionId: desc.id }, null, 2)
|
|
1589
|
+
}
|
|
1590
|
+
]
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
function startHeartbeat(opts) {
|
|
1596
|
+
const filename = opts.filename ?? "HEARTBEAT.md";
|
|
1597
|
+
const path = join(opts.workspace, filename);
|
|
1598
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
1599
|
+
let timer = null;
|
|
1600
|
+
let currentEveryMs = null;
|
|
1601
|
+
let firing = false;
|
|
1602
|
+
async function load() {
|
|
1603
|
+
if (!existsSync(path)) return null;
|
|
1604
|
+
let source;
|
|
1605
|
+
try {
|
|
1606
|
+
source = await readFile(path, "utf8");
|
|
1607
|
+
} catch {
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
const parsed = matter(source);
|
|
1611
|
+
const fm = parsed.data;
|
|
1612
|
+
const agent = typeof fm.agent === "string" ? fm.agent : null;
|
|
1613
|
+
if (!agent) return null;
|
|
1614
|
+
const enabled = fm.enabled === void 0 ? true : Boolean(fm.enabled);
|
|
1615
|
+
const everyRaw = typeof fm.every === "string" ? fm.every : "60s";
|
|
1616
|
+
const everyMs = parseDuration(everyRaw);
|
|
1617
|
+
if (!everyMs) return null;
|
|
1618
|
+
const conversationTemplate = typeof fm.conversation === "string" ? fm.conversation : "heartbeat-{date}";
|
|
1619
|
+
return {
|
|
1620
|
+
agent,
|
|
1621
|
+
everyMs,
|
|
1622
|
+
enabled,
|
|
1623
|
+
conversationTemplate,
|
|
1624
|
+
body: parsed.content.trim()
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
async function tick() {
|
|
1628
|
+
if (firing) return;
|
|
1629
|
+
if (opts.shouldFire && !opts.shouldFire()) return;
|
|
1630
|
+
firing = true;
|
|
1631
|
+
let agentId;
|
|
1632
|
+
try {
|
|
1633
|
+
const hb = await load();
|
|
1634
|
+
if (!hb || !hb.enabled) return;
|
|
1635
|
+
agentId = hb.agent;
|
|
1636
|
+
const agent = await opts.buildAgent(hb.agent);
|
|
1637
|
+
if (!agent) {
|
|
1638
|
+
opts.events.emit({
|
|
1639
|
+
type: "heartbeat-error",
|
|
1640
|
+
at: now().toISOString(),
|
|
1641
|
+
agent: hb.agent,
|
|
1642
|
+
error: `buildAgent('${hb.agent}') returned null`
|
|
1643
|
+
});
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
if (!hb.body) {
|
|
1647
|
+
opts.events.emit({
|
|
1648
|
+
type: "heartbeat-error",
|
|
1649
|
+
at: now().toISOString(),
|
|
1650
|
+
agent: hb.agent,
|
|
1651
|
+
error: "HEARTBEAT.md body is empty"
|
|
1652
|
+
});
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
const conversationId = expandTemplate(hb.conversationTemplate, now());
|
|
1656
|
+
await opts.conversations.open(conversationId, { agent: hb.agent });
|
|
1657
|
+
const startedAt = now();
|
|
1658
|
+
await opts.conversations.appendTurn(
|
|
1659
|
+
conversationId,
|
|
1660
|
+
"user",
|
|
1661
|
+
hb.body,
|
|
1662
|
+
{ at: startedAt.toISOString(), attribution: "heartbeat" }
|
|
1663
|
+
);
|
|
1664
|
+
opts.events.emit({
|
|
1665
|
+
type: "conv-turn-appended",
|
|
1666
|
+
at: startedAt.toISOString(),
|
|
1667
|
+
conversationId,
|
|
1668
|
+
role: "user",
|
|
1669
|
+
contentPreview: preview(hb.body)
|
|
1670
|
+
});
|
|
1671
|
+
const t0 = Date.now();
|
|
1672
|
+
const reply = await agent.generate(hb.body);
|
|
1673
|
+
const durationMs = Date.now() - t0;
|
|
1674
|
+
const finishedAt = now();
|
|
1675
|
+
await opts.conversations.appendTurn(
|
|
1676
|
+
conversationId,
|
|
1677
|
+
"assistant",
|
|
1678
|
+
reply.text,
|
|
1679
|
+
{ at: finishedAt.toISOString(), attribution: hb.agent }
|
|
1680
|
+
);
|
|
1681
|
+
opts.events.emit({
|
|
1682
|
+
type: "conv-turn-appended",
|
|
1683
|
+
at: finishedAt.toISOString(),
|
|
1684
|
+
conversationId,
|
|
1685
|
+
role: "assistant",
|
|
1686
|
+
contentPreview: preview(reply.text)
|
|
1687
|
+
});
|
|
1688
|
+
opts.events.emit({
|
|
1689
|
+
type: "heartbeat-fired",
|
|
1690
|
+
at: finishedAt.toISOString(),
|
|
1691
|
+
agent: hb.agent,
|
|
1692
|
+
conversationId,
|
|
1693
|
+
prompt: hb.body,
|
|
1694
|
+
reply: reply.text,
|
|
1695
|
+
durationMs
|
|
1696
|
+
});
|
|
1697
|
+
if (currentEveryMs !== null && currentEveryMs !== hb.everyMs) {
|
|
1698
|
+
reschedule(hb.everyMs);
|
|
1699
|
+
}
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
opts.events.emit({
|
|
1702
|
+
type: "heartbeat-error",
|
|
1703
|
+
at: now().toISOString(),
|
|
1704
|
+
agent: agentId,
|
|
1705
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1706
|
+
});
|
|
1707
|
+
} finally {
|
|
1708
|
+
firing = false;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
function reschedule(everyMs) {
|
|
1712
|
+
if (timer) clearInterval(timer);
|
|
1713
|
+
currentEveryMs = everyMs;
|
|
1714
|
+
timer = setInterval(() => {
|
|
1715
|
+
void tick();
|
|
1716
|
+
}, everyMs);
|
|
1717
|
+
}
|
|
1718
|
+
return {
|
|
1719
|
+
start() {
|
|
1720
|
+
if (timer) return;
|
|
1721
|
+
void load().then((hb) => {
|
|
1722
|
+
const everyMs = hb?.everyMs ?? 6e4;
|
|
1723
|
+
reschedule(everyMs);
|
|
1724
|
+
});
|
|
1725
|
+
},
|
|
1726
|
+
stop() {
|
|
1727
|
+
if (timer) {
|
|
1728
|
+
clearInterval(timer);
|
|
1729
|
+
timer = null;
|
|
1730
|
+
currentEveryMs = null;
|
|
1731
|
+
}
|
|
1732
|
+
},
|
|
1733
|
+
async fireNow() {
|
|
1734
|
+
await tick();
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
var DURATION_RE = /^(\d+)\s*(s|m|h|d)$/;
|
|
1739
|
+
function parseDuration(input) {
|
|
1740
|
+
const trimmed = input.trim();
|
|
1741
|
+
const match = trimmed.match(DURATION_RE);
|
|
1742
|
+
if (!match) return null;
|
|
1743
|
+
const n = Number(match[1]);
|
|
1744
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
1745
|
+
switch (match[2]) {
|
|
1746
|
+
case "s":
|
|
1747
|
+
return n * 1e3;
|
|
1748
|
+
case "m":
|
|
1749
|
+
return n * 6e4;
|
|
1750
|
+
case "h":
|
|
1751
|
+
return n * 36e5;
|
|
1752
|
+
case "d":
|
|
1753
|
+
return n * 864e5;
|
|
1754
|
+
default:
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
function expandTemplate(template, when) {
|
|
1759
|
+
const date = when.toISOString().slice(0, 10);
|
|
1760
|
+
return template.replace(/\{date\}/g, date);
|
|
1761
|
+
}
|
|
1762
|
+
function preview(text3, max = 120) {
|
|
1763
|
+
const flat = text3.replace(/\s+/g, " ").trim();
|
|
1764
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
|
|
1765
|
+
}
|
|
1766
|
+
var DEFAULT_ALLOWED_ORIGINS = [
|
|
1767
|
+
"http://localhost:*",
|
|
1768
|
+
"http://127.0.0.1:*",
|
|
1769
|
+
"https://localhost:*"
|
|
1770
|
+
];
|
|
1771
|
+
async function startHttpServer(opts) {
|
|
1772
|
+
const startedAt = Date.now();
|
|
1773
|
+
const authSource = opts.auth ?? { mode: "none" };
|
|
1774
|
+
const readAuth = () => typeof authSource === "function" ? authSource() : authSource;
|
|
1775
|
+
function isLoopback(req) {
|
|
1776
|
+
const addr = req.socket.remoteAddress ?? "";
|
|
1777
|
+
const isLocalAddr = addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
1778
|
+
if (!isLocalAddr) return false;
|
|
1779
|
+
if (req.headers["x-forwarded-for"]) return false;
|
|
1780
|
+
return true;
|
|
1781
|
+
}
|
|
1782
|
+
function authorize(req, res) {
|
|
1783
|
+
const auth = readAuth();
|
|
1784
|
+
if (auth.mode === "none") return true;
|
|
1785
|
+
if (isLoopback(req)) return true;
|
|
1786
|
+
const header = req.headers.authorization;
|
|
1787
|
+
const expected = `Bearer ${auth.token}`;
|
|
1788
|
+
if (header !== expected) {
|
|
1789
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
1790
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
1791
|
+
return false;
|
|
1792
|
+
}
|
|
1793
|
+
return true;
|
|
1794
|
+
}
|
|
1795
|
+
function checkSessionsToken(req) {
|
|
1796
|
+
if (!opts.token) return "ok";
|
|
1797
|
+
const header = req.headers.authorization;
|
|
1798
|
+
const expected = `Bearer ${opts.token}`;
|
|
1799
|
+
if (header === expected) return "ok";
|
|
1800
|
+
const urlStr = req.url ?? "";
|
|
1801
|
+
if (urlStr.includes("?")) {
|
|
1802
|
+
const qs = new URLSearchParams(urlStr.slice(urlStr.indexOf("?") + 1));
|
|
1803
|
+
const qsToken = qs.get("token");
|
|
1804
|
+
if (qsToken && qsToken === opts.token) return "ok";
|
|
1805
|
+
}
|
|
1806
|
+
const origin = req.headers.origin;
|
|
1807
|
+
if (typeof origin === "string" && originAllowed(origin)) return "ok";
|
|
1808
|
+
return header ? "bad" : "missing";
|
|
1809
|
+
}
|
|
1810
|
+
function originAllowed(origin) {
|
|
1811
|
+
const list = opts.strictOrigins ? opts.allowedOrigins ?? [] : [...DEFAULT_ALLOWED_ORIGINS, ...opts.allowedOrigins ?? []];
|
|
1812
|
+
for (const pattern of list) {
|
|
1813
|
+
if (pattern === origin) return true;
|
|
1814
|
+
if (pattern.endsWith(":*")) {
|
|
1815
|
+
const prefix = pattern.slice(0, -2);
|
|
1816
|
+
if (origin === prefix || origin.startsWith(prefix + ":")) return true;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return false;
|
|
1820
|
+
}
|
|
1821
|
+
function rejectUnauthorizedSession(req, res, kind) {
|
|
1822
|
+
const origin = req.headers.origin ?? null;
|
|
1823
|
+
const allowList = opts.strictOrigins ? opts.allowedOrigins ?? [] : [...DEFAULT_ALLOWED_ORIGINS, ...opts.allowedOrigins ?? []];
|
|
1824
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
1825
|
+
const message = kind === "missing" ? origin ? `Origin "${origin}" not in the daemon's allowlist, and no Bearer token sent. Either add it (\`agentproto config set daemon.allowedOrigins ${origin}\` then restart the daemon), or send Authorization: Bearer <token> read from <workspace>/.agentproto/runtime.json.` : `Authorization: Bearer <token> required on mutating /sessions/* routes. Read the token from <workspace>/.agentproto/runtime.json. (Browser callers can use an Origin in the allowlist instead.)` : `Invalid bearer token. The daemon regenerated its token on its last boot \u2014 re-read <workspace>/.agentproto/runtime.json.`;
|
|
1826
|
+
res.end(
|
|
1827
|
+
JSON.stringify({
|
|
1828
|
+
error: "sessions_unauthorized",
|
|
1829
|
+
message,
|
|
1830
|
+
// Debug data so a UI / network-tab inspection points the user
|
|
1831
|
+
// straight at the fix without needing the daemon's stderr.
|
|
1832
|
+
rejectedOrigin: origin,
|
|
1833
|
+
allowedOrigins: allowList,
|
|
1834
|
+
// We never echo the token; only the absence-or-presence flag.
|
|
1835
|
+
receivedAuthHeader: typeof req.headers.authorization === "string"
|
|
1836
|
+
})
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
function isMutatingSessionsRoute(method, path) {
|
|
1840
|
+
if (!path.startsWith("/sessions")) return false;
|
|
1841
|
+
if (method === "POST" || method === "DELETE" || method === "PUT" || method === "PATCH") {
|
|
1842
|
+
return true;
|
|
1843
|
+
}
|
|
1844
|
+
return false;
|
|
1845
|
+
}
|
|
1846
|
+
async function handleMcp(req, res) {
|
|
1847
|
+
if (!authorize(req, res)) return;
|
|
1848
|
+
const server2 = await opts.mcpServerFactory();
|
|
1849
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1850
|
+
sessionIdGenerator: void 0
|
|
1851
|
+
});
|
|
1852
|
+
const transportInternal = transport;
|
|
1853
|
+
if ("onerror" in transport) {
|
|
1854
|
+
transportInternal["onerror"] = (err) => {
|
|
1855
|
+
console.error("[mcp transport.onerror]", err);
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
res.on("close", () => {
|
|
1859
|
+
void transport.close();
|
|
1860
|
+
void server2.close();
|
|
1861
|
+
});
|
|
1862
|
+
try {
|
|
1863
|
+
await server2.connect(transport);
|
|
1864
|
+
await transport.handleRequest(req, res);
|
|
1865
|
+
} catch (err) {
|
|
1866
|
+
console.error("[mcp] handleRequest threw:", err);
|
|
1867
|
+
if (!res.headersSent) {
|
|
1868
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
1869
|
+
res.end(
|
|
1870
|
+
JSON.stringify({
|
|
1871
|
+
error: "mcp_transport_failed",
|
|
1872
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1873
|
+
})
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
function handleHealth(_req, res) {
|
|
1879
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1880
|
+
res.end(
|
|
1881
|
+
JSON.stringify({
|
|
1882
|
+
status: "ok",
|
|
1883
|
+
workspace: opts.meta.workspace,
|
|
1884
|
+
registered: opts.meta.registered,
|
|
1885
|
+
uptimeMs: Date.now() - startedAt
|
|
1886
|
+
})
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
function handleEvents(req, res) {
|
|
1890
|
+
if (!authorize(req, res)) return;
|
|
1891
|
+
res.writeHead(200, {
|
|
1892
|
+
"content-type": "text/event-stream",
|
|
1893
|
+
"cache-control": "no-cache, no-transform",
|
|
1894
|
+
connection: "keep-alive"
|
|
1895
|
+
});
|
|
1896
|
+
res.write(`: connected
|
|
1897
|
+
|
|
1898
|
+
`);
|
|
1899
|
+
const off = opts.events.onAny((ev) => {
|
|
1900
|
+
res.write(`data: ${JSON.stringify(ev)}
|
|
1901
|
+
|
|
1902
|
+
`);
|
|
1903
|
+
});
|
|
1904
|
+
req.on("close", off);
|
|
1905
|
+
}
|
|
1906
|
+
async function handleListConversations(req, res) {
|
|
1907
|
+
if (!authorize(req, res)) return;
|
|
1908
|
+
const list = await opts.conversations.list();
|
|
1909
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1910
|
+
res.end(JSON.stringify(list));
|
|
1911
|
+
}
|
|
1912
|
+
async function handleGetConversation(req, res, id) {
|
|
1913
|
+
if (!authorize(req, res)) return;
|
|
1914
|
+
try {
|
|
1915
|
+
const data = await opts.conversations.read(id);
|
|
1916
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1917
|
+
res.end(JSON.stringify(data));
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
1920
|
+
res.end(
|
|
1921
|
+
JSON.stringify({
|
|
1922
|
+
error: "not_found",
|
|
1923
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1924
|
+
})
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
async function handleHeartbeatTick(req, res) {
|
|
1929
|
+
if (!authorize(req, res)) return;
|
|
1930
|
+
await opts.heartbeat.fireNow();
|
|
1931
|
+
res.writeHead(202, { "content-type": "application/json" });
|
|
1932
|
+
res.end(JSON.stringify({ status: "fired" }));
|
|
1933
|
+
}
|
|
1934
|
+
async function handleFileUpload(req, res, url) {
|
|
1935
|
+
const reply = (status, body) => {
|
|
1936
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
1937
|
+
res.end(JSON.stringify(body));
|
|
1938
|
+
};
|
|
1939
|
+
const qs = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
|
|
1940
|
+
const cwd = qs.get("cwd");
|
|
1941
|
+
const rawName = qs.get("name");
|
|
1942
|
+
if (!cwd || !rawName) {
|
|
1943
|
+
reply(400, { error: "missing_cwd_or_name" });
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
if (!isAbsolute(cwd)) {
|
|
1947
|
+
reply(400, { error: "cwd_not_absolute" });
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
const safeName = rawName.replace(/[/\\]/g, "_").replace(/\.\.+/g, ".").replace(/^\.+/, "").slice(0, 200);
|
|
1951
|
+
if (safeName.length === 0) {
|
|
1952
|
+
reply(400, { error: "invalid_name" });
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
try {
|
|
1956
|
+
const st = await stat(cwd);
|
|
1957
|
+
if (!st.isDirectory()) {
|
|
1958
|
+
reply(400, { error: "cwd_not_a_directory" });
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
} catch {
|
|
1962
|
+
reply(400, { error: "cwd_not_found" });
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
const dir = join(cwd, ".agentproto-attachments");
|
|
1966
|
+
const target = resolve(dir, safeName);
|
|
1967
|
+
if (!target.startsWith(resolve(dir) + (dir.endsWith("/") ? "" : "/"))) {
|
|
1968
|
+
reply(400, { error: "path_traversal_blocked" });
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
const MAX_BYTES = 32 * 1024 * 1024;
|
|
1972
|
+
const chunks = [];
|
|
1973
|
+
let total = 0;
|
|
1974
|
+
let aborted = false;
|
|
1975
|
+
await new Promise((resolveBody, rejectBody) => {
|
|
1976
|
+
req.on("data", (chunk) => {
|
|
1977
|
+
if (aborted) return;
|
|
1978
|
+
const buf = chunk;
|
|
1979
|
+
total += buf.length;
|
|
1980
|
+
if (total > MAX_BYTES) {
|
|
1981
|
+
aborted = true;
|
|
1982
|
+
reply(413, { error: "file_too_large", maxBytes: MAX_BYTES });
|
|
1983
|
+
rejectBody(new Error("too_large"));
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
chunks.push(buf);
|
|
1987
|
+
});
|
|
1988
|
+
req.on("end", () => {
|
|
1989
|
+
if (!aborted) resolveBody();
|
|
1990
|
+
});
|
|
1991
|
+
req.on("error", (err) => rejectBody(err));
|
|
1992
|
+
}).catch(() => void 0);
|
|
1993
|
+
if (aborted) return;
|
|
1994
|
+
try {
|
|
1995
|
+
await mkdir(dir, { recursive: true });
|
|
1996
|
+
await writeFile(target, Buffer.concat(chunks));
|
|
1997
|
+
reply(200, { path: target, bytes: total });
|
|
1998
|
+
} catch (err) {
|
|
1999
|
+
reply(500, {
|
|
2000
|
+
error: "write_failed",
|
|
2001
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
function applyCors(req, res) {
|
|
2006
|
+
const origin = req.headers.origin ?? "*";
|
|
2007
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
2008
|
+
res.setHeader("Vary", "Origin");
|
|
2009
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
2010
|
+
res.setHeader(
|
|
2011
|
+
"Access-Control-Allow-Headers",
|
|
2012
|
+
req.headers["access-control-request-headers"] ?? "authorization,content-type,mcp-session-id"
|
|
2013
|
+
);
|
|
2014
|
+
res.setHeader(
|
|
2015
|
+
"Access-Control-Allow-Methods",
|
|
2016
|
+
"GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
|
2017
|
+
);
|
|
2018
|
+
if (req.headers["access-control-request-private-network"] === "true") {
|
|
2019
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
const server = createServer((req, res) => {
|
|
2023
|
+
void (async () => {
|
|
2024
|
+
try {
|
|
2025
|
+
const url = req.url ?? "/";
|
|
2026
|
+
const path = url.split("?")[0] ?? "/";
|
|
2027
|
+
if (req.headers["x-forwarded-for"]) {
|
|
2028
|
+
opts.events.emit({
|
|
2029
|
+
type: "remote-log",
|
|
2030
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2031
|
+
line: `[http-in] ${req.method} ${url} host=${req.headers.host ?? "?"} xff=${String(req.headers["x-forwarded-for"])}`
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
applyCors(req, res);
|
|
2035
|
+
if (req.method === "OPTIONS") {
|
|
2036
|
+
res.writeHead(204);
|
|
2037
|
+
res.end();
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
if (path === "/health" && req.method === "GET") {
|
|
2041
|
+
handleHealth(req, res);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
if (path === "/events" && req.method === "GET") {
|
|
2045
|
+
handleEvents(req, res);
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
if (path === "/mcp") {
|
|
2049
|
+
await handleMcp(req, res);
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
if (path === "/conversations" && req.method === "GET") {
|
|
2053
|
+
await handleListConversations(req, res);
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
if (path.startsWith("/conversations/") && req.method === "GET") {
|
|
2057
|
+
const id = path.slice("/conversations/".length);
|
|
2058
|
+
await handleGetConversation(req, res, id);
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (path === "/heartbeat/tick" && req.method === "POST") {
|
|
2062
|
+
await handleHeartbeatTick(req, res);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
if (path === "/files/upload" && req.method === "POST") {
|
|
2066
|
+
const gate = checkSessionsToken(req);
|
|
2067
|
+
if (gate !== "ok") {
|
|
2068
|
+
rejectUnauthorizedSession(req, res, gate);
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
await handleFileUpload(req, res, url);
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
if (opts.sessions && path.startsWith("/sessions")) {
|
|
2075
|
+
if (isMutatingSessionsRoute(req.method ?? "GET", path)) {
|
|
2076
|
+
const gate = checkSessionsToken(req);
|
|
2077
|
+
if (gate !== "ok") {
|
|
2078
|
+
rejectUnauthorizedSession(req, res, gate);
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
const handled = await handleSessions(
|
|
2083
|
+
req,
|
|
2084
|
+
res,
|
|
2085
|
+
path,
|
|
2086
|
+
opts.sessions,
|
|
2087
|
+
opts.resolveAgentAdapter,
|
|
2088
|
+
opts.ptyEnabled === true
|
|
2089
|
+
);
|
|
2090
|
+
if (handled) return;
|
|
2091
|
+
}
|
|
2092
|
+
if (path === "/workspaces" && req.method === "GET") {
|
|
2093
|
+
try {
|
|
2094
|
+
const config = await loadWorkspacesConfig();
|
|
2095
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2096
|
+
res.end(JSON.stringify(config));
|
|
2097
|
+
} catch (err) {
|
|
2098
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
2099
|
+
res.end(
|
|
2100
|
+
JSON.stringify({
|
|
2101
|
+
error: "workspaces_load_failed",
|
|
2102
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2103
|
+
})
|
|
2104
|
+
);
|
|
2105
|
+
}
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
if (path === "/mcps/imports" && req.method === "GET") {
|
|
2109
|
+
const config = await loadImportedMcps();
|
|
2110
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2111
|
+
res.end(JSON.stringify(config));
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
if (path === "/mcps/imports" && req.method === "POST") {
|
|
2115
|
+
const body = await readJsonBody(req);
|
|
2116
|
+
if (!body || typeof body.sourceMcpId !== "string") {
|
|
2117
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
2118
|
+
res.end(JSON.stringify({ error: "missing_sourceMcpId" }));
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
const discovered = await discoverMcps();
|
|
2122
|
+
const snapshot = discovered.find((d) => d.id === body.sourceMcpId);
|
|
2123
|
+
if (!snapshot) {
|
|
2124
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
2125
|
+
res.end(
|
|
2126
|
+
JSON.stringify({
|
|
2127
|
+
error: "discovered_mcp_not_found",
|
|
2128
|
+
sourceMcpId: body.sourceMcpId
|
|
2129
|
+
})
|
|
2130
|
+
);
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
const cfg = await loadImportedMcps();
|
|
2134
|
+
const next = addImport(cfg, {
|
|
2135
|
+
snapshot,
|
|
2136
|
+
...body.alias ? { alias: body.alias } : {}
|
|
2137
|
+
});
|
|
2138
|
+
await saveImportedMcps(next);
|
|
2139
|
+
res.writeHead(201, { "content-type": "application/json" });
|
|
2140
|
+
res.end(JSON.stringify(next.imports.find((e) => e.id === snapshot.id)));
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const importMatch = path.match(/^\/mcps\/imports\/(.+)$/);
|
|
2144
|
+
if (importMatch && req.method === "DELETE") {
|
|
2145
|
+
const id = decodeURIComponent(importMatch[1] ?? "");
|
|
2146
|
+
const cfg = await loadImportedMcps();
|
|
2147
|
+
if (!cfg.imports.some((e) => e.id === id)) {
|
|
2148
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
2149
|
+
res.end(JSON.stringify({ error: "import_not_found", id }));
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
await saveImportedMcps(removeImport(cfg, id));
|
|
2153
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2154
|
+
res.end(JSON.stringify({ ok: true, id }));
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
if (path === "/mcps/discovered" && req.method === "GET") {
|
|
2158
|
+
try {
|
|
2159
|
+
const mcps = await discoverMcps();
|
|
2160
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2161
|
+
res.end(JSON.stringify({ mcps }));
|
|
2162
|
+
} catch (err) {
|
|
2163
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
2164
|
+
res.end(
|
|
2165
|
+
JSON.stringify({
|
|
2166
|
+
error: "discovery_failed",
|
|
2167
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2168
|
+
})
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
if (path === "/mcps/proxy/status" && req.method === "GET") {
|
|
2174
|
+
if (!opts.mcpProxy) {
|
|
2175
|
+
res.writeHead(501, { "content-type": "application/json" });
|
|
2176
|
+
res.end(JSON.stringify({ error: "mcp_proxy_not_configured" }));
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
try {
|
|
2180
|
+
const imports = await opts.mcpProxy.listAliases();
|
|
2181
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2182
|
+
res.end(JSON.stringify({ imports }));
|
|
2183
|
+
} catch (err) {
|
|
2184
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
2185
|
+
res.end(
|
|
2186
|
+
JSON.stringify({
|
|
2187
|
+
error: "proxy_status_failed",
|
|
2188
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2189
|
+
})
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
const proxyToolsMatch = path.match(/^\/mcps\/proxy\/tools\/(.+)$/);
|
|
2195
|
+
if (proxyToolsMatch && req.method === "GET") {
|
|
2196
|
+
if (!opts.mcpProxy) {
|
|
2197
|
+
res.writeHead(501, { "content-type": "application/json" });
|
|
2198
|
+
res.end(JSON.stringify({ error: "mcp_proxy_not_configured" }));
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
const alias = decodeURIComponent(proxyToolsMatch[1] ?? "");
|
|
2202
|
+
const out = await opts.mcpProxy.listTools(alias);
|
|
2203
|
+
res.writeHead(out.ok ? 200 : 502, {
|
|
2204
|
+
"content-type": "application/json"
|
|
2205
|
+
});
|
|
2206
|
+
res.end(JSON.stringify(out));
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
if (path === "/mcps/proxy/call" && req.method === "POST") {
|
|
2210
|
+
if (!opts.mcpProxy) {
|
|
2211
|
+
res.writeHead(501, { "content-type": "application/json" });
|
|
2212
|
+
res.end(JSON.stringify({ error: "mcp_proxy_not_configured" }));
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
const body = await readJsonBody(req);
|
|
2216
|
+
if (!body || typeof body.alias !== "string" || typeof body.toolName !== "string") {
|
|
2217
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
2218
|
+
res.end(
|
|
2219
|
+
JSON.stringify({
|
|
2220
|
+
error: "invalid_body",
|
|
2221
|
+
message: "expected { alias, toolName, args? }"
|
|
2222
|
+
})
|
|
2223
|
+
);
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
const result = await opts.mcpProxy.callTool(
|
|
2227
|
+
body.alias,
|
|
2228
|
+
body.toolName,
|
|
2229
|
+
body.args ?? {}
|
|
2230
|
+
);
|
|
2231
|
+
res.writeHead(result.ok ? 200 : 502, {
|
|
2232
|
+
"content-type": "application/json"
|
|
2233
|
+
});
|
|
2234
|
+
res.end(JSON.stringify(result));
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
if (path === "/adapters" && req.method === "GET") {
|
|
2238
|
+
if (!opts.listAgentAdapters) {
|
|
2239
|
+
res.writeHead(501, { "content-type": "application/json" });
|
|
2240
|
+
res.end(
|
|
2241
|
+
JSON.stringify({
|
|
2242
|
+
error: "lister_not_configured",
|
|
2243
|
+
message: "Daemon was started without `listAgentAdapters` \u2014 see @agentproto/cli's `listInstalledAdapters` for the canonical impl."
|
|
2244
|
+
})
|
|
2245
|
+
);
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
try {
|
|
2249
|
+
const adapters = await opts.listAgentAdapters();
|
|
2250
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
2251
|
+
res.end(JSON.stringify({ adapters }));
|
|
2252
|
+
} catch (err) {
|
|
2253
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
2254
|
+
res.end(
|
|
2255
|
+
JSON.stringify({
|
|
2256
|
+
error: "list_failed",
|
|
2257
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2258
|
+
})
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
2264
|
+
res.end(JSON.stringify({ error: "not_found", path }));
|
|
2265
|
+
} catch (err) {
|
|
2266
|
+
if (!res.headersSent) {
|
|
2267
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
2268
|
+
res.end(
|
|
2269
|
+
JSON.stringify({
|
|
2270
|
+
error: "internal_error",
|
|
2271
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2272
|
+
})
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
})();
|
|
2277
|
+
});
|
|
2278
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
2279
|
+
server.on("upgrade", (req, socket, head) => {
|
|
2280
|
+
const url = req.url ?? "/";
|
|
2281
|
+
const path = url.split("?")[0] ?? "/";
|
|
2282
|
+
const ptyMatch = path.match(/^\/sessions\/([^/]+)\/pty$/);
|
|
2283
|
+
if (!ptyMatch) {
|
|
2284
|
+
socket.destroy();
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
if (!opts.sessions || opts.ptyEnabled !== true) {
|
|
2288
|
+
rejectUpgrade(socket, 501, "pty_not_configured");
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
const gate = checkSessionsToken(req);
|
|
2292
|
+
if (gate !== "ok") {
|
|
2293
|
+
rejectUpgrade(socket, 401, gate === "missing" ? "missing_token" : "bad_token");
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
const id = ptyMatch[1];
|
|
2297
|
+
if (!id) {
|
|
2298
|
+
rejectUpgrade(socket, 400, "missing_id");
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
const desc = opts.sessions.findByIdOrName(id);
|
|
2302
|
+
if (!desc) {
|
|
2303
|
+
rejectUpgrade(socket, 404, "session_not_found");
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
if (desc.pty !== true) {
|
|
2307
|
+
rejectUpgrade(socket, 400, "session_not_pty");
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
if (desc.status === "exited" || desc.status === "killed" || desc.status === "error") {
|
|
2311
|
+
rejectUpgrade(
|
|
2312
|
+
socket,
|
|
2313
|
+
410,
|
|
2314
|
+
`session_${desc.status}`,
|
|
2315
|
+
`Session ${desc.id}${desc.name ? ` (${desc.name})` : ""} has ${desc.status}. Spawn a fresh one with: agentproto sessions restart ${desc.name ?? desc.id}`
|
|
2316
|
+
);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2320
|
+
handlePtyWebSocket(ws, req, desc.id, opts.sessions);
|
|
2321
|
+
});
|
|
2322
|
+
});
|
|
2323
|
+
const bind = opts.bind ?? "127.0.0.1";
|
|
2324
|
+
await new Promise((resolve8, reject) => {
|
|
2325
|
+
server.once("error", reject);
|
|
2326
|
+
server.listen(opts.port, bind, () => resolve8());
|
|
2327
|
+
});
|
|
2328
|
+
return {
|
|
2329
|
+
url: `http://${bind}:${opts.port}`,
|
|
2330
|
+
async stop() {
|
|
2331
|
+
wss.close();
|
|
2332
|
+
await new Promise((resolve8) => server.close(() => resolve8()));
|
|
2333
|
+
}
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
function rejectUpgrade(socket, status, reason, message) {
|
|
2337
|
+
const reasonText = `${status} ${reason}`;
|
|
2338
|
+
const body = { error: reason };
|
|
2339
|
+
if (message) body.message = message;
|
|
2340
|
+
try {
|
|
2341
|
+
socket.write(
|
|
2342
|
+
`HTTP/1.1 ${reasonText}\r
|
|
2343
|
+
Content-Type: application/json\r
|
|
2344
|
+
Connection: close\r
|
|
2345
|
+
\r
|
|
2346
|
+
` + JSON.stringify(body) + `
|
|
2347
|
+
`
|
|
2348
|
+
);
|
|
2349
|
+
} catch {
|
|
2350
|
+
}
|
|
2351
|
+
socket.destroy();
|
|
2352
|
+
}
|
|
2353
|
+
function handlePtyWebSocket(ws, req, sessionId, registry) {
|
|
2354
|
+
const query = new URLSearchParams(
|
|
2355
|
+
(req.url ?? "").includes("?") ? (req.url ?? "").split("?")[1] : ""
|
|
2356
|
+
);
|
|
2357
|
+
const cols = clampPositiveInt(query.get("cols"), 80);
|
|
2358
|
+
const rows = clampPositiveInt(query.get("rows"), 24);
|
|
2359
|
+
const BACKPRESSURE_BYTES = 256 * 1024;
|
|
2360
|
+
const handle = registry.attachPty(
|
|
2361
|
+
sessionId,
|
|
2362
|
+
{ cols, rows },
|
|
2363
|
+
(chunk) => {
|
|
2364
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
2365
|
+
if (ws.bufferedAmount > BACKPRESSURE_BYTES) return;
|
|
2366
|
+
ws.send(
|
|
2367
|
+
JSON.stringify({ kind: "data", b64: chunk.toString("base64") })
|
|
2368
|
+
);
|
|
2369
|
+
},
|
|
2370
|
+
(evt) => {
|
|
2371
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
2372
|
+
ws.send(
|
|
2373
|
+
JSON.stringify({
|
|
2374
|
+
kind: "exit",
|
|
2375
|
+
exitCode: evt.exitCode,
|
|
2376
|
+
...evt.signal != null ? { signal: evt.signal } : {}
|
|
2377
|
+
})
|
|
2378
|
+
);
|
|
2379
|
+
try {
|
|
2380
|
+
ws.close(1e3, "session exited");
|
|
2381
|
+
} catch {
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
);
|
|
2385
|
+
if (!handle) {
|
|
2386
|
+
try {
|
|
2387
|
+
ws.close(1011, "session not attachable");
|
|
2388
|
+
} catch {
|
|
2389
|
+
}
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
ws.on("message", (raw, isBinary) => {
|
|
2393
|
+
if (isBinary) {
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
let frame;
|
|
2397
|
+
try {
|
|
2398
|
+
frame = JSON.parse(raw.toString("utf8"));
|
|
2399
|
+
} catch {
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
if (!frame || typeof frame !== "object") return;
|
|
2403
|
+
const f = frame;
|
|
2404
|
+
switch (f.kind) {
|
|
2405
|
+
case "input": {
|
|
2406
|
+
if (typeof f.text === "string") {
|
|
2407
|
+
handle.write(f.text);
|
|
2408
|
+
} else if (typeof f.b64 === "string") {
|
|
2409
|
+
try {
|
|
2410
|
+
handle.write(Buffer.from(f.b64, "base64").toString("utf8"));
|
|
2411
|
+
} catch {
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
break;
|
|
2415
|
+
}
|
|
2416
|
+
case "resize": {
|
|
2417
|
+
const c = typeof f.cols === "number" && f.cols > 0 ? Math.floor(f.cols) : null;
|
|
2418
|
+
const r = typeof f.rows === "number" && f.rows > 0 ? Math.floor(f.rows) : null;
|
|
2419
|
+
if (c && r) handle.resize(c, r);
|
|
2420
|
+
break;
|
|
2421
|
+
}
|
|
2422
|
+
case "ping":
|
|
2423
|
+
try {
|
|
2424
|
+
ws.send(JSON.stringify({ kind: "pong" }));
|
|
2425
|
+
} catch {
|
|
2426
|
+
}
|
|
2427
|
+
break;
|
|
2428
|
+
}
|
|
2429
|
+
});
|
|
2430
|
+
ws.on("close", () => {
|
|
2431
|
+
handle.detach();
|
|
2432
|
+
});
|
|
2433
|
+
ws.on("error", () => {
|
|
2434
|
+
handle.detach();
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
function clampPositiveInt(raw, fallback) {
|
|
2438
|
+
if (!raw) return fallback;
|
|
2439
|
+
const n = Number.parseInt(raw, 10);
|
|
2440
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
2441
|
+
}
|
|
2442
|
+
function clampInt(raw, fallback, min, max) {
|
|
2443
|
+
if (!raw) return fallback;
|
|
2444
|
+
const n = Number.parseInt(raw, 10);
|
|
2445
|
+
if (!Number.isFinite(n)) return fallback;
|
|
2446
|
+
if (n < min) return min;
|
|
2447
|
+
if (n > max) return max;
|
|
2448
|
+
return n;
|
|
2449
|
+
}
|
|
2450
|
+
async function handleSessions(req, res, path, registry, resolveAgentAdapter, ptyEnabled = false) {
|
|
2451
|
+
const json = (status, body) => {
|
|
2452
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
2453
|
+
res.end(JSON.stringify(body));
|
|
2454
|
+
};
|
|
2455
|
+
if (path === "/sessions" && req.method === "GET") {
|
|
2456
|
+
json(200, { sessions: registry.list() });
|
|
2457
|
+
return true;
|
|
2458
|
+
}
|
|
2459
|
+
if (path === "/sessions/agent" && req.method === "POST") {
|
|
2460
|
+
if (!resolveAgentAdapter) {
|
|
2461
|
+
json(501, {
|
|
2462
|
+
error: "agent_resolver_not_configured",
|
|
2463
|
+
message: "POST /sessions/agent needs the host to inject `resolveAgentAdapter` (e.g. via @agentproto/cli's resolveAdapter shim)."
|
|
2464
|
+
});
|
|
2465
|
+
return true;
|
|
2466
|
+
}
|
|
2467
|
+
const body = await readJsonBody(req);
|
|
2468
|
+
if (!body || typeof body !== "object") {
|
|
2469
|
+
json(400, { error: "invalid_body" });
|
|
2470
|
+
return true;
|
|
2471
|
+
}
|
|
2472
|
+
const b = body;
|
|
2473
|
+
const adapter = typeof b.adapter === "string" ? b.adapter : "";
|
|
2474
|
+
if (!adapter) {
|
|
2475
|
+
json(400, { error: "missing_adapter" });
|
|
2476
|
+
return true;
|
|
2477
|
+
}
|
|
2478
|
+
let cwd = typeof b.cwd === "string" && b.cwd.length > 0 ? b.cwd : null;
|
|
2479
|
+
let workspaceSlug = typeof b.workspaceSlug === "string" ? b.workspaceSlug : "";
|
|
2480
|
+
if (!cwd) {
|
|
2481
|
+
try {
|
|
2482
|
+
const config = await loadWorkspacesConfig();
|
|
2483
|
+
const ws = workspaceSlug ? findWorkspace(config, workspaceSlug) : getActiveWorkspace(config);
|
|
2484
|
+
if (ws) {
|
|
2485
|
+
cwd = ws.path;
|
|
2486
|
+
workspaceSlug = ws.slug;
|
|
2487
|
+
}
|
|
2488
|
+
} catch {
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
if (!cwd) {
|
|
2492
|
+
cwd = process.cwd();
|
|
2493
|
+
console.warn(
|
|
2494
|
+
`[/sessions/agent] no cwd resolvable for adapter=${adapter} (no body.cwd, no workspaceSlug match in ~/.agentproto/workspaces.json, no active workspace) \u2014 falling back to daemon's cwd ${cwd}`
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
if (!workspaceSlug) workspaceSlug = "default";
|
|
2498
|
+
const resolved = await resolveAgentAdapter(adapter);
|
|
2499
|
+
if (!resolved) {
|
|
2500
|
+
json(404, { error: "adapter_not_found", adapter });
|
|
2501
|
+
return true;
|
|
2502
|
+
}
|
|
2503
|
+
try {
|
|
2504
|
+
const resumeSessionId = typeof b.resumeSessionId === "string" && b.resumeSessionId.length > 0 ? b.resumeSessionId : void 0;
|
|
2505
|
+
const agentSession = await resolved.startSession({
|
|
2506
|
+
cwd,
|
|
2507
|
+
...resumeSessionId ? { resumeSessionId } : {}
|
|
2508
|
+
});
|
|
2509
|
+
const desc = registry.spawnAgent({
|
|
2510
|
+
workspaceSlug,
|
|
2511
|
+
cwd,
|
|
2512
|
+
agentSession,
|
|
2513
|
+
adapterSlug: adapter,
|
|
2514
|
+
...typeof b.prompt === "string" ? { initialPrompt: b.prompt } : {},
|
|
2515
|
+
...typeof b.label === "string" ? { label: b.label } : {},
|
|
2516
|
+
...resolved.commandPreview ? { commandPreview: resolved.commandPreview } : {}
|
|
2517
|
+
});
|
|
2518
|
+
json(201, desc);
|
|
2519
|
+
} catch (err) {
|
|
2520
|
+
json(500, {
|
|
2521
|
+
error: "agent_spawn_failed",
|
|
2522
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
return true;
|
|
2526
|
+
}
|
|
2527
|
+
if (path === "/sessions/terminal" && req.method === "POST") {
|
|
2528
|
+
if (!ptyEnabled) {
|
|
2529
|
+
json(501, {
|
|
2530
|
+
error: "pty_not_configured",
|
|
2531
|
+
message: "POST /sessions/terminal needs the host to inject `spawnPty` into createGateway (node-pty optional dep \u2014 install in @agentproto/cli)."
|
|
2532
|
+
});
|
|
2533
|
+
return true;
|
|
2534
|
+
}
|
|
2535
|
+
const body = await readJsonBody(req);
|
|
2536
|
+
if (!body || typeof body !== "object") {
|
|
2537
|
+
json(400, { error: "invalid_body" });
|
|
2538
|
+
return true;
|
|
2539
|
+
}
|
|
2540
|
+
const b = body;
|
|
2541
|
+
const argv = Array.isArray(b.argv) ? b.argv.filter((v) => typeof v === "string") : [];
|
|
2542
|
+
if (argv.length === 0) {
|
|
2543
|
+
json(400, { error: "missing_argv" });
|
|
2544
|
+
return true;
|
|
2545
|
+
}
|
|
2546
|
+
const cols = typeof b.cols === "number" && b.cols > 0 ? Math.floor(b.cols) : 80;
|
|
2547
|
+
const rows = typeof b.rows === "number" && b.rows > 0 ? Math.floor(b.rows) : 24;
|
|
2548
|
+
let cwd = typeof b.cwd === "string" && b.cwd.length > 0 ? b.cwd : null;
|
|
2549
|
+
let workspaceSlug = typeof b.workspaceSlug === "string" ? b.workspaceSlug : "";
|
|
2550
|
+
if (!cwd) {
|
|
2551
|
+
try {
|
|
2552
|
+
const config = await loadWorkspacesConfig();
|
|
2553
|
+
const ws = workspaceSlug ? findWorkspace(config, workspaceSlug) : getActiveWorkspace(config);
|
|
2554
|
+
if (ws) {
|
|
2555
|
+
cwd = ws.path;
|
|
2556
|
+
workspaceSlug = ws.slug;
|
|
2557
|
+
}
|
|
2558
|
+
} catch {
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
if (!cwd) {
|
|
2562
|
+
cwd = process.cwd();
|
|
2563
|
+
console.warn(
|
|
2564
|
+
`[/sessions/terminal] no cwd resolvable for argv=${argv.join(" ")} \u2014 falling back to daemon's cwd ${cwd}`
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
if (!workspaceSlug) workspaceSlug = "default";
|
|
2568
|
+
try {
|
|
2569
|
+
const desc = registry.spawnPty({
|
|
2570
|
+
argv,
|
|
2571
|
+
cwd,
|
|
2572
|
+
workspaceSlug,
|
|
2573
|
+
cols,
|
|
2574
|
+
rows,
|
|
2575
|
+
...b.env && typeof b.env === "object" ? { env: b.env } : {},
|
|
2576
|
+
...typeof b.name === "string" ? { name: b.name } : {},
|
|
2577
|
+
...typeof b.label === "string" ? { label: b.label } : {}
|
|
2578
|
+
});
|
|
2579
|
+
json(201, desc);
|
|
2580
|
+
} catch (err) {
|
|
2581
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2582
|
+
const status = msg.includes("already in use") ? 409 : msg.includes("no PTY factory") ? 501 : 500;
|
|
2583
|
+
json(status, { error: "pty_spawn_failed", message: msg });
|
|
2584
|
+
}
|
|
2585
|
+
return true;
|
|
2586
|
+
}
|
|
2587
|
+
const promptMatch = path.match(/^\/sessions\/([^/]+)\/prompt$/);
|
|
2588
|
+
if (promptMatch && req.method === "POST") {
|
|
2589
|
+
const id2 = promptMatch[1];
|
|
2590
|
+
if (!id2) return false;
|
|
2591
|
+
const body = await readJsonBody(req);
|
|
2592
|
+
const prompt = body?.prompt;
|
|
2593
|
+
const validPrompt = typeof prompt === "string" && prompt.length > 0 || Array.isArray(prompt) && prompt.length > 0 && prompt.every((b) => b !== null && typeof b === "object") || prompt !== null && typeof prompt === "object" && !Array.isArray(prompt);
|
|
2594
|
+
if (!validPrompt) {
|
|
2595
|
+
json(400, {
|
|
2596
|
+
error: "missing_prompt",
|
|
2597
|
+
message: "Body `prompt` must be a non-empty string, a content block object, or an array of content blocks."
|
|
2598
|
+
});
|
|
2599
|
+
return true;
|
|
2600
|
+
}
|
|
2601
|
+
const reqUrl = req.url ?? "";
|
|
2602
|
+
const queryString = reqUrl.includes("?") ? reqUrl.slice(reqUrl.indexOf("?") + 1) : "";
|
|
2603
|
+
const wait = new URLSearchParams(queryString).get("wait");
|
|
2604
|
+
const fireAndForget = wait === "false" || wait === "0";
|
|
2605
|
+
try {
|
|
2606
|
+
if (fireAndForget) {
|
|
2607
|
+
registry.enqueuePrompt(id2, prompt);
|
|
2608
|
+
json(202, { ok: true, id: id2, queued: true });
|
|
2609
|
+
} else {
|
|
2610
|
+
await registry.sendPrompt(id2, prompt);
|
|
2611
|
+
json(200, { ok: true, id: id2 });
|
|
2612
|
+
}
|
|
2613
|
+
} catch (err) {
|
|
2614
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2615
|
+
const status = msg.includes("no session") ? 404 : msg.includes("not an agent") ? 400 : msg.includes("mid-turn") ? 409 : 500;
|
|
2616
|
+
json(status, { error: "send_prompt_failed", message: msg });
|
|
2617
|
+
}
|
|
2618
|
+
return true;
|
|
2619
|
+
}
|
|
2620
|
+
if (path === "/sessions" && req.method === "POST") {
|
|
2621
|
+
const body = await readJsonBody(req);
|
|
2622
|
+
if (!body || typeof body !== "object") {
|
|
2623
|
+
json(400, { error: "invalid_body" });
|
|
2624
|
+
return true;
|
|
2625
|
+
}
|
|
2626
|
+
const b = body;
|
|
2627
|
+
const argv = Array.isArray(b.argv) ? b.argv.filter((v) => typeof v === "string") : [];
|
|
2628
|
+
if (argv.length === 0) {
|
|
2629
|
+
json(400, { error: "missing_argv" });
|
|
2630
|
+
return true;
|
|
2631
|
+
}
|
|
2632
|
+
try {
|
|
2633
|
+
const desc = registry.spawn({
|
|
2634
|
+
kind: b.kind === "terminal" || b.kind === "agent-cli" || b.kind === "command" ? b.kind : "command",
|
|
2635
|
+
workspaceSlug: typeof b.workspaceSlug === "string" ? b.workspaceSlug : "default",
|
|
2636
|
+
cwd: typeof b.cwd === "string" ? b.cwd : process.cwd(),
|
|
2637
|
+
argv,
|
|
2638
|
+
env: b.env && typeof b.env === "object" ? b.env : void 0,
|
|
2639
|
+
label: typeof b.label === "string" ? b.label : void 0
|
|
2640
|
+
});
|
|
2641
|
+
json(201, desc);
|
|
2642
|
+
} catch (err) {
|
|
2643
|
+
json(500, {
|
|
2644
|
+
error: "spawn_failed",
|
|
2645
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
return true;
|
|
2649
|
+
}
|
|
2650
|
+
const idMatch = path.match(
|
|
2651
|
+
/^\/sessions\/([^/]+)(\/stream|\/kill|\/preview)?$/
|
|
2652
|
+
);
|
|
2653
|
+
if (!idMatch) return false;
|
|
2654
|
+
const [, rawIdOrName, suffix] = idMatch;
|
|
2655
|
+
if (!rawIdOrName) return false;
|
|
2656
|
+
const resolvedDesc = registry.findByIdOrName(rawIdOrName);
|
|
2657
|
+
const id = resolvedDesc?.id ?? rawIdOrName;
|
|
2658
|
+
if (suffix === "/preview" && req.method === "GET") {
|
|
2659
|
+
if (!resolvedDesc) {
|
|
2660
|
+
json(404, { error: "session_not_found", id: rawIdOrName });
|
|
2661
|
+
return true;
|
|
2662
|
+
}
|
|
2663
|
+
const reqUrl = req.url ?? "";
|
|
2664
|
+
const qs = new URLSearchParams(
|
|
2665
|
+
reqUrl.includes("?") ? reqUrl.slice(reqUrl.indexOf("?") + 1) : ""
|
|
2666
|
+
);
|
|
2667
|
+
const lineCap = clampInt(qs.get("lines"), 10, 1, 200);
|
|
2668
|
+
const byteCap = clampInt(qs.get("bytes"), 16 * 1024, 256, 64 * 1024);
|
|
2669
|
+
const lines = [];
|
|
2670
|
+
if (resolvedDesc.pty !== true) {
|
|
2671
|
+
const unsub = registry.attach(id, (line) => {
|
|
2672
|
+
lines.push(line);
|
|
2673
|
+
});
|
|
2674
|
+
if (unsub) unsub();
|
|
2675
|
+
}
|
|
2676
|
+
const bufBytes = registry.readTerminalOutput(id, byteCap);
|
|
2677
|
+
json(200, {
|
|
2678
|
+
id: resolvedDesc.id,
|
|
2679
|
+
lines: lines.slice(-lineCap),
|
|
2680
|
+
bytes: bufBytes ? bufBytes.toString("base64") : null
|
|
2681
|
+
});
|
|
2682
|
+
return true;
|
|
2683
|
+
}
|
|
2684
|
+
if (suffix === "/stream" && req.method === "GET") {
|
|
2685
|
+
if (!resolvedDesc) {
|
|
2686
|
+
json(404, { error: "session_not_found", id: rawIdOrName });
|
|
2687
|
+
return true;
|
|
2688
|
+
}
|
|
2689
|
+
res.writeHead(200, {
|
|
2690
|
+
"content-type": "text/event-stream",
|
|
2691
|
+
"cache-control": "no-cache",
|
|
2692
|
+
connection: "keep-alive"
|
|
2693
|
+
});
|
|
2694
|
+
const ping = setInterval(() => {
|
|
2695
|
+
try {
|
|
2696
|
+
res.write(`: keep-alive
|
|
2697
|
+
|
|
2698
|
+
`);
|
|
2699
|
+
} catch {
|
|
2700
|
+
clearInterval(ping);
|
|
2701
|
+
}
|
|
2702
|
+
}, 25e3);
|
|
2703
|
+
const unsub = registry.attach(id, (line, stream) => {
|
|
2704
|
+
try {
|
|
2705
|
+
res.write(
|
|
2706
|
+
`data: ${JSON.stringify({ line, stream })}
|
|
2707
|
+
|
|
2708
|
+
`
|
|
2709
|
+
);
|
|
2710
|
+
} catch {
|
|
2711
|
+
}
|
|
2712
|
+
});
|
|
2713
|
+
if (!unsub) {
|
|
2714
|
+
clearInterval(ping);
|
|
2715
|
+
res.end();
|
|
2716
|
+
return true;
|
|
2717
|
+
}
|
|
2718
|
+
req.once("close", () => {
|
|
2719
|
+
unsub();
|
|
2720
|
+
clearInterval(ping);
|
|
2721
|
+
});
|
|
2722
|
+
return true;
|
|
2723
|
+
}
|
|
2724
|
+
if (suffix === "/kill" && req.method === "POST") {
|
|
2725
|
+
const ok = registry.kill(id);
|
|
2726
|
+
json(ok ? 200 : 404, { ok, id });
|
|
2727
|
+
return true;
|
|
2728
|
+
}
|
|
2729
|
+
if (!suffix && req.method === "GET") {
|
|
2730
|
+
if (!resolvedDesc) {
|
|
2731
|
+
json(404, { error: "session_not_found", id: rawIdOrName });
|
|
2732
|
+
return true;
|
|
2733
|
+
}
|
|
2734
|
+
json(200, resolvedDesc);
|
|
2735
|
+
return true;
|
|
2736
|
+
}
|
|
2737
|
+
if (!suffix && req.method === "DELETE") {
|
|
2738
|
+
const ok = registry.forget(id);
|
|
2739
|
+
json(ok ? 200 : 404, { ok, id });
|
|
2740
|
+
return true;
|
|
2741
|
+
}
|
|
2742
|
+
return false;
|
|
2743
|
+
}
|
|
2744
|
+
async function readJsonBody(req) {
|
|
2745
|
+
const chunks = [];
|
|
2746
|
+
for await (const chunk of req) {
|
|
2747
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
2748
|
+
}
|
|
2749
|
+
if (chunks.length === 0) return null;
|
|
2750
|
+
try {
|
|
2751
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2752
|
+
} catch {
|
|
2753
|
+
return null;
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
var RESUME_STRATEGIES = {
|
|
2757
|
+
"claude-code": {
|
|
2758
|
+
// Printed by claude on graceful exit when session persistence
|
|
2759
|
+
// is on (default). Example: `claude --resume 0e483f81-1a44-4bec-9667-b37158450296`
|
|
2760
|
+
outputHint: /claude\s+--resume\s+([0-9a-f-]{8,})/i,
|
|
2761
|
+
storeAs: "claudeResumeId",
|
|
2762
|
+
fsProbe: probeMtimeLatestJsonl(".claude/projects"),
|
|
2763
|
+
spawnArgs: (id) => ["claude", "--resume", id]
|
|
2764
|
+
}
|
|
2765
|
+
// Stubs for other shipped adapters — fill in as we learn each
|
|
2766
|
+
// provider's resume mechanism. Today they fall back to ACP-level
|
|
2767
|
+
// resume (whatever the agent-cli runtime supports) or fresh spawn.
|
|
2768
|
+
//
|
|
2769
|
+
// hermes: { storeAs: "hermesResumeId", ... }
|
|
2770
|
+
// codex: { storeAs: "codexResumeId", ... }
|
|
2771
|
+
// openclaw: { storeAs: "openClawResumeId", ... }
|
|
2772
|
+
// opencode: { storeAs: "openCodeResumeId", ... }
|
|
2773
|
+
};
|
|
2774
|
+
function probeMtimeLatestJsonl(storeRel) {
|
|
2775
|
+
return async (cwd, prevStartedAt) => {
|
|
2776
|
+
const encoded = cwd.replace(/\//g, "-");
|
|
2777
|
+
const dir = resolve(homedir(), storeRel, encoded);
|
|
2778
|
+
let entries;
|
|
2779
|
+
try {
|
|
2780
|
+
entries = await promises.readdir(dir);
|
|
2781
|
+
} catch {
|
|
2782
|
+
return null;
|
|
2783
|
+
}
|
|
2784
|
+
const jsonl = entries.filter((e) => e.endsWith(".jsonl"));
|
|
2785
|
+
if (jsonl.length === 0) return null;
|
|
2786
|
+
const startedAtMs = Date.parse(prevStartedAt);
|
|
2787
|
+
const candidates = [];
|
|
2788
|
+
for (const f of jsonl) {
|
|
2789
|
+
try {
|
|
2790
|
+
const st = await promises.stat(join(dir, f));
|
|
2791
|
+
if (!Number.isFinite(startedAtMs) || st.mtimeMs >= startedAtMs - 1e3) {
|
|
2792
|
+
candidates.push({ name: f, mtime: st.mtimeMs });
|
|
2793
|
+
}
|
|
2794
|
+
} catch {
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
if (candidates.length === 0) return null;
|
|
2798
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
2799
|
+
return candidates[0].name.replace(/\.jsonl$/, "");
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
var SESSIONS_FILE_PATH = () => resolve(homedir(), ".agentproto", "sessions.json");
|
|
2803
|
+
var RECENT_LINES_CAP = 500;
|
|
2804
|
+
var RECENT_BYTES_CAP = 64 * 1024;
|
|
2805
|
+
var PERSIST_DEBOUNCE_MS = 1500;
|
|
2806
|
+
var HISTORY_CAP = 200;
|
|
2807
|
+
function stripAnsiCodes(s) {
|
|
2808
|
+
return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
|
|
2809
|
+
}
|
|
2810
|
+
function createSessionsRegistry(opts) {
|
|
2811
|
+
const persistPath = opts?.persistPath ?? SESSIONS_FILE_PATH();
|
|
2812
|
+
const persist = opts?.persist ?? true;
|
|
2813
|
+
const ptyFactory = opts?.spawnPty;
|
|
2814
|
+
const resumeAgent = opts?.resumeAgent;
|
|
2815
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
2816
|
+
let persistTimer = null;
|
|
2817
|
+
let nextSubId = 1;
|
|
2818
|
+
let shutdownDone = false;
|
|
2819
|
+
if (persist) {
|
|
2820
|
+
loadHistorySnapshot(persistPath, sessions);
|
|
2821
|
+
}
|
|
2822
|
+
const onProcessExit = () => {
|
|
2823
|
+
shutdownImpl();
|
|
2824
|
+
};
|
|
2825
|
+
if (persist) {
|
|
2826
|
+
process.on("exit", onProcessExit);
|
|
2827
|
+
}
|
|
2828
|
+
const schedulePersist = () => {
|
|
2829
|
+
if (!persist) return;
|
|
2830
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
2831
|
+
persistTimer = setTimeout(() => {
|
|
2832
|
+
void persistSnapshot();
|
|
2833
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
2834
|
+
};
|
|
2835
|
+
const persistSnapshot = async () => {
|
|
2836
|
+
try {
|
|
2837
|
+
const snapshot = {
|
|
2838
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2839
|
+
sessions: Array.from(sessions.values()).map((s) => s.desc)
|
|
2840
|
+
};
|
|
2841
|
+
await promises.mkdir(dirname(persistPath), { recursive: true });
|
|
2842
|
+
await promises.writeFile(persistPath, JSON.stringify(snapshot, null, 2) + "\n");
|
|
2843
|
+
} catch (err) {
|
|
2844
|
+
console.warn(
|
|
2845
|
+
`[sessions] persist failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
};
|
|
2849
|
+
const appendLine = (rt, line, stream) => {
|
|
2850
|
+
rt.recentLines.push(line);
|
|
2851
|
+
if (rt.recentLines.length > RECENT_LINES_CAP) {
|
|
2852
|
+
rt.recentLines.splice(0, rt.recentLines.length - RECENT_LINES_CAP);
|
|
2853
|
+
}
|
|
2854
|
+
rt.desc.lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2855
|
+
sniffResumeHints(rt, line);
|
|
2856
|
+
rt.emitter.emit("line", { line, stream });
|
|
2857
|
+
};
|
|
2858
|
+
const sniffResumeHints = (rt, line) => {
|
|
2859
|
+
if (rt.desc.kind !== "agent-cli") return;
|
|
2860
|
+
const strategy = rt.desc.adapterSlug ? RESUME_STRATEGIES[rt.desc.adapterSlug] : void 0;
|
|
2861
|
+
if (!strategy?.outputHint) return;
|
|
2862
|
+
const plain = stripAnsiCodes(line);
|
|
2863
|
+
const m = plain.match(strategy.outputHint);
|
|
2864
|
+
if (m && m[1]) {
|
|
2865
|
+
rt.desc.resumeMetadata = {
|
|
2866
|
+
...rt.desc.resumeMetadata ?? {},
|
|
2867
|
+
[strategy.storeAs]: m[1]
|
|
2868
|
+
};
|
|
2869
|
+
schedulePersist();
|
|
2870
|
+
}
|
|
2871
|
+
};
|
|
2872
|
+
const appendBytes = (rt, chunk) => {
|
|
2873
|
+
rt.recentBytes.push(chunk);
|
|
2874
|
+
rt.recentBytesSize += chunk.byteLength;
|
|
2875
|
+
while (rt.recentBytesSize > RECENT_BYTES_CAP && rt.recentBytes.length > 1) {
|
|
2876
|
+
const dropped = rt.recentBytes.shift();
|
|
2877
|
+
if (dropped) rt.recentBytesSize -= dropped.byteLength;
|
|
2878
|
+
}
|
|
2879
|
+
rt.desc.lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2880
|
+
rt.emitter.emit("data", chunk);
|
|
2881
|
+
};
|
|
2882
|
+
const reconcilePtySize = (rt) => {
|
|
2883
|
+
if (!rt.pty || !rt.ptySubscribers || rt.ptySubscribers.size === 0) return;
|
|
2884
|
+
let cols = Infinity;
|
|
2885
|
+
let rows = Infinity;
|
|
2886
|
+
for (const dim of rt.ptySubscribers.values()) {
|
|
2887
|
+
if (dim.cols < cols) cols = dim.cols;
|
|
2888
|
+
if (dim.rows < rows) rows = dim.rows;
|
|
2889
|
+
}
|
|
2890
|
+
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
|
2891
|
+
try {
|
|
2892
|
+
rt.pty.resize(cols, rows);
|
|
2893
|
+
} catch {
|
|
2894
|
+
}
|
|
2895
|
+
};
|
|
2896
|
+
const projectEvent = (rt, evt) => {
|
|
2897
|
+
switch (evt.kind) {
|
|
2898
|
+
case "text-delta":
|
|
2899
|
+
if (evt.text) {
|
|
2900
|
+
const combined = rt.textBuf + evt.text;
|
|
2901
|
+
const lines = combined.split(/\r?\n/);
|
|
2902
|
+
rt.textBuf = lines.pop() ?? "";
|
|
2903
|
+
for (const line of lines) appendLine(rt, line, "stdout");
|
|
2904
|
+
}
|
|
2905
|
+
break;
|
|
2906
|
+
case "thought":
|
|
2907
|
+
if (evt.text) {
|
|
2908
|
+
const combined = rt.thoughtBuf + evt.text;
|
|
2909
|
+
const lines = combined.split(/\r?\n/);
|
|
2910
|
+
rt.thoughtBuf = lines.pop() ?? "";
|
|
2911
|
+
for (const line of lines) {
|
|
2912
|
+
if (line.trim()) {
|
|
2913
|
+
appendLine(rt, `\x1B[2m[thought] ${line}\x1B[0m`, "stdout");
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
break;
|
|
2918
|
+
case "tool-call":
|
|
2919
|
+
appendLine(
|
|
2920
|
+
rt,
|
|
2921
|
+
`\x1B[36m[tool] ${evt.toolName ?? "?"}\x1B[0m`,
|
|
2922
|
+
"stdout"
|
|
2923
|
+
);
|
|
2924
|
+
break;
|
|
2925
|
+
case "tool-result":
|
|
2926
|
+
if (evt.isError)
|
|
2927
|
+
appendLine(rt, `\x1B[31m[tool-error]\x1B[0m`, "stderr");
|
|
2928
|
+
break;
|
|
2929
|
+
case "agent-prompt":
|
|
2930
|
+
appendLine(rt, `\x1B[33m[awaiting input]\x1B[0m`, "stdout");
|
|
2931
|
+
break;
|
|
2932
|
+
case "turn-end": {
|
|
2933
|
+
if (rt.thoughtBuf.trim()) {
|
|
2934
|
+
appendLine(
|
|
2935
|
+
rt,
|
|
2936
|
+
`\x1B[2m[thought] ${rt.thoughtBuf}\x1B[0m`,
|
|
2937
|
+
"stdout"
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
rt.thoughtBuf = "";
|
|
2941
|
+
if (rt.textBuf) {
|
|
2942
|
+
appendLine(rt, rt.textBuf, "stdout");
|
|
2943
|
+
rt.textBuf = "";
|
|
2944
|
+
}
|
|
2945
|
+
appendLine(
|
|
2946
|
+
rt,
|
|
2947
|
+
`\x1B[2m\u2500\u2500 turn-end (${evt.reason ?? "completed"}) \u2500\u2500\x1B[0m`,
|
|
2948
|
+
"stdout"
|
|
2949
|
+
);
|
|
2950
|
+
break;
|
|
2951
|
+
}
|
|
2952
|
+
case "error": {
|
|
2953
|
+
const code = typeof evt.error?.code === "number" ? ` (code ${evt.error.code})` : "";
|
|
2954
|
+
appendLine(
|
|
2955
|
+
rt,
|
|
2956
|
+
`\x1B[31m[error]${code} ${evt.error?.message ?? "unknown"}\x1B[0m`,
|
|
2957
|
+
"stderr"
|
|
2958
|
+
);
|
|
2959
|
+
const data = evt.error?.data;
|
|
2960
|
+
if (data && typeof data === "object" && typeof data.stderr === "string") {
|
|
2961
|
+
for (const line of data.stderr.split(/\r?\n/)) {
|
|
2962
|
+
if (line) appendLine(rt, `\x1B[2m ${line}\x1B[0m`, "stderr");
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
break;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
const validateAgentTurn = (id, caller) => {
|
|
2970
|
+
const rt = sessions.get(id);
|
|
2971
|
+
if (!rt) throw new Error(`${caller}: no session "${id}"`);
|
|
2972
|
+
if (!rt.agentSession) {
|
|
2973
|
+
throw new Error(
|
|
2974
|
+
`${caller}: session "${id}" is not an agent session (kind=${rt.desc.kind})`
|
|
2975
|
+
);
|
|
2976
|
+
}
|
|
2977
|
+
if (rt.busy) {
|
|
2978
|
+
throw new Error(
|
|
2979
|
+
`${caller}: session "${id}" is mid-turn \u2014 wait for it to finish or cancel`
|
|
2980
|
+
);
|
|
2981
|
+
}
|
|
2982
|
+
return rt;
|
|
2983
|
+
};
|
|
2984
|
+
const maybeResumeAgent = async (rt) => {
|
|
2985
|
+
if (rt.agentSession) return;
|
|
2986
|
+
if (!resumeAgent) return;
|
|
2987
|
+
if (rt.desc.kind !== "agent-cli") return;
|
|
2988
|
+
const adapterSlug = rt.desc.adapterSlug ?? rt.adapterSlug;
|
|
2989
|
+
const adapterSessionId = rt.desc.adapterSessionId;
|
|
2990
|
+
const cwd = rt.desc.cwd;
|
|
2991
|
+
if (!adapterSlug || !adapterSessionId || !cwd) return;
|
|
2992
|
+
if (rt.resumePromise) {
|
|
2993
|
+
await rt.resumePromise;
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
rt.resumePromise = (async () => {
|
|
2997
|
+
appendLine(
|
|
2998
|
+
rt,
|
|
2999
|
+
`\u2500\u2500 resuming ${adapterSlug} session ${adapterSessionId} \u2500\u2500`,
|
|
3000
|
+
"stdout"
|
|
3001
|
+
);
|
|
3002
|
+
try {
|
|
3003
|
+
const fresh = await resumeAgent({
|
|
3004
|
+
adapterSlug,
|
|
3005
|
+
cwd,
|
|
3006
|
+
resumeSessionId: adapterSessionId
|
|
3007
|
+
});
|
|
3008
|
+
if (!fresh) {
|
|
3009
|
+
appendLine(
|
|
3010
|
+
rt,
|
|
3011
|
+
`[error] resume failed: adapter '${adapterSlug}' returned null`,
|
|
3012
|
+
"stderr"
|
|
3013
|
+
);
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
rt.agentSession = fresh;
|
|
3017
|
+
rt.adapterSlug = adapterSlug;
|
|
3018
|
+
rt.desc.adapterSessionId = fresh.sessionId;
|
|
3019
|
+
if (rt.desc.status !== "running") {
|
|
3020
|
+
rt.desc.status = "running";
|
|
3021
|
+
delete rt.desc.endedAt;
|
|
3022
|
+
delete rt.desc.exitCode;
|
|
3023
|
+
rt.emitter.emit("status", rt.desc.status);
|
|
3024
|
+
}
|
|
3025
|
+
schedulePersist();
|
|
3026
|
+
} catch (err) {
|
|
3027
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3028
|
+
appendLine(rt, `[error] resume failed: ${msg}`, "stderr");
|
|
3029
|
+
}
|
|
3030
|
+
})();
|
|
3031
|
+
try {
|
|
3032
|
+
await rt.resumePromise;
|
|
3033
|
+
} finally {
|
|
3034
|
+
rt.resumePromise = void 0;
|
|
3035
|
+
}
|
|
3036
|
+
};
|
|
3037
|
+
const runAgentTurn = async (rt, message) => {
|
|
3038
|
+
if (!rt.agentSession) {
|
|
3039
|
+
throw new Error("runAgentTurn: session has no agentSession");
|
|
3040
|
+
}
|
|
3041
|
+
rt.busy = true;
|
|
3042
|
+
try {
|
|
3043
|
+
appendLine(
|
|
3044
|
+
rt,
|
|
3045
|
+
`\x1B[2m\u2500\u2500 \u25B6 ${typeof message === "string" ? message : JSON.stringify(message)} \u2500\u2500\x1B[0m`,
|
|
3046
|
+
"stdout"
|
|
3047
|
+
);
|
|
3048
|
+
const wrapped = typeof message === "string" ? { type: "text", text: message } : message;
|
|
3049
|
+
for await (const evt of rt.agentSession.send(wrapped)) {
|
|
3050
|
+
projectEvent(rt, evt);
|
|
3051
|
+
}
|
|
3052
|
+
} catch (err) {
|
|
3053
|
+
rt.desc.status = "error";
|
|
3054
|
+
rt.desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3055
|
+
appendLine(
|
|
3056
|
+
rt,
|
|
3057
|
+
`[turn error] ${err instanceof Error ? err.message : String(err)}`,
|
|
3058
|
+
"stderr"
|
|
3059
|
+
);
|
|
3060
|
+
schedulePersist();
|
|
3061
|
+
} finally {
|
|
3062
|
+
rt.busy = false;
|
|
3063
|
+
}
|
|
3064
|
+
};
|
|
3065
|
+
const wireOutputStreams = (rt) => {
|
|
3066
|
+
const onChunk = (stream) => (chunk) => {
|
|
3067
|
+
const text3 = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
3068
|
+
for (const line of text3.split(/\r?\n/)) {
|
|
3069
|
+
if (line.length > 0) appendLine(rt, line, stream);
|
|
3070
|
+
}
|
|
3071
|
+
};
|
|
3072
|
+
if (!rt.child) return;
|
|
3073
|
+
rt.child.stdout?.on("data", onChunk("stdout"));
|
|
3074
|
+
rt.child.stderr?.on("data", onChunk("stderr"));
|
|
3075
|
+
};
|
|
3076
|
+
return {
|
|
3077
|
+
spawn(input) {
|
|
3078
|
+
const id = input.id ?? `sess_${randomUUID().slice(0, 8)}`;
|
|
3079
|
+
if (sessions.has(id)) {
|
|
3080
|
+
throw new Error(`sessions.spawn: id "${id}" already in use`);
|
|
3081
|
+
}
|
|
3082
|
+
const env = { ...process.env, ...input.env ?? {} };
|
|
3083
|
+
delete env.NODE_OPTIONS;
|
|
3084
|
+
const [bin, ...args] = input.argv;
|
|
3085
|
+
if (!bin) throw new Error("sessions.spawn: argv must include a binary");
|
|
3086
|
+
const stdinMode = input.keepStdin ? "pipe" : "ignore";
|
|
3087
|
+
const child = spawn(bin, args, {
|
|
3088
|
+
cwd: input.cwd,
|
|
3089
|
+
env,
|
|
3090
|
+
stdio: [stdinMode, "pipe", "pipe"]
|
|
3091
|
+
});
|
|
3092
|
+
const desc = {
|
|
3093
|
+
id,
|
|
3094
|
+
kind: input.kind,
|
|
3095
|
+
workspaceSlug: input.workspaceSlug,
|
|
3096
|
+
command: [bin, ...args].map(quoteArg).join(" "),
|
|
3097
|
+
pid: child.pid ?? null,
|
|
3098
|
+
status: "starting",
|
|
3099
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3100
|
+
argv: input.argv,
|
|
3101
|
+
cwd: input.cwd,
|
|
3102
|
+
...input.label ? { label: input.label } : {}
|
|
3103
|
+
};
|
|
3104
|
+
const rt = {
|
|
3105
|
+
desc,
|
|
3106
|
+
child,
|
|
3107
|
+
recentLines: [],
|
|
3108
|
+
recentBytes: [],
|
|
3109
|
+
recentBytesSize: 0,
|
|
3110
|
+
emitter: new EventEmitter(),
|
|
3111
|
+
busy: false,
|
|
3112
|
+
textBuf: "",
|
|
3113
|
+
thoughtBuf: ""
|
|
3114
|
+
};
|
|
3115
|
+
rt.emitter.setMaxListeners(50);
|
|
3116
|
+
sessions.set(id, rt);
|
|
3117
|
+
wireOutputStreams(rt);
|
|
3118
|
+
child.once("spawn", () => {
|
|
3119
|
+
desc.status = "running";
|
|
3120
|
+
rt.emitter.emit("status", desc.status);
|
|
3121
|
+
schedulePersist();
|
|
3122
|
+
});
|
|
3123
|
+
child.once("error", (err) => {
|
|
3124
|
+
desc.status = "error";
|
|
3125
|
+
desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3126
|
+
appendLine(rt, `[spawn error] ${err.message}`, "stderr");
|
|
3127
|
+
rt.emitter.emit("status", desc.status);
|
|
3128
|
+
schedulePersist();
|
|
3129
|
+
});
|
|
3130
|
+
child.once("exit", (code, signal) => {
|
|
3131
|
+
if (signal && desc.status !== "killed") desc.status = "killed";
|
|
3132
|
+
else if (desc.status === "running" || desc.status === "starting") {
|
|
3133
|
+
desc.status = "exited";
|
|
3134
|
+
}
|
|
3135
|
+
desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3136
|
+
if (typeof code === "number") desc.exitCode = code;
|
|
3137
|
+
rt.emitter.emit("status", desc.status);
|
|
3138
|
+
schedulePersist();
|
|
3139
|
+
});
|
|
3140
|
+
schedulePersist();
|
|
3141
|
+
return desc;
|
|
3142
|
+
},
|
|
3143
|
+
register(input) {
|
|
3144
|
+
if (sessions.has(input.id)) {
|
|
3145
|
+
const existing = sessions.get(input.id);
|
|
3146
|
+
if (existing) return existing.desc;
|
|
3147
|
+
}
|
|
3148
|
+
const desc = {
|
|
3149
|
+
id: input.id,
|
|
3150
|
+
kind: input.kind ?? "command",
|
|
3151
|
+
workspaceSlug: input.workspaceSlug,
|
|
3152
|
+
command: input.command,
|
|
3153
|
+
pid: input.child.pid ?? null,
|
|
3154
|
+
status: input.child.killed ? "killed" : "running",
|
|
3155
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3156
|
+
...input.label ? { label: input.label } : {}
|
|
3157
|
+
};
|
|
3158
|
+
const rt = {
|
|
3159
|
+
desc,
|
|
3160
|
+
child: input.child,
|
|
3161
|
+
recentLines: [],
|
|
3162
|
+
recentBytes: [],
|
|
3163
|
+
recentBytesSize: 0,
|
|
3164
|
+
emitter: new EventEmitter(),
|
|
3165
|
+
busy: false,
|
|
3166
|
+
textBuf: "",
|
|
3167
|
+
thoughtBuf: ""
|
|
3168
|
+
};
|
|
3169
|
+
rt.emitter.setMaxListeners(50);
|
|
3170
|
+
sessions.set(input.id, rt);
|
|
3171
|
+
wireOutputStreams(rt);
|
|
3172
|
+
input.child.once("exit", (code, signal) => {
|
|
3173
|
+
if (signal && desc.status !== "killed") desc.status = "killed";
|
|
3174
|
+
else if (desc.status === "running") desc.status = "exited";
|
|
3175
|
+
desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3176
|
+
if (typeof code === "number") desc.exitCode = code;
|
|
3177
|
+
rt.emitter.emit("status", desc.status);
|
|
3178
|
+
schedulePersist();
|
|
3179
|
+
});
|
|
3180
|
+
input.child.once("error", (err) => {
|
|
3181
|
+
desc.status = "error";
|
|
3182
|
+
desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3183
|
+
appendLine(rt, `[child error] ${err.message}`, "stderr");
|
|
3184
|
+
rt.emitter.emit("status", desc.status);
|
|
3185
|
+
schedulePersist();
|
|
3186
|
+
});
|
|
3187
|
+
schedulePersist();
|
|
3188
|
+
return desc;
|
|
3189
|
+
},
|
|
3190
|
+
spawnAgent(input) {
|
|
3191
|
+
const id = `sess_${randomUUID().slice(0, 8)}`;
|
|
3192
|
+
const desc = {
|
|
3193
|
+
id,
|
|
3194
|
+
kind: "agent-cli",
|
|
3195
|
+
workspaceSlug: input.workspaceSlug,
|
|
3196
|
+
command: input.commandPreview ?? `${input.adapterSlug} (agent)`,
|
|
3197
|
+
pid: null,
|
|
3198
|
+
status: "running",
|
|
3199
|
+
// driver already started the session
|
|
3200
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3201
|
+
cwd: input.cwd,
|
|
3202
|
+
adapterSlug: input.adapterSlug,
|
|
3203
|
+
// ACP-level session id — sticks across daemon restart so
|
|
3204
|
+
// `agentproto sessions restart <id>` can pass it as
|
|
3205
|
+
// `resumeSessionId` and the adapter reattaches to the prior
|
|
3206
|
+
// conversation rather than starting blank.
|
|
3207
|
+
adapterSessionId: input.agentSession.sessionId,
|
|
3208
|
+
...input.label ? { label: input.label } : {}
|
|
3209
|
+
};
|
|
3210
|
+
const rt = {
|
|
3211
|
+
desc,
|
|
3212
|
+
agentSession: input.agentSession,
|
|
3213
|
+
adapterSlug: input.adapterSlug,
|
|
3214
|
+
recentLines: [],
|
|
3215
|
+
recentBytes: [],
|
|
3216
|
+
recentBytesSize: 0,
|
|
3217
|
+
emitter: new EventEmitter(),
|
|
3218
|
+
busy: false,
|
|
3219
|
+
textBuf: "",
|
|
3220
|
+
thoughtBuf: ""
|
|
3221
|
+
};
|
|
3222
|
+
rt.emitter.setMaxListeners(50);
|
|
3223
|
+
sessions.set(id, rt);
|
|
3224
|
+
appendLine(
|
|
3225
|
+
rt,
|
|
3226
|
+
`\u2500\u2500 ${input.adapterSlug} agent session ${input.agentSession.sessionId} (cwd ${input.cwd}) \u2500\u2500`,
|
|
3227
|
+
"stdout"
|
|
3228
|
+
);
|
|
3229
|
+
schedulePersist();
|
|
3230
|
+
if (input.initialPrompt) {
|
|
3231
|
+
void runAgentTurn(rt, input.initialPrompt).catch((err) => {
|
|
3232
|
+
appendLine(
|
|
3233
|
+
rt,
|
|
3234
|
+
`[turn error] ${err instanceof Error ? err.message : String(err)}`,
|
|
3235
|
+
"stderr"
|
|
3236
|
+
);
|
|
3237
|
+
});
|
|
3238
|
+
}
|
|
3239
|
+
return desc;
|
|
3240
|
+
},
|
|
3241
|
+
spawnPty(input) {
|
|
3242
|
+
if (!ptyFactory) {
|
|
3243
|
+
throw new Error(
|
|
3244
|
+
"sessions.spawnPty: no PTY factory configured (node-pty optional dep missing in the daemon)"
|
|
3245
|
+
);
|
|
3246
|
+
}
|
|
3247
|
+
const id = `sess_${randomUUID().slice(0, 8)}`;
|
|
3248
|
+
if (input.name) {
|
|
3249
|
+
for (const rt2 of sessions.values()) {
|
|
3250
|
+
if (rt2.desc.name !== input.name) continue;
|
|
3251
|
+
const status = rt2.desc.status;
|
|
3252
|
+
if (status === "running" || status === "starting") {
|
|
3253
|
+
throw new Error(
|
|
3254
|
+
`sessions.spawnPty: name "${input.name}" already in use by ${rt2.desc.id} (status=${status})`
|
|
3255
|
+
);
|
|
3256
|
+
}
|
|
3257
|
+
rt2.desc.name = void 0;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
const [bin, ...args] = input.argv;
|
|
3261
|
+
if (!bin) {
|
|
3262
|
+
throw new Error("sessions.spawnPty: argv must include a binary");
|
|
3263
|
+
}
|
|
3264
|
+
const env = { ...process.env, ...input.env ?? {} };
|
|
3265
|
+
delete env.NODE_OPTIONS;
|
|
3266
|
+
const pty = ptyFactory({
|
|
3267
|
+
command: bin,
|
|
3268
|
+
args,
|
|
3269
|
+
cwd: input.cwd,
|
|
3270
|
+
env,
|
|
3271
|
+
cols: input.cols,
|
|
3272
|
+
rows: input.rows
|
|
3273
|
+
});
|
|
3274
|
+
const desc = {
|
|
3275
|
+
id,
|
|
3276
|
+
kind: "terminal",
|
|
3277
|
+
workspaceSlug: input.workspaceSlug,
|
|
3278
|
+
command: [bin, ...args].map(quoteArg).join(" "),
|
|
3279
|
+
pid: pty.pid,
|
|
3280
|
+
status: "running",
|
|
3281
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3282
|
+
pty: true,
|
|
3283
|
+
argv: input.argv,
|
|
3284
|
+
cwd: input.cwd,
|
|
3285
|
+
...input.name ? { name: input.name } : {},
|
|
3286
|
+
...input.label ? { label: input.label } : {}
|
|
3287
|
+
};
|
|
3288
|
+
const rt = {
|
|
3289
|
+
desc,
|
|
3290
|
+
pty,
|
|
3291
|
+
ptySubscribers: /* @__PURE__ */ new Map(),
|
|
3292
|
+
recentLines: [],
|
|
3293
|
+
recentBytes: [],
|
|
3294
|
+
recentBytesSize: 0,
|
|
3295
|
+
emitter: new EventEmitter(),
|
|
3296
|
+
busy: false,
|
|
3297
|
+
textBuf: "",
|
|
3298
|
+
thoughtBuf: ""
|
|
3299
|
+
};
|
|
3300
|
+
rt.emitter.setMaxListeners(50);
|
|
3301
|
+
sessions.set(id, rt);
|
|
3302
|
+
pty.onData((chunk) => {
|
|
3303
|
+
appendBytes(rt, Buffer.from(chunk, "utf8"));
|
|
3304
|
+
});
|
|
3305
|
+
pty.onExit((evt) => {
|
|
3306
|
+
if (rt.desc.status !== "killed") {
|
|
3307
|
+
rt.desc.status = "exited";
|
|
3308
|
+
}
|
|
3309
|
+
rt.desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3310
|
+
if (typeof evt.exitCode === "number") rt.desc.exitCode = evt.exitCode;
|
|
3311
|
+
rt.emitter.emit("exit", evt);
|
|
3312
|
+
rt.emitter.emit("status", rt.desc.status);
|
|
3313
|
+
schedulePersist();
|
|
3314
|
+
});
|
|
3315
|
+
schedulePersist();
|
|
3316
|
+
return desc;
|
|
3317
|
+
},
|
|
3318
|
+
async sendPrompt(id, message) {
|
|
3319
|
+
const rtPre = sessions.get(id);
|
|
3320
|
+
if (rtPre) await maybeResumeAgent(rtPre);
|
|
3321
|
+
const rt = validateAgentTurn(id, "sendPrompt");
|
|
3322
|
+
await runAgentTurn(rt, message);
|
|
3323
|
+
},
|
|
3324
|
+
enqueuePrompt(id, message) {
|
|
3325
|
+
const rtPre = sessions.get(id);
|
|
3326
|
+
if (!rtPre) {
|
|
3327
|
+
throw new Error(`enqueuePrompt: no session "${id}"`);
|
|
3328
|
+
}
|
|
3329
|
+
if (rtPre.desc.kind !== "agent-cli") {
|
|
3330
|
+
throw new Error(
|
|
3331
|
+
`enqueuePrompt: session "${id}" is not an agent session (kind=${rtPre.desc.kind})`
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
3334
|
+
if (rtPre.busy) {
|
|
3335
|
+
throw new Error(
|
|
3336
|
+
`enqueuePrompt: session "${id}" is mid-turn \u2014 wait for it to finish or cancel`
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3339
|
+
void (async () => {
|
|
3340
|
+
try {
|
|
3341
|
+
await maybeResumeAgent(rtPre);
|
|
3342
|
+
const rt = validateAgentTurn(id, "enqueuePrompt");
|
|
3343
|
+
await runAgentTurn(rt, message);
|
|
3344
|
+
} catch (err) {
|
|
3345
|
+
appendLine(
|
|
3346
|
+
rtPre,
|
|
3347
|
+
`[error] ${err instanceof Error ? err.message : String(err)}`,
|
|
3348
|
+
"stderr"
|
|
3349
|
+
);
|
|
3350
|
+
}
|
|
3351
|
+
})();
|
|
3352
|
+
},
|
|
3353
|
+
list() {
|
|
3354
|
+
return Array.from(sessions.values()).map((s) => s.desc).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
3355
|
+
},
|
|
3356
|
+
get(id) {
|
|
3357
|
+
return sessions.get(id)?.desc;
|
|
3358
|
+
},
|
|
3359
|
+
attach(id, onLine) {
|
|
3360
|
+
const rt = sessions.get(id);
|
|
3361
|
+
if (!rt) return null;
|
|
3362
|
+
for (const line of rt.recentLines) onLine(line, "stdout");
|
|
3363
|
+
const handler = (evt) => onLine(evt.line, evt.stream);
|
|
3364
|
+
rt.emitter.on("line", handler);
|
|
3365
|
+
return () => rt.emitter.off("line", handler);
|
|
3366
|
+
},
|
|
3367
|
+
attachPty(id, initial, onData, onExit) {
|
|
3368
|
+
const rt = sessions.get(id);
|
|
3369
|
+
if (!rt || !rt.pty || !rt.ptySubscribers) return null;
|
|
3370
|
+
for (const chunk of rt.recentBytes) onData(chunk);
|
|
3371
|
+
const subId = nextSubId++;
|
|
3372
|
+
rt.ptySubscribers.set(subId, { cols: initial.cols, rows: initial.rows });
|
|
3373
|
+
reconcilePtySize(rt);
|
|
3374
|
+
const dataHandler = (chunk) => onData(chunk);
|
|
3375
|
+
const exitHandler = (evt) => onExit(evt);
|
|
3376
|
+
rt.emitter.on("data", dataHandler);
|
|
3377
|
+
rt.emitter.once("exit", exitHandler);
|
|
3378
|
+
return {
|
|
3379
|
+
write(data) {
|
|
3380
|
+
if (!rt.pty) return;
|
|
3381
|
+
try {
|
|
3382
|
+
rt.pty.write(data);
|
|
3383
|
+
} catch {
|
|
3384
|
+
}
|
|
3385
|
+
},
|
|
3386
|
+
resize(cols, rows) {
|
|
3387
|
+
if (!rt.ptySubscribers) return;
|
|
3388
|
+
rt.ptySubscribers.set(subId, { cols, rows });
|
|
3389
|
+
reconcilePtySize(rt);
|
|
3390
|
+
},
|
|
3391
|
+
detach() {
|
|
3392
|
+
rt.emitter.off("data", dataHandler);
|
|
3393
|
+
rt.emitter.off("exit", exitHandler);
|
|
3394
|
+
rt.ptySubscribers?.delete(subId);
|
|
3395
|
+
reconcilePtySize(rt);
|
|
3396
|
+
}
|
|
3397
|
+
};
|
|
3398
|
+
},
|
|
3399
|
+
findByIdOrName(query) {
|
|
3400
|
+
const direct = sessions.get(query);
|
|
3401
|
+
if (direct) return direct.desc;
|
|
3402
|
+
for (const rt of sessions.values()) {
|
|
3403
|
+
if (rt.desc.name === query) return rt.desc;
|
|
3404
|
+
}
|
|
3405
|
+
return void 0;
|
|
3406
|
+
},
|
|
3407
|
+
writeTerminalInput(id, data) {
|
|
3408
|
+
const rt = sessions.get(id);
|
|
3409
|
+
if (!rt || !rt.pty) return false;
|
|
3410
|
+
try {
|
|
3411
|
+
rt.pty.write(data);
|
|
3412
|
+
return true;
|
|
3413
|
+
} catch {
|
|
3414
|
+
return false;
|
|
3415
|
+
}
|
|
3416
|
+
},
|
|
3417
|
+
readTerminalOutput(id, lastBytes) {
|
|
3418
|
+
const rt = sessions.get(id);
|
|
3419
|
+
if (!rt || !rt.pty) return null;
|
|
3420
|
+
const joined = Buffer.concat(rt.recentBytes, rt.recentBytesSize);
|
|
3421
|
+
if (typeof lastBytes === "number" && lastBytes > 0 && joined.byteLength > lastBytes) {
|
|
3422
|
+
return joined.subarray(joined.byteLength - lastBytes);
|
|
3423
|
+
}
|
|
3424
|
+
return joined;
|
|
3425
|
+
},
|
|
3426
|
+
kill(id, signal = "SIGTERM") {
|
|
3427
|
+
const rt = sessions.get(id);
|
|
3428
|
+
if (!rt) return false;
|
|
3429
|
+
if (rt.desc.status === "exited" || rt.desc.status === "killed" || rt.desc.status === "error") {
|
|
3430
|
+
return false;
|
|
3431
|
+
}
|
|
3432
|
+
rt.desc.status = "killed";
|
|
3433
|
+
rt.desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3434
|
+
if (rt.agentSession) {
|
|
3435
|
+
void rt.agentSession.close().catch(() => void 0);
|
|
3436
|
+
}
|
|
3437
|
+
if (rt.pty) {
|
|
3438
|
+
try {
|
|
3439
|
+
rt.pty.kill(signal);
|
|
3440
|
+
} catch {
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
rt.child?.kill(signal);
|
|
3444
|
+
schedulePersist();
|
|
3445
|
+
return true;
|
|
3446
|
+
},
|
|
3447
|
+
forget(id) {
|
|
3448
|
+
const rt = sessions.get(id);
|
|
3449
|
+
if (!rt) return false;
|
|
3450
|
+
rt.emitter.removeAllListeners();
|
|
3451
|
+
sessions.delete(id);
|
|
3452
|
+
schedulePersist();
|
|
3453
|
+
return true;
|
|
3454
|
+
},
|
|
3455
|
+
shutdown() {
|
|
3456
|
+
shutdownImpl();
|
|
3457
|
+
}
|
|
3458
|
+
};
|
|
3459
|
+
function shutdownImpl() {
|
|
3460
|
+
if (shutdownDone) return;
|
|
3461
|
+
shutdownDone = true;
|
|
3462
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
3463
|
+
if (persist) {
|
|
3464
|
+
try {
|
|
3465
|
+
process.off("exit", onProcessExit);
|
|
3466
|
+
} catch {
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
3470
|
+
for (const rt of sessions.values()) {
|
|
3471
|
+
rt.emitter.removeAllListeners();
|
|
3472
|
+
if (rt.desc.status === "running" || rt.desc.status === "starting") {
|
|
3473
|
+
rt.desc.status = "killed";
|
|
3474
|
+
rt.desc.endedAt = nowIso;
|
|
3475
|
+
if (rt.agentSession) {
|
|
3476
|
+
void rt.agentSession.close().catch(() => void 0);
|
|
3477
|
+
}
|
|
3478
|
+
if (rt.pty) {
|
|
3479
|
+
try {
|
|
3480
|
+
rt.pty.kill("SIGTERM");
|
|
3481
|
+
} catch {
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
rt.child?.kill("SIGTERM");
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
if (persist) {
|
|
3488
|
+
try {
|
|
3489
|
+
const snapshot = {
|
|
3490
|
+
savedAt: nowIso,
|
|
3491
|
+
sessions: Array.from(sessions.values()).map((s) => s.desc)
|
|
3492
|
+
};
|
|
3493
|
+
mkdirSync(dirname(persistPath), { recursive: true });
|
|
3494
|
+
writeFileSync(persistPath, JSON.stringify(snapshot, null, 2) + "\n");
|
|
3495
|
+
} catch {
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
sessions.clear();
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
function loadHistorySnapshot(persistPath, sessions) {
|
|
3502
|
+
let raw;
|
|
3503
|
+
try {
|
|
3504
|
+
raw = readFileSync(persistPath, "utf8");
|
|
3505
|
+
} catch {
|
|
3506
|
+
return;
|
|
3507
|
+
}
|
|
3508
|
+
let parsed;
|
|
3509
|
+
try {
|
|
3510
|
+
parsed = JSON.parse(raw);
|
|
3511
|
+
} catch {
|
|
3512
|
+
console.warn(
|
|
3513
|
+
`[sessions] history file ${persistPath} is malformed \u2014 ignoring`
|
|
3514
|
+
);
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
if (!Array.isArray(parsed.sessions)) return;
|
|
3518
|
+
const sorted = parsed.sessions.filter((s) => !!s && typeof s.id === "string").sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, HISTORY_CAP);
|
|
3519
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3520
|
+
for (const desc of sorted) {
|
|
3521
|
+
if (sessions.has(desc.id)) continue;
|
|
3522
|
+
const wasAlive = desc.status === "running" || desc.status === "starting";
|
|
3523
|
+
const reclassified = wasAlive ? {
|
|
3524
|
+
...desc,
|
|
3525
|
+
status: "killed",
|
|
3526
|
+
endedAt: desc.endedAt ?? now
|
|
3527
|
+
} : desc;
|
|
3528
|
+
const rt = {
|
|
3529
|
+
desc: reclassified,
|
|
3530
|
+
recentLines: [],
|
|
3531
|
+
recentBytes: [],
|
|
3532
|
+
recentBytesSize: 0,
|
|
3533
|
+
emitter: new EventEmitter(),
|
|
3534
|
+
busy: false,
|
|
3535
|
+
textBuf: "",
|
|
3536
|
+
thoughtBuf: ""
|
|
3537
|
+
};
|
|
3538
|
+
rt.emitter.setMaxListeners(50);
|
|
3539
|
+
sessions.set(desc.id, rt);
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
function quoteArg(arg) {
|
|
3543
|
+
if (arg === "") return '""';
|
|
3544
|
+
if (/^[a-zA-Z0-9._/=:@,+-]+$/.test(arg)) return arg;
|
|
3545
|
+
return `"${arg.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
3546
|
+
}
|
|
3547
|
+
var McpProxyRegistry = class {
|
|
3548
|
+
clients = /* @__PURE__ */ new Map();
|
|
3549
|
+
lastMtimeMs = 0;
|
|
3550
|
+
/** Sentinel so we know whether the file has *ever* been read.
|
|
3551
|
+
* Distinguishes "file missing on first call" (legit empty state)
|
|
3552
|
+
* from "we've read it before and it's gone" (treat as empty too,
|
|
3553
|
+
* but only after closing live clients). */
|
|
3554
|
+
hasReadOnce = false;
|
|
3555
|
+
/**
|
|
3556
|
+
* Reload the imports file when its mtime has advanced. Avoids
|
|
3557
|
+
* repeated parsing when nothing changed — most calls are no-ops.
|
|
3558
|
+
* Disconnects clients whose import was removed; connects the new
|
|
3559
|
+
* set lazily (the first call that touches them).
|
|
3560
|
+
*/
|
|
3561
|
+
async ensureCurrent() {
|
|
3562
|
+
let mtimeMs = 0;
|
|
3563
|
+
try {
|
|
3564
|
+
const st = await promises.stat(IMPORTED_MCPS_PATH());
|
|
3565
|
+
mtimeMs = st.mtimeMs;
|
|
3566
|
+
} catch {
|
|
3567
|
+
mtimeMs = 0;
|
|
3568
|
+
}
|
|
3569
|
+
if (this.hasReadOnce && mtimeMs === this.lastMtimeMs) return;
|
|
3570
|
+
const config = await loadImportedMcps().catch(() => ({
|
|
3571
|
+
version: 1,
|
|
3572
|
+
imports: []
|
|
3573
|
+
}));
|
|
3574
|
+
const wantedIds = new Set(config.imports.map((e) => e.id));
|
|
3575
|
+
for (const [id, c] of this.clients.entries()) {
|
|
3576
|
+
if (!wantedIds.has(id)) {
|
|
3577
|
+
await safeClose(c.client);
|
|
3578
|
+
this.clients.delete(id);
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
for (const entry of config.imports) {
|
|
3582
|
+
const existing = this.clients.get(entry.id);
|
|
3583
|
+
if (existing) {
|
|
3584
|
+
if (JSON.stringify(existing.entry.snapshot) !== JSON.stringify(entry.snapshot)) {
|
|
3585
|
+
await safeClose(existing.client);
|
|
3586
|
+
this.clients.set(entry.id, freshPlaceholder(entry));
|
|
3587
|
+
} else {
|
|
3588
|
+
existing.entry = entry;
|
|
3589
|
+
}
|
|
3590
|
+
} else {
|
|
3591
|
+
this.clients.set(entry.id, freshPlaceholder(entry));
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
this.lastMtimeMs = mtimeMs;
|
|
3595
|
+
this.hasReadOnce = true;
|
|
3596
|
+
}
|
|
3597
|
+
/**
|
|
3598
|
+
* Lazy connect: the first call to `connectIfNeeded(alias)`
|
|
3599
|
+
* actually opens the transport + handshakes. Subsequent calls
|
|
3600
|
+
* reuse the live client.
|
|
3601
|
+
*/
|
|
3602
|
+
async connectIfNeeded(alias) {
|
|
3603
|
+
await this.ensureCurrent();
|
|
3604
|
+
const handle = this.findByAlias(alias);
|
|
3605
|
+
if (!handle) return null;
|
|
3606
|
+
if (handle.client) return handle;
|
|
3607
|
+
try {
|
|
3608
|
+
const client = await openClient(handle.entry);
|
|
3609
|
+
handle.client = client;
|
|
3610
|
+
handle.lastError = null;
|
|
3611
|
+
const t = await client.listTools();
|
|
3612
|
+
handle.tools = t.tools.map(toDescriptor);
|
|
3613
|
+
return handle;
|
|
3614
|
+
} catch (err) {
|
|
3615
|
+
handle.client = null;
|
|
3616
|
+
handle.tools = null;
|
|
3617
|
+
handle.lastError = err instanceof Error ? err.message : String(err);
|
|
3618
|
+
return handle;
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
/** Find a client handle by alias *or* import id (defensive — agents
|
|
3622
|
+
* sometimes pass the id from `list_imported_mcps`). */
|
|
3623
|
+
findByAlias(aliasOrId) {
|
|
3624
|
+
for (const c of this.clients.values()) {
|
|
3625
|
+
if (c.entry.alias === aliasOrId) return c;
|
|
3626
|
+
if (c.entry.id === aliasOrId) return c;
|
|
3627
|
+
}
|
|
3628
|
+
return void 0;
|
|
3629
|
+
}
|
|
3630
|
+
async listAliases() {
|
|
3631
|
+
await this.ensureCurrent();
|
|
3632
|
+
return Array.from(this.clients.values()).map((c) => ({
|
|
3633
|
+
alias: c.entry.alias,
|
|
3634
|
+
importId: c.entry.id,
|
|
3635
|
+
source: c.entry.snapshot.source,
|
|
3636
|
+
type: c.entry.snapshot.type,
|
|
3637
|
+
status: c.client ? "connected" : c.lastError ? "error" : "pending",
|
|
3638
|
+
toolCount: c.tools?.length ?? 0,
|
|
3639
|
+
...c.lastError ? { lastError: c.lastError } : {}
|
|
3640
|
+
}));
|
|
3641
|
+
}
|
|
3642
|
+
async listTools(alias) {
|
|
3643
|
+
const handle = await this.connectIfNeeded(alias);
|
|
3644
|
+
if (!handle) return { ok: false, error: `unknown alias "${alias}"` };
|
|
3645
|
+
if (!handle.client || !handle.tools) {
|
|
3646
|
+
return {
|
|
3647
|
+
ok: false,
|
|
3648
|
+
error: handle.lastError ?? "client not connected"
|
|
3649
|
+
};
|
|
3650
|
+
}
|
|
3651
|
+
return { ok: true, tools: handle.tools };
|
|
3652
|
+
}
|
|
3653
|
+
async callTool(alias, toolName, args) {
|
|
3654
|
+
const handle = await this.connectIfNeeded(alias);
|
|
3655
|
+
if (!handle) return { ok: false, error: `unknown alias "${alias}"` };
|
|
3656
|
+
if (!handle.client) {
|
|
3657
|
+
return {
|
|
3658
|
+
ok: false,
|
|
3659
|
+
error: handle.lastError ?? "client not connected"
|
|
3660
|
+
};
|
|
3661
|
+
}
|
|
3662
|
+
try {
|
|
3663
|
+
const result = await handle.client.callTool({
|
|
3664
|
+
name: toolName,
|
|
3665
|
+
arguments: args && typeof args === "object" ? args : {}
|
|
3666
|
+
});
|
|
3667
|
+
return { ok: true, result };
|
|
3668
|
+
} catch (err) {
|
|
3669
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3670
|
+
if (/closed|disconnect|EPIPE|ECONNRESET/i.test(msg)) {
|
|
3671
|
+
await safeClose(handle.client);
|
|
3672
|
+
handle.client = null;
|
|
3673
|
+
handle.tools = null;
|
|
3674
|
+
handle.lastError = msg;
|
|
3675
|
+
}
|
|
3676
|
+
return { ok: false, error: msg };
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3679
|
+
async closeAll() {
|
|
3680
|
+
await Promise.all(
|
|
3681
|
+
Array.from(this.clients.values()).map((c) => safeClose(c.client))
|
|
3682
|
+
);
|
|
3683
|
+
this.clients.clear();
|
|
3684
|
+
}
|
|
3685
|
+
};
|
|
3686
|
+
function freshPlaceholder(entry) {
|
|
3687
|
+
return { entry, client: null, tools: null, lastError: null };
|
|
3688
|
+
}
|
|
3689
|
+
function toDescriptor(tool) {
|
|
3690
|
+
return {
|
|
3691
|
+
name: tool.name,
|
|
3692
|
+
...tool.description ? { description: tool.description } : {},
|
|
3693
|
+
...tool.inputSchema ? { inputSchema: tool.inputSchema } : {}
|
|
3694
|
+
};
|
|
3695
|
+
}
|
|
3696
|
+
function expandEnvPlaceholders(raw) {
|
|
3697
|
+
const out = {};
|
|
3698
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
3699
|
+
const expanded = v.replace(
|
|
3700
|
+
/\$\{([A-Z_][A-Z0-9_]*)(?::-([^}]*))?\}/gi,
|
|
3701
|
+
(_match, name, fallback) => {
|
|
3702
|
+
const fromEnv = process.env[name];
|
|
3703
|
+
if (fromEnv !== void 0) return fromEnv;
|
|
3704
|
+
if (fallback !== void 0) return fallback;
|
|
3705
|
+
return "";
|
|
3706
|
+
}
|
|
3707
|
+
);
|
|
3708
|
+
if (expanded.length === 0) continue;
|
|
3709
|
+
out[k] = expanded;
|
|
3710
|
+
}
|
|
3711
|
+
return out;
|
|
3712
|
+
}
|
|
3713
|
+
async function safeClose(client) {
|
|
3714
|
+
if (!client) return;
|
|
3715
|
+
try {
|
|
3716
|
+
await client.close();
|
|
3717
|
+
} catch {
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
async function openClient(entry) {
|
|
3721
|
+
const snap = entry.snapshot;
|
|
3722
|
+
const client = new Client(
|
|
3723
|
+
{ name: "agentproto-daemon-proxy", version: "0.1.0" },
|
|
3724
|
+
{ capabilities: {} }
|
|
3725
|
+
);
|
|
3726
|
+
if (snap.type === "stdio") {
|
|
3727
|
+
if (!snap.command) {
|
|
3728
|
+
throw new Error(
|
|
3729
|
+
`import "${entry.alias}" is stdio but has no command field`
|
|
3730
|
+
);
|
|
3731
|
+
}
|
|
3732
|
+
const expandedEnv = expandEnvPlaceholders(snap.env ?? {});
|
|
3733
|
+
const transport = new StdioClientTransport({
|
|
3734
|
+
command: snap.command,
|
|
3735
|
+
args: snap.args ?? [],
|
|
3736
|
+
env: { ...process.env, ...expandedEnv },
|
|
3737
|
+
// Pipe stderr so we can include it in the error message instead of
|
|
3738
|
+
// surfacing the opaque "Connection closed" MCP error code.
|
|
3739
|
+
stderr: "pipe"
|
|
3740
|
+
});
|
|
3741
|
+
const stderrBuf = [];
|
|
3742
|
+
transport.stderr?.on("data", (chunk) => {
|
|
3743
|
+
stderrBuf.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
|
3744
|
+
if (stderrBuf.length > 40) stderrBuf.shift();
|
|
3745
|
+
});
|
|
3746
|
+
try {
|
|
3747
|
+
await client.connect(transport);
|
|
3748
|
+
} catch (err) {
|
|
3749
|
+
const stderrText = stderrBuf.join("").trim();
|
|
3750
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3751
|
+
throw new Error(stderrText ? `${msg}
|
|
3752
|
+
stderr: ${stderrText.slice(-600)}` : msg);
|
|
3753
|
+
}
|
|
3754
|
+
return client;
|
|
3755
|
+
}
|
|
3756
|
+
if (snap.type === "http") {
|
|
3757
|
+
if (!snap.url) throw new Error(`import "${entry.alias}" has no url`);
|
|
3758
|
+
const transport = new StreamableHTTPClientTransport(new URL(snap.url), {
|
|
3759
|
+
requestInit: {
|
|
3760
|
+
headers: snap.headers ?? {}
|
|
3761
|
+
}
|
|
3762
|
+
});
|
|
3763
|
+
await client.connect(transport);
|
|
3764
|
+
return client;
|
|
3765
|
+
}
|
|
3766
|
+
if (snap.type === "sse") {
|
|
3767
|
+
if (!snap.url) throw new Error(`import "${entry.alias}" has no url`);
|
|
3768
|
+
const transport = new SSEClientTransport(new URL(snap.url), {
|
|
3769
|
+
requestInit: {
|
|
3770
|
+
headers: snap.headers ?? {}
|
|
3771
|
+
}
|
|
3772
|
+
});
|
|
3773
|
+
await client.connect(transport);
|
|
3774
|
+
return client;
|
|
3775
|
+
}
|
|
3776
|
+
throw new Error(
|
|
3777
|
+
`import "${entry.alias}" has unsupported transport type "${snap.type}"`
|
|
3778
|
+
);
|
|
3779
|
+
}
|
|
3780
|
+
var execFileAsync = promisify(execFile);
|
|
3781
|
+
var URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
3782
|
+
var STARTUP_TIMEOUT_MS = 3e4;
|
|
3783
|
+
function quickTunnelProvider() {
|
|
3784
|
+
let child = null;
|
|
3785
|
+
let configPath = null;
|
|
3786
|
+
return {
|
|
3787
|
+
id: "quick",
|
|
3788
|
+
async start(opts) {
|
|
3789
|
+
await assertCloudflaredOnPath();
|
|
3790
|
+
const targetUrl = `http://${opts.target.host}:${opts.target.port}`;
|
|
3791
|
+
const id = randomBytes(4).toString("hex");
|
|
3792
|
+
const dir = join(opts.workspace, ".agentproto");
|
|
3793
|
+
await mkdir(dir, { recursive: true });
|
|
3794
|
+
configPath = join(dir, `cloudflared-${id}.yml`);
|
|
3795
|
+
const configBody = [
|
|
3796
|
+
`# generated by agentproto-runtime \u2014 sentinel only`,
|
|
3797
|
+
`# overrides ~/.cloudflared/config.yml so its ingress rules`,
|
|
3798
|
+
`# don't shadow the inline --url rule. Safe to delete when the`,
|
|
3799
|
+
`# tunnel is down.`,
|
|
3800
|
+
`no-autoupdate: true`,
|
|
3801
|
+
`loglevel: debug`,
|
|
3802
|
+
``
|
|
3803
|
+
].join("\n");
|
|
3804
|
+
await writeFile(configPath, configBody, "utf8");
|
|
3805
|
+
const proc = spawn(
|
|
3806
|
+
"cloudflared",
|
|
3807
|
+
["tunnel", "--config", configPath, "--url", targetUrl],
|
|
3808
|
+
{ stdio: ["ignore", "pipe", "pipe"], shell: false }
|
|
3809
|
+
);
|
|
3810
|
+
child = proc;
|
|
3811
|
+
const url = await new Promise((resolve8, reject) => {
|
|
3812
|
+
let settled = false;
|
|
3813
|
+
const timer = setTimeout(() => {
|
|
3814
|
+
if (settled) return;
|
|
3815
|
+
settled = true;
|
|
3816
|
+
try {
|
|
3817
|
+
proc.kill("SIGTERM");
|
|
3818
|
+
} catch {
|
|
3819
|
+
}
|
|
3820
|
+
reject(
|
|
3821
|
+
new Error(
|
|
3822
|
+
`cloudflared did not emit a public URL within ${STARTUP_TIMEOUT_MS / 1e3}s`
|
|
3823
|
+
)
|
|
3824
|
+
);
|
|
3825
|
+
}, STARTUP_TIMEOUT_MS);
|
|
3826
|
+
const onData = (chunk) => {
|
|
3827
|
+
const text3 = chunk.toString("utf8");
|
|
3828
|
+
for (const line of text3.split(/\r?\n/)) {
|
|
3829
|
+
if (line.length > 0) opts.onLog?.(`[cloudflared] ${line}`);
|
|
3830
|
+
}
|
|
3831
|
+
if (settled) return;
|
|
3832
|
+
const match = text3.match(URL_REGEX);
|
|
3833
|
+
if (match) {
|
|
3834
|
+
settled = true;
|
|
3835
|
+
clearTimeout(timer);
|
|
3836
|
+
resolve8(match[0]);
|
|
3837
|
+
}
|
|
3838
|
+
};
|
|
3839
|
+
proc.stderr?.on("data", onData);
|
|
3840
|
+
proc.stdout?.on("data", onData);
|
|
3841
|
+
proc.once("error", (err) => {
|
|
3842
|
+
if (settled) return;
|
|
3843
|
+
settled = true;
|
|
3844
|
+
clearTimeout(timer);
|
|
3845
|
+
reject(err);
|
|
3846
|
+
});
|
|
3847
|
+
proc.once("exit", (code, signal) => {
|
|
3848
|
+
if (settled) return;
|
|
3849
|
+
settled = true;
|
|
3850
|
+
clearTimeout(timer);
|
|
3851
|
+
reject(
|
|
3852
|
+
new Error(
|
|
3853
|
+
`cloudflared exited before emitting a URL (code=${code}, signal=${signal})`
|
|
3854
|
+
)
|
|
3855
|
+
);
|
|
3856
|
+
});
|
|
3857
|
+
});
|
|
3858
|
+
proc.once("exit", (code, signal) => {
|
|
3859
|
+
opts.onLog?.(
|
|
3860
|
+
`[cloudflared] exited (code=${code ?? "?"} signal=${signal ?? "-"})`
|
|
3861
|
+
);
|
|
3862
|
+
});
|
|
3863
|
+
return { publicUrl: url, pid: proc.pid ?? null };
|
|
3864
|
+
},
|
|
3865
|
+
async stop() {
|
|
3866
|
+
const proc = child;
|
|
3867
|
+
const cfg = configPath;
|
|
3868
|
+
child = null;
|
|
3869
|
+
configPath = null;
|
|
3870
|
+
if (cfg) {
|
|
3871
|
+
try {
|
|
3872
|
+
await rm(cfg, { force: true });
|
|
3873
|
+
} catch {
|
|
3874
|
+
}
|
|
3875
|
+
}
|
|
3876
|
+
if (!proc || proc.exitCode !== null) return;
|
|
3877
|
+
proc.kill("SIGTERM");
|
|
3878
|
+
const timer = setTimeout(() => {
|
|
3879
|
+
try {
|
|
3880
|
+
proc.kill("SIGKILL");
|
|
3881
|
+
} catch {
|
|
3882
|
+
}
|
|
3883
|
+
}, 3e3);
|
|
3884
|
+
timer.unref();
|
|
3885
|
+
await new Promise((resolve8) => {
|
|
3886
|
+
if (proc.exitCode !== null) {
|
|
3887
|
+
clearTimeout(timer);
|
|
3888
|
+
resolve8();
|
|
3889
|
+
return;
|
|
3890
|
+
}
|
|
3891
|
+
proc.once("exit", () => {
|
|
3892
|
+
clearTimeout(timer);
|
|
3893
|
+
resolve8();
|
|
3894
|
+
});
|
|
3895
|
+
});
|
|
3896
|
+
}
|
|
3897
|
+
};
|
|
3898
|
+
}
|
|
3899
|
+
async function assertCloudflaredOnPath() {
|
|
3900
|
+
try {
|
|
3901
|
+
await execFileAsync("cloudflared", ["--version"], { timeout: 3e3 });
|
|
3902
|
+
} catch {
|
|
3903
|
+
throw new Error(
|
|
3904
|
+
"cloudflared not found on PATH. Install it first:\n macOS: brew install cloudflared\n Linux: https://pkg.cloudflare.com/index.html\n Windows: winget install --id Cloudflare.cloudflared\nOr download a prebuilt binary from https://github.com/cloudflare/cloudflared/releases."
|
|
3905
|
+
);
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
// src/remote-controller.ts
|
|
3910
|
+
var RemoteController = class {
|
|
3911
|
+
state = null;
|
|
3912
|
+
bearerToken = null;
|
|
3913
|
+
provider = null;
|
|
3914
|
+
lastError;
|
|
3915
|
+
opts;
|
|
3916
|
+
constructor(opts) {
|
|
3917
|
+
this.opts = opts;
|
|
3918
|
+
}
|
|
3919
|
+
/**
|
|
3920
|
+
* Auth getter handed to startHttpServer. The HTTP server calls this
|
|
3921
|
+
* on every request so flipping enable/disable is effective without
|
|
3922
|
+
* a restart.
|
|
3923
|
+
*/
|
|
3924
|
+
readAuth = () => {
|
|
3925
|
+
if (this.bearerToken === null) return { mode: "none" };
|
|
3926
|
+
return { mode: "bearer", token: this.bearerToken };
|
|
3927
|
+
};
|
|
3928
|
+
status() {
|
|
3929
|
+
if (!this.state) {
|
|
3930
|
+
return { enabled: false, lastError: this.lastError };
|
|
3931
|
+
}
|
|
3932
|
+
return {
|
|
3933
|
+
enabled: true,
|
|
3934
|
+
provider: this.state.provider,
|
|
3935
|
+
publicUrl: this.state.publicUrl,
|
|
3936
|
+
pid: this.state.pid,
|
|
3937
|
+
createdAt: this.state.createdAt,
|
|
3938
|
+
target: this.state.target,
|
|
3939
|
+
exposesGateway: this.state.exposesGateway,
|
|
3940
|
+
lastError: this.lastError
|
|
3941
|
+
};
|
|
3942
|
+
}
|
|
3943
|
+
async enable(input) {
|
|
3944
|
+
if (this.state) {
|
|
3945
|
+
throw new Error(
|
|
3946
|
+
"remote tunnel already active \u2014 call remote_disable first to rotate"
|
|
3947
|
+
);
|
|
3948
|
+
}
|
|
3949
|
+
const providerId = input.provider ?? "quick";
|
|
3950
|
+
const provider = pickProvider(providerId);
|
|
3951
|
+
this.lastError = void 0;
|
|
3952
|
+
const target = {
|
|
3953
|
+
host: input.targetHost ?? "127.0.0.1",
|
|
3954
|
+
port: input.targetPort ?? this.opts.port
|
|
3955
|
+
};
|
|
3956
|
+
const exposesGateway = target.host === "127.0.0.1" && target.port === this.opts.port;
|
|
3957
|
+
let started;
|
|
3958
|
+
try {
|
|
3959
|
+
started = await provider.start({
|
|
3960
|
+
target,
|
|
3961
|
+
workspace: this.opts.workspace,
|
|
3962
|
+
onLog: (line) => {
|
|
3963
|
+
this.opts.onLog?.(line);
|
|
3964
|
+
}
|
|
3965
|
+
});
|
|
3966
|
+
} catch (err) {
|
|
3967
|
+
this.lastError = errToMessage(err);
|
|
3968
|
+
throw err;
|
|
3969
|
+
}
|
|
3970
|
+
let bearerToken = null;
|
|
3971
|
+
let bearerTokenHash = "";
|
|
3972
|
+
if (exposesGateway) {
|
|
3973
|
+
bearerToken = randomBytes(32).toString("base64url");
|
|
3974
|
+
bearerTokenHash = sha256(bearerToken);
|
|
3975
|
+
}
|
|
3976
|
+
const state = {
|
|
3977
|
+
provider: providerId,
|
|
3978
|
+
publicUrl: started.publicUrl,
|
|
3979
|
+
bearerTokenHash,
|
|
3980
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3981
|
+
pid: started.pid,
|
|
3982
|
+
target,
|
|
3983
|
+
exposesGateway
|
|
3984
|
+
};
|
|
3985
|
+
this.state = state;
|
|
3986
|
+
this.bearerToken = bearerToken;
|
|
3987
|
+
this.provider = provider;
|
|
3988
|
+
await persistState(this.opts.workspace, state);
|
|
3989
|
+
const result = {
|
|
3990
|
+
provider: providerId,
|
|
3991
|
+
publicUrl: started.publicUrl,
|
|
3992
|
+
target,
|
|
3993
|
+
exposesGateway
|
|
3994
|
+
};
|
|
3995
|
+
if (exposesGateway && bearerToken) {
|
|
3996
|
+
const mcpEndpoint = `${started.publicUrl}/mcp`;
|
|
3997
|
+
result.bearerToken = bearerToken;
|
|
3998
|
+
result.mcpEndpoint = mcpEndpoint;
|
|
3999
|
+
result.mcpConfigSnippet = buildMcpConfigSnippet(mcpEndpoint, bearerToken);
|
|
4000
|
+
}
|
|
4001
|
+
return result;
|
|
4002
|
+
}
|
|
4003
|
+
async disable() {
|
|
4004
|
+
if (!this.state) return { disabled: false };
|
|
4005
|
+
const provider = this.provider;
|
|
4006
|
+
this.state = null;
|
|
4007
|
+
this.bearerToken = null;
|
|
4008
|
+
this.provider = null;
|
|
4009
|
+
if (provider) {
|
|
4010
|
+
try {
|
|
4011
|
+
await provider.stop();
|
|
4012
|
+
} catch (err) {
|
|
4013
|
+
this.lastError = errToMessage(err);
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
await clearState(this.opts.workspace);
|
|
4017
|
+
return { disabled: true };
|
|
4018
|
+
}
|
|
4019
|
+
/**
|
|
4020
|
+
* Stop the provider on gateway shutdown. Does NOT clear persisted
|
|
4021
|
+
* state — that survives across restarts so the user can `remote_status`
|
|
4022
|
+
* after rebooting and see "stale state, run remote_enable to refresh".
|
|
4023
|
+
* (Stale-recovery semantics land in v2; today we just leave the file.)
|
|
4024
|
+
*/
|
|
4025
|
+
async shutdown() {
|
|
4026
|
+
if (this.provider) {
|
|
4027
|
+
try {
|
|
4028
|
+
await this.provider.stop();
|
|
4029
|
+
} catch {
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
};
|
|
4034
|
+
function pickProvider(id) {
|
|
4035
|
+
if (id === "quick") return quickTunnelProvider();
|
|
4036
|
+
const _exhaustive = id;
|
|
4037
|
+
throw new Error(`unknown remote provider: ${String(_exhaustive)}`);
|
|
4038
|
+
}
|
|
4039
|
+
function sha256(input) {
|
|
4040
|
+
return createHash("sha256").update(input).digest("hex");
|
|
4041
|
+
}
|
|
4042
|
+
function errToMessage(err) {
|
|
4043
|
+
return err instanceof Error ? err.message : String(err);
|
|
4044
|
+
}
|
|
4045
|
+
async function persistState(workspace, state) {
|
|
4046
|
+
const dir = join(workspace, ".agentproto");
|
|
4047
|
+
await mkdir(dir, { recursive: true });
|
|
4048
|
+
const file = join(dir, "remote.json");
|
|
4049
|
+
await writeFile(file, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
4050
|
+
try {
|
|
4051
|
+
await chmod(file, 384);
|
|
4052
|
+
} catch {
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
async function clearState(workspace) {
|
|
4056
|
+
const file = join(workspace, ".agentproto/remote.json");
|
|
4057
|
+
if (!existsSync(file)) return;
|
|
4058
|
+
await rm(file, { force: true });
|
|
4059
|
+
}
|
|
4060
|
+
function buildMcpConfigSnippet(endpoint, token) {
|
|
4061
|
+
return JSON.stringify(
|
|
4062
|
+
{
|
|
4063
|
+
mcpServers: {
|
|
4064
|
+
"agentproto-remote": {
|
|
4065
|
+
transport: "http",
|
|
4066
|
+
url: endpoint,
|
|
4067
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
},
|
|
4071
|
+
null,
|
|
4072
|
+
2
|
|
4073
|
+
);
|
|
4074
|
+
}
|
|
4075
|
+
function text2(value) {
|
|
4076
|
+
return {
|
|
4077
|
+
content: [
|
|
4078
|
+
{
|
|
4079
|
+
type: "text",
|
|
4080
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
4081
|
+
}
|
|
4082
|
+
]
|
|
4083
|
+
};
|
|
4084
|
+
}
|
|
4085
|
+
function registerRemoteTools(server, opts) {
|
|
4086
|
+
const { controller } = opts;
|
|
4087
|
+
server.tool(
|
|
4088
|
+
"remote_enable",
|
|
4089
|
+
"Publish a local port to the internet via Cloudflare. By default exposes this gateway and gates it with a bearer token (returned ONCE in the response \u2014 store it; only its hash is persisted). Pass `targetPort` to tunnel a different local service (Supabase, dev server, \u2026); in that mode the daemon does not gate the proxied traffic \u2014 the upstream service handles its own auth. Returns the public URL plus a paste-ready `.mcp.json` snippet (gateway mode only). Re-running while a tunnel is already up errors; call `remote_disable` first to rotate.",
|
|
4090
|
+
{
|
|
4091
|
+
provider: z.enum(["quick"]).optional().describe(
|
|
4092
|
+
"Tunnel backend. `quick` = Cloudflare Quick Tunnel (no API key, ephemeral *.trycloudflare.com URL). Defaults to `quick`. Named-CF / Tailscale providers TBD."
|
|
4093
|
+
),
|
|
4094
|
+
targetPort: z.number().int().min(1).max(65535).optional().describe(
|
|
4095
|
+
"Local port to expose. Defaults to this gateway's own port (= publish the MCP server). Pass another port to tunnel a different local service (e.g. 54371 for Supabase) \u2014 in that mode bearer auth is OFF since the upstream handles its own gating."
|
|
4096
|
+
),
|
|
4097
|
+
targetHost: z.string().optional().describe(
|
|
4098
|
+
"Host the tunnel forwards to. Defaults to `127.0.0.1`. Use `localhost` only if the target is explicitly IPv6-bound; cloudflared resolves DNS and IPv6-first lookups can break IPv4-only origins."
|
|
4099
|
+
)
|
|
4100
|
+
},
|
|
4101
|
+
async ({ provider, targetPort, targetHost }) => {
|
|
4102
|
+
const result = await controller.enable({ provider, targetPort, targetHost });
|
|
4103
|
+
return text2(result);
|
|
4104
|
+
}
|
|
4105
|
+
);
|
|
4106
|
+
server.tool(
|
|
4107
|
+
"remote_disable",
|
|
4108
|
+
"Tear down the active remote tunnel and drop bearer auth back to loopback-only. Idempotent \u2014 calling when no tunnel is active is a no-op.",
|
|
4109
|
+
{},
|
|
4110
|
+
async () => {
|
|
4111
|
+
const result = await controller.disable();
|
|
4112
|
+
return text2(result);
|
|
4113
|
+
}
|
|
4114
|
+
);
|
|
4115
|
+
server.tool(
|
|
4116
|
+
"remote_status",
|
|
4117
|
+
"Report whether a remote tunnel is active. When active: provider, public URL, supervised PID, createdAt. The bearer token itself is never returned by status \u2014 it was shown once at enable.",
|
|
4118
|
+
{},
|
|
4119
|
+
async () => {
|
|
4120
|
+
return text2(controller.status());
|
|
4121
|
+
}
|
|
4122
|
+
);
|
|
4123
|
+
}
|
|
4124
|
+
var WorkspacePathError = class extends Error {
|
|
4125
|
+
constructor(message) {
|
|
4126
|
+
super(message);
|
|
4127
|
+
this.name = "WorkspacePathError";
|
|
4128
|
+
}
|
|
4129
|
+
};
|
|
4130
|
+
function createWorkspaceFs(opts) {
|
|
4131
|
+
const root = resolve(opts.workspace);
|
|
4132
|
+
function resolvePath4(path) {
|
|
4133
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
4134
|
+
throw new WorkspacePathError("path must be a non-empty string");
|
|
4135
|
+
}
|
|
4136
|
+
if (isAbsolute(path)) {
|
|
4137
|
+
throw new WorkspacePathError(
|
|
4138
|
+
"absolute paths are not allowed; use a workspace-relative path"
|
|
4139
|
+
);
|
|
4140
|
+
}
|
|
4141
|
+
const joined = normalize(join(root, path));
|
|
4142
|
+
const rel = relative(root, joined);
|
|
4143
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
4144
|
+
throw new WorkspacePathError(
|
|
4145
|
+
`path escapes the workspace: '${path}'`
|
|
4146
|
+
);
|
|
4147
|
+
}
|
|
4148
|
+
return joined;
|
|
4149
|
+
}
|
|
4150
|
+
return {
|
|
4151
|
+
async readFile(path) {
|
|
4152
|
+
const abs = resolvePath4(path);
|
|
4153
|
+
const buf = await readFile(abs);
|
|
4154
|
+
return buf.toString("utf8");
|
|
4155
|
+
},
|
|
4156
|
+
async writeFile(path, content) {
|
|
4157
|
+
const abs = resolvePath4(path);
|
|
4158
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
4159
|
+
await writeFile(abs, content);
|
|
4160
|
+
},
|
|
4161
|
+
async exists(path) {
|
|
4162
|
+
try {
|
|
4163
|
+
const abs = resolvePath4(path);
|
|
4164
|
+
return existsSync(abs);
|
|
4165
|
+
} catch {
|
|
4166
|
+
return false;
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
};
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
// src/index.ts
|
|
4173
|
+
async function createGateway(opts) {
|
|
4174
|
+
const workspace = resolve(opts.workspace);
|
|
4175
|
+
if (!existsSync(workspace)) {
|
|
4176
|
+
throw new Error(`runtime: workspace dir does not exist: ${workspace}`);
|
|
4177
|
+
}
|
|
4178
|
+
const port = opts.port ?? 18790;
|
|
4179
|
+
const events = createRuntimeEvents();
|
|
4180
|
+
const conversations = fileConversationStore({ workspace });
|
|
4181
|
+
const workspaceFs = createWorkspaceFs({ workspace });
|
|
4182
|
+
const remote = new RemoteController({
|
|
4183
|
+
workspace,
|
|
4184
|
+
port,
|
|
4185
|
+
onLog: (line) => events.emit({
|
|
4186
|
+
type: "remote-log",
|
|
4187
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4188
|
+
line
|
|
4189
|
+
})
|
|
4190
|
+
});
|
|
4191
|
+
const { registered } = await createMcpServer({
|
|
4192
|
+
specs: opts.specs,
|
|
4193
|
+
workspace,
|
|
4194
|
+
name: opts.name ?? "agentproto-runtime",
|
|
4195
|
+
version: opts.version ?? "0.1.0-alpha"
|
|
4196
|
+
});
|
|
4197
|
+
const sessions = createSessionsRegistry({
|
|
4198
|
+
...opts.spawnPty ? { spawnPty: opts.spawnPty } : {},
|
|
4199
|
+
// Resume hook: when a prompt arrives for a dead agent-cli row
|
|
4200
|
+
// (typical after daemon restart), the registry calls back into
|
|
4201
|
+
// the adapter resolver to re-create the AgentSession with
|
|
4202
|
+
// `resumeSessionId = adapterSessionId`. ACP semantics: the
|
|
4203
|
+
// upstream provider reattaches to the prior conversation.
|
|
4204
|
+
// Unwired (no resolveAgentAdapter) → legacy "not an agent
|
|
4205
|
+
// session" error, user must spawn fresh.
|
|
4206
|
+
...opts.resolveAgentAdapter ? {
|
|
4207
|
+
resumeAgent: async ({ adapterSlug, cwd, resumeSessionId }) => {
|
|
4208
|
+
const adapter = await opts.resolveAgentAdapter(adapterSlug);
|
|
4209
|
+
if (!adapter) return null;
|
|
4210
|
+
try {
|
|
4211
|
+
return await adapter.startSession({ cwd, resumeSessionId });
|
|
4212
|
+
} catch (err) {
|
|
4213
|
+
console.warn(
|
|
4214
|
+
`[agentproto] resumeAgent('${adapterSlug}', ${resumeSessionId}) failed: ${err instanceof Error ? err.message : String(err)}`
|
|
4215
|
+
);
|
|
4216
|
+
return null;
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
} : {}
|
|
4220
|
+
});
|
|
4221
|
+
const token = opts.token ?? randomUUID();
|
|
4222
|
+
const mcpProxy = new McpProxyRegistry();
|
|
4223
|
+
const mcpServerFactory = async () => {
|
|
4224
|
+
const { server } = await createMcpServer({
|
|
4225
|
+
specs: opts.specs,
|
|
4226
|
+
workspace,
|
|
4227
|
+
name: opts.name ?? "agentproto-runtime",
|
|
4228
|
+
version: opts.version ?? "0.1.0-alpha"
|
|
4229
|
+
});
|
|
4230
|
+
registerFsTools(server, { workspace });
|
|
4231
|
+
registerCommandTools(server, { workspace });
|
|
4232
|
+
registerRemoteTools(server, { controller: remote });
|
|
4233
|
+
registerSessionTools(server, {
|
|
4234
|
+
registry: sessions,
|
|
4235
|
+
mcpProxy,
|
|
4236
|
+
ptyEnabled: opts.spawnPty != null,
|
|
4237
|
+
...opts.resolveAgentAdapter ? { resolveAgentAdapter: opts.resolveAgentAdapter } : {},
|
|
4238
|
+
...opts.listAgentAdapters ? { listAgentAdapters: opts.listAgentAdapters } : {}
|
|
4239
|
+
});
|
|
4240
|
+
return server;
|
|
4241
|
+
};
|
|
4242
|
+
if (opts.boot !== false) {
|
|
4243
|
+
await runBoot(workspace, opts, conversations, events).catch((err) => {
|
|
4244
|
+
events.emit({
|
|
4245
|
+
type: "heartbeat-error",
|
|
4246
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4247
|
+
agent: opts.defaultBootAgent,
|
|
4248
|
+
error: `BOOT.md failed: ${err instanceof Error ? err.message : String(err)}`
|
|
4249
|
+
});
|
|
4250
|
+
});
|
|
4251
|
+
}
|
|
4252
|
+
const heartbeat = startHeartbeat({
|
|
4253
|
+
workspace,
|
|
4254
|
+
conversations,
|
|
4255
|
+
events,
|
|
4256
|
+
buildAgent: opts.buildAgent ?? noopBuildAgent
|
|
4257
|
+
});
|
|
4258
|
+
const http = await startHttpServer({
|
|
4259
|
+
port,
|
|
4260
|
+
bind: opts.bind,
|
|
4261
|
+
// Auth is read on every request via this getter. `opts.auth`
|
|
4262
|
+
// (when provided) is the startup default and represents the
|
|
4263
|
+
// operator's intent — bearer-only deployments stay bearer-only
|
|
4264
|
+
// even when no remote tunnel is up. The controller's auth wins
|
|
4265
|
+
// once a tunnel is enabled (it issues a fresh token); on disable
|
|
4266
|
+
// we fall back to `opts.auth` if set, otherwise `mode: "none"`.
|
|
4267
|
+
auth: () => {
|
|
4268
|
+
const remoteAuth = remote.readAuth();
|
|
4269
|
+
if (remoteAuth.mode === "bearer") return remoteAuth;
|
|
4270
|
+
return opts.auth ?? { mode: "none" };
|
|
4271
|
+
},
|
|
4272
|
+
mcpServerFactory,
|
|
4273
|
+
conversations,
|
|
4274
|
+
events,
|
|
4275
|
+
heartbeat,
|
|
4276
|
+
sessions,
|
|
4277
|
+
mcpProxy,
|
|
4278
|
+
token,
|
|
4279
|
+
ptyEnabled: opts.spawnPty != null,
|
|
4280
|
+
...opts.allowedOrigins ? { allowedOrigins: opts.allowedOrigins } : {},
|
|
4281
|
+
...opts.strictOrigins ? { strictOrigins: true } : {},
|
|
4282
|
+
...opts.resolveAgentAdapter ? { resolveAgentAdapter: opts.resolveAgentAdapter } : {},
|
|
4283
|
+
...opts.listAgentAdapters ? { listAgentAdapters: opts.listAgentAdapters } : {},
|
|
4284
|
+
meta: { workspace, registered }
|
|
4285
|
+
});
|
|
4286
|
+
heartbeat.start();
|
|
4287
|
+
events.emit({
|
|
4288
|
+
type: "boot",
|
|
4289
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4290
|
+
workspace,
|
|
4291
|
+
registered
|
|
4292
|
+
});
|
|
4293
|
+
void writeRuntimeMeta(workspace, {
|
|
4294
|
+
workspace,
|
|
4295
|
+
port,
|
|
4296
|
+
bind: opts.bind ?? "127.0.0.1",
|
|
4297
|
+
pid: process.pid,
|
|
4298
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4299
|
+
name: opts.name ?? "agentproto-runtime",
|
|
4300
|
+
registered,
|
|
4301
|
+
token
|
|
4302
|
+
});
|
|
4303
|
+
return {
|
|
4304
|
+
url: http.url,
|
|
4305
|
+
workspace,
|
|
4306
|
+
workspaceFs,
|
|
4307
|
+
registered,
|
|
4308
|
+
sessions,
|
|
4309
|
+
token,
|
|
4310
|
+
async stop() {
|
|
4311
|
+
heartbeat.stop();
|
|
4312
|
+
sessions.shutdown();
|
|
4313
|
+
await mcpProxy.closeAll();
|
|
4314
|
+
await remote.shutdown();
|
|
4315
|
+
await http.stop();
|
|
4316
|
+
}
|
|
4317
|
+
};
|
|
4318
|
+
}
|
|
4319
|
+
var noopBuildAgent = async () => null;
|
|
4320
|
+
async function runBoot(workspace, opts, conversations, events) {
|
|
4321
|
+
const path = join(workspace, "BOOT.md");
|
|
4322
|
+
if (!existsSync(path)) return;
|
|
4323
|
+
const body = (await readFile(path, "utf8")).trim();
|
|
4324
|
+
if (!body) return;
|
|
4325
|
+
if (!opts.buildAgent || !opts.defaultBootAgent) return;
|
|
4326
|
+
const agent = await opts.buildAgent(opts.defaultBootAgent);
|
|
4327
|
+
if (!agent) return;
|
|
4328
|
+
const conversationId = `boot-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:.]/g, "-")}`;
|
|
4329
|
+
await conversations.open(conversationId, { agent: opts.defaultBootAgent });
|
|
4330
|
+
await conversations.appendTurn(conversationId, "user", body, {
|
|
4331
|
+
attribution: "boot"
|
|
4332
|
+
});
|
|
4333
|
+
const reply = await agent.generate(body);
|
|
4334
|
+
await conversations.appendTurn(conversationId, "assistant", reply.text, {
|
|
4335
|
+
attribution: opts.defaultBootAgent
|
|
4336
|
+
});
|
|
4337
|
+
events.emit({
|
|
4338
|
+
type: "heartbeat-fired",
|
|
4339
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4340
|
+
agent: opts.defaultBootAgent,
|
|
4341
|
+
conversationId,
|
|
4342
|
+
prompt: body,
|
|
4343
|
+
reply: reply.text,
|
|
4344
|
+
durationMs: 0
|
|
4345
|
+
});
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
export { createGateway, createWorkspaceFs, fileConversationStore, parseDuration, readRuntimeMeta, sweepStaleRuntimeMetas, unlinkRuntimeMeta };
|
|
4349
|
+
//# sourceMappingURL=index.mjs.map
|
|
4350
|
+
//# sourceMappingURL=index.mjs.map
|