@agentproto/cli 0.1.0-alpha.6 → 0.1.0-alpha.8
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/README.md +280 -17
- package/dist/cli.mjs +4168 -557
- package/dist/cli.mjs.map +1 -1
- package/dist/index.mjs +1697 -242
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -6
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import * as childProc from 'child_process';
|
|
1
2
|
import { execFile, spawn } from 'child_process';
|
|
2
3
|
import { createHash, randomUUID, randomBytes } from 'crypto';
|
|
3
|
-
import { mkdtemp, writeFile, chmod, rm, mkdir, readFile, readdir, stat } from 'fs/promises';
|
|
4
|
+
import { mkdtemp, writeFile, chmod, rm, mkdir, unlink, readFile, readdir, stat } from 'fs/promises';
|
|
4
5
|
import { tmpdir, userInfo, hostname, homedir, platform } from 'os';
|
|
5
6
|
import { resolve, join, dirname, isAbsolute, normalize, relative, basename } from 'path';
|
|
6
7
|
import { promisify, parseArgs } from 'util';
|
|
7
|
-
import { promises, existsSync } from 'fs';
|
|
8
|
+
import { promises, existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
8
9
|
import { createInterface } from 'readline/promises';
|
|
9
10
|
import { stdout, stdin } from 'process';
|
|
10
11
|
import { createAgentCliRuntime } from '@agentproto/driver-agent-cli';
|
|
@@ -16,11 +17,11 @@ import matter from 'gray-matter';
|
|
|
16
17
|
import { EventEmitter } from 'events';
|
|
17
18
|
import { createServer } from 'http';
|
|
18
19
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
20
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
19
21
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
20
22
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
21
23
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
22
24
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
23
|
-
import WebSocket from 'ws';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* @agentproto/cli v0.1.0-alpha
|
|
@@ -84,9 +85,12 @@ async function listInstalledAdapters(opts) {
|
|
|
84
85
|
};
|
|
85
86
|
out.push(info);
|
|
86
87
|
} catch (err) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
89
|
+
if (/Cannot find package|ERR_MODULE_NOT_FOUND/i.test(msg) && msg.includes(`@agentproto/adapter-${slug}`)) ; else {
|
|
90
|
+
console.warn(
|
|
91
|
+
`[agentproto/cli] listInstalledAdapters: skipping broken adapter '${slug}': ${msg}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
}
|
|
@@ -259,10 +263,10 @@ async function runExternalStep(step, ledger, head) {
|
|
|
259
263
|
`);
|
|
260
264
|
const opener = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
|
|
261
265
|
const args = platform() === "win32" ? ["/c", "start", step.url] : [step.url];
|
|
262
|
-
await new Promise((
|
|
266
|
+
await new Promise((resolve4) => {
|
|
263
267
|
const child = spawn(opener, args, { stdio: "ignore", detached: true });
|
|
264
|
-
child.once("error", () =>
|
|
265
|
-
child.once("spawn", () =>
|
|
268
|
+
child.once("error", () => resolve4());
|
|
269
|
+
child.once("spawn", () => resolve4());
|
|
266
270
|
});
|
|
267
271
|
let value = "";
|
|
268
272
|
if (step.callback?.param) {
|
|
@@ -335,7 +339,7 @@ async function promptString(question, opts) {
|
|
|
335
339
|
process.stdin.setRawMode?.(true);
|
|
336
340
|
let buf = "";
|
|
337
341
|
process.stdout.write(prompt);
|
|
338
|
-
return new Promise((
|
|
342
|
+
return new Promise((resolve4) => {
|
|
339
343
|
const onData = (chunk) => {
|
|
340
344
|
for (const code of chunk) {
|
|
341
345
|
if (code === 13 || code === 10) {
|
|
@@ -343,7 +347,7 @@ async function promptString(question, opts) {
|
|
|
343
347
|
process.stdin.setRawMode?.(false);
|
|
344
348
|
process.stdout.write("\n");
|
|
345
349
|
rl.close();
|
|
346
|
-
|
|
350
|
+
resolve4(buf || opts.defaultValue || "");
|
|
347
351
|
return;
|
|
348
352
|
}
|
|
349
353
|
if (code === 3) {
|
|
@@ -425,7 +429,7 @@ async function resolveSelectOptions(options) {
|
|
|
425
429
|
});
|
|
426
430
|
}
|
|
427
431
|
async function runShellCapturing(cmd, opts) {
|
|
428
|
-
return new Promise((
|
|
432
|
+
return new Promise((resolve4) => {
|
|
429
433
|
const child = spawn("bash", ["-lc", cmd], {
|
|
430
434
|
stdio: opts.interactive ? "inherit" : ["ignore", "pipe", "pipe"]
|
|
431
435
|
});
|
|
@@ -443,11 +447,11 @@ async function runShellCapturing(cmd, opts) {
|
|
|
443
447
|
}, opts.timeoutMs);
|
|
444
448
|
child.once("error", () => {
|
|
445
449
|
clearTimeout(timer);
|
|
446
|
-
|
|
450
|
+
resolve4({ exitCode: 127, stdout, stderr: stderr || "spawn error" });
|
|
447
451
|
});
|
|
448
452
|
child.once("exit", (code) => {
|
|
449
453
|
clearTimeout(timer);
|
|
450
|
-
|
|
454
|
+
resolve4({ exitCode: code ?? 0, stdout, stderr });
|
|
451
455
|
});
|
|
452
456
|
});
|
|
453
457
|
}
|
|
@@ -806,15 +810,15 @@ function homedir2() {
|
|
|
806
810
|
return process.env["HOME"] ?? process.env["USERPROFILE"] ?? "/";
|
|
807
811
|
}
|
|
808
812
|
function spawnInherit(cmd, argv) {
|
|
809
|
-
return new Promise((
|
|
813
|
+
return new Promise((resolve4, reject) => {
|
|
810
814
|
const child = spawn(cmd, argv, { stdio: "inherit" });
|
|
811
815
|
child.once("error", reject);
|
|
812
|
-
child.once("exit", (code) =>
|
|
816
|
+
child.once("exit", (code) => resolve4(code ?? 0));
|
|
813
817
|
});
|
|
814
818
|
}
|
|
815
819
|
async function runVersionCheck(check) {
|
|
816
820
|
if (!check) return { ok: false, message: "no version_check declared" };
|
|
817
|
-
return new Promise((
|
|
821
|
+
return new Promise((resolve4) => {
|
|
818
822
|
const child = spawn("bash", ["-lc", check.cmd], {
|
|
819
823
|
stdio: ["ignore", "pipe", "ignore"]
|
|
820
824
|
});
|
|
@@ -822,19 +826,19 @@ async function runVersionCheck(check) {
|
|
|
822
826
|
child.stdout.on("data", (c) => {
|
|
823
827
|
buf += c.toString("utf8");
|
|
824
828
|
});
|
|
825
|
-
child.once("error", () =>
|
|
829
|
+
child.once("error", () => resolve4({ ok: false, message: "check failed" }));
|
|
826
830
|
child.once("exit", (code) => {
|
|
827
831
|
if (code !== 0) {
|
|
828
|
-
|
|
832
|
+
resolve4({ ok: false, message: `check exited ${code}` });
|
|
829
833
|
return;
|
|
830
834
|
}
|
|
831
835
|
const re = new RegExp(check.parse);
|
|
832
836
|
const m = buf.match(re);
|
|
833
837
|
if (!m || !m[1]) {
|
|
834
|
-
|
|
838
|
+
resolve4({ ok: false, message: "could not parse version" });
|
|
835
839
|
return;
|
|
836
840
|
}
|
|
837
|
-
|
|
841
|
+
resolve4({ ok: true, message: `version ${m[1]}` });
|
|
838
842
|
});
|
|
839
843
|
});
|
|
840
844
|
}
|
|
@@ -1019,6 +1023,134 @@ function formatRelative(ms) {
|
|
|
1019
1023
|
return `${Math.round(ms / 864e5)}d`;
|
|
1020
1024
|
}
|
|
1021
1025
|
|
|
1026
|
+
// src/util/pty-factory.ts
|
|
1027
|
+
async function loadNodePtyFactory() {
|
|
1028
|
+
try {
|
|
1029
|
+
const nodePtyMod = await import('node-pty');
|
|
1030
|
+
const nodePty = nodePtyMod.default ?? nodePtyMod;
|
|
1031
|
+
if (typeof nodePty.spawn !== "function") return null;
|
|
1032
|
+
return (opts) => {
|
|
1033
|
+
const pty = nodePty.spawn(opts.command, opts.args, {
|
|
1034
|
+
name: "xterm-256color",
|
|
1035
|
+
cols: opts.cols,
|
|
1036
|
+
rows: opts.rows,
|
|
1037
|
+
cwd: opts.cwd,
|
|
1038
|
+
env: {
|
|
1039
|
+
...process.env,
|
|
1040
|
+
...opts.env ?? {}
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
const proc = {
|
|
1044
|
+
get pid() {
|
|
1045
|
+
return pty.pid;
|
|
1046
|
+
},
|
|
1047
|
+
write(data) {
|
|
1048
|
+
pty.write(data);
|
|
1049
|
+
},
|
|
1050
|
+
resize(cols, rows) {
|
|
1051
|
+
pty.resize(cols, rows);
|
|
1052
|
+
},
|
|
1053
|
+
kill(signal) {
|
|
1054
|
+
pty.kill(signal);
|
|
1055
|
+
},
|
|
1056
|
+
onData(handler) {
|
|
1057
|
+
pty.onData(handler);
|
|
1058
|
+
},
|
|
1059
|
+
onExit(handler) {
|
|
1060
|
+
pty.onExit(handler);
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
return proc;
|
|
1064
|
+
};
|
|
1065
|
+
} catch {
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
var CONFIG_FILE_PATH = () => join(homedir(), ".agentproto", "config.json");
|
|
1070
|
+
async function loadConfig(path) {
|
|
1071
|
+
const target = CONFIG_FILE_PATH();
|
|
1072
|
+
try {
|
|
1073
|
+
const raw = await promises.readFile(target, "utf8");
|
|
1074
|
+
const parsed = JSON.parse(raw);
|
|
1075
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1076
|
+
return parsed;
|
|
1077
|
+
}
|
|
1078
|
+
console.warn(
|
|
1079
|
+
`[runtime/config] ${target}: top-level value is not an object \u2014 ignoring`
|
|
1080
|
+
);
|
|
1081
|
+
return {};
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
const code = err.code;
|
|
1084
|
+
if (code && code !== "ENOENT") {
|
|
1085
|
+
console.warn(
|
|
1086
|
+
`[runtime/config] failed to read ${target}: ${err instanceof Error ? err.message : String(err)}`
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
return {};
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
var WORKSPACES_CONFIG_VERSION = 1;
|
|
1093
|
+
var DEFAULT_CONFIG_DIR = () => resolve(homedir(), ".agentproto");
|
|
1094
|
+
var DEFAULT_CONFIG_PATH = () => resolve(DEFAULT_CONFIG_DIR(), "workspaces.json");
|
|
1095
|
+
function sanitizeSlug(input2) {
|
|
1096
|
+
const trimmed = input2.trim().toLowerCase();
|
|
1097
|
+
const cleaned = trimmed.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
1098
|
+
return cleaned || "workspace";
|
|
1099
|
+
}
|
|
1100
|
+
async function loadWorkspacesConfig(path = DEFAULT_CONFIG_PATH()) {
|
|
1101
|
+
let raw;
|
|
1102
|
+
try {
|
|
1103
|
+
raw = await promises.readFile(path, "utf8");
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
if (err.code === "ENOENT") {
|
|
1106
|
+
return { version: WORKSPACES_CONFIG_VERSION, workspaces: [] };
|
|
1107
|
+
}
|
|
1108
|
+
throw err;
|
|
1109
|
+
}
|
|
1110
|
+
let parsed;
|
|
1111
|
+
try {
|
|
1112
|
+
parsed = JSON.parse(raw);
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
throw new Error(
|
|
1115
|
+
`agentproto: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete the file or fix it manually.`
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
return normalizeConfig(parsed);
|
|
1119
|
+
}
|
|
1120
|
+
function normalizeConfig(parsed) {
|
|
1121
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1122
|
+
return { version: WORKSPACES_CONFIG_VERSION, workspaces: [] };
|
|
1123
|
+
}
|
|
1124
|
+
const obj = parsed;
|
|
1125
|
+
const workspaces = [];
|
|
1126
|
+
if (Array.isArray(obj.workspaces)) {
|
|
1127
|
+
for (const entry of obj.workspaces) {
|
|
1128
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1129
|
+
const e = entry;
|
|
1130
|
+
const slug = typeof e.slug === "string" ? sanitizeSlug(e.slug) : "";
|
|
1131
|
+
const path = typeof e.path === "string" ? e.path : "";
|
|
1132
|
+
if (!slug || !path) continue;
|
|
1133
|
+
const addedAt = typeof e.addedAt === "string" ? e.addedAt : (/* @__PURE__ */ new Date()).toISOString();
|
|
1134
|
+
const updatedAt = typeof e.updatedAt === "string" ? e.updatedAt : addedAt;
|
|
1135
|
+
const we = { slug, path, addedAt, updatedAt };
|
|
1136
|
+
if (typeof e.label === "string" && e.label.trim()) {
|
|
1137
|
+
we.label = e.label.trim();
|
|
1138
|
+
}
|
|
1139
|
+
workspaces.push(we);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const dedup = /* @__PURE__ */ new Map();
|
|
1143
|
+
for (const w of workspaces) dedup.set(w.slug, w);
|
|
1144
|
+
const finalList = Array.from(dedup.values());
|
|
1145
|
+
const active = typeof obj.active === "string" && finalList.some((w) => w.slug === obj.active) ? obj.active : finalList[0]?.slug;
|
|
1146
|
+
const out = {
|
|
1147
|
+
version: WORKSPACES_CONFIG_VERSION,
|
|
1148
|
+
workspaces: finalList
|
|
1149
|
+
};
|
|
1150
|
+
if (active !== void 0) out.active = active;
|
|
1151
|
+
return out;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1022
1154
|
// ../define-doctype/dist/index.mjs
|
|
1023
1155
|
var DEFAULT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{1,79}$/;
|
|
1024
1156
|
var MAX_DESCRIPTION_LEN = 2e3;
|
|
@@ -1153,7 +1285,7 @@ function createVerbs(spec) {
|
|
|
1153
1285
|
}
|
|
1154
1286
|
return { path, handle: newHandle, rendered };
|
|
1155
1287
|
}
|
|
1156
|
-
async function
|
|
1288
|
+
async function resolve4(block, ctx = {}) {
|
|
1157
1289
|
if ("inline" in block) {
|
|
1158
1290
|
return spec.define(block.inline);
|
|
1159
1291
|
}
|
|
@@ -1179,7 +1311,7 @@ function createVerbs(spec) {
|
|
|
1179
1311
|
async function deleteFn(path) {
|
|
1180
1312
|
await rm(path, { force: true });
|
|
1181
1313
|
}
|
|
1182
|
-
return { create, load, list, update, resolve:
|
|
1314
|
+
return { create, load, list, update, resolve: resolve4, delete: deleteFn };
|
|
1183
1315
|
}
|
|
1184
1316
|
function defaultBody(spec, frontmatter) {
|
|
1185
1317
|
const id = typeof frontmatter.name === "string" ? frontmatter.name : typeof frontmatter.id === "string" ? frontmatter.id : typeof frontmatter.slug === "string" ? frontmatter.slug : spec.name;
|
|
@@ -1197,51 +1329,28 @@ createDoctype({
|
|
|
1197
1329
|
validate(def) {
|
|
1198
1330
|
const result = extensionFrontmatterSchema.safeParse(def);
|
|
1199
1331
|
if (!result.success) {
|
|
1200
|
-
throw new Error(
|
|
1201
|
-
`defineExtension (AIP-40): ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
|
|
1202
|
-
);
|
|
1332
|
+
throw new Error(`defineExtension (AIP-40): ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
|
|
1203
1333
|
}
|
|
1204
1334
|
},
|
|
1205
1335
|
build(def) {
|
|
1206
1336
|
return { ...def };
|
|
1207
1337
|
}
|
|
1208
1338
|
});
|
|
1209
|
-
function parseExtensionManifest(source) {
|
|
1210
|
-
const parsed = matter(source);
|
|
1211
|
-
if (Object.keys(parsed.data).length === 0) {
|
|
1212
|
-
throw new Error("parseExtensionManifest: missing or empty frontmatter");
|
|
1213
|
-
}
|
|
1214
|
-
const result = extensionFrontmatterSchema.safeParse(parsed.data);
|
|
1215
|
-
if (!result.success) {
|
|
1216
|
-
throw new Error(
|
|
1217
|
-
`parseExtensionManifest: invalid frontmatter \u2014 ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
|
|
1218
|
-
);
|
|
1219
|
-
}
|
|
1220
|
-
return { frontmatter: result.data, body: parsed.content };
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// ../extension/dist/index.mjs
|
|
1224
1339
|
function specFromExtension(extension, opts = {}) {
|
|
1225
1340
|
const { parent } = opts;
|
|
1226
1341
|
const isRoot = extension.extends === "none";
|
|
1227
1342
|
if (!isRoot && !parent) {
|
|
1228
|
-
throw new Error(
|
|
1229
|
-
`specFromExtension (AIP-40): extension '${extension.slug}' extends '${extension.extends}' but no parent spec was provided in opts.parent`
|
|
1230
|
-
);
|
|
1343
|
+
throw new Error(`specFromExtension (AIP-40): extension '${extension.slug}' extends '${extension.extends}' but no parent spec was provided in opts.parent`);
|
|
1231
1344
|
}
|
|
1232
1345
|
if (isRoot && !extension.path_convention) {
|
|
1233
|
-
throw new Error(
|
|
1234
|
-
`specFromExtension (AIP-40): extension '${extension.slug}' is a root doctype (extends: none) and MUST declare path_convention`
|
|
1235
|
-
);
|
|
1346
|
+
throw new Error(`specFromExtension (AIP-40): extension '${extension.slug}' is a root doctype (extends: none) and MUST declare path_convention`);
|
|
1236
1347
|
}
|
|
1237
1348
|
if (parent && extension.tighten) {
|
|
1238
1349
|
verifyTightening(extension);
|
|
1239
1350
|
}
|
|
1240
1351
|
const slugName = extension.slug.split(":")[1] ?? extension.slug;
|
|
1241
1352
|
const pathTemplate = extension.path_convention ?? null;
|
|
1242
|
-
const extensionKeys = new Set(
|
|
1243
|
-
Object.keys(extension.add_fields?.properties ?? {})
|
|
1244
|
-
);
|
|
1353
|
+
const extensionKeys = new Set(Object.keys(extension.add_fields?.properties ?? {}));
|
|
1245
1354
|
const define = (params) => {
|
|
1246
1355
|
const withDefaults = { ...params };
|
|
1247
1356
|
if (extension.defaults) {
|
|
@@ -1257,8 +1366,10 @@ function specFromExtension(extension, opts = {}) {
|
|
|
1257
1366
|
const parentOnly = {};
|
|
1258
1367
|
const extensionOnly = {};
|
|
1259
1368
|
for (const [key, value] of Object.entries(withDefaults)) {
|
|
1260
|
-
if (extensionKeys.has(key))
|
|
1261
|
-
|
|
1369
|
+
if (extensionKeys.has(key))
|
|
1370
|
+
extensionOnly[key] = value;
|
|
1371
|
+
else
|
|
1372
|
+
parentOnly[key] = value;
|
|
1262
1373
|
}
|
|
1263
1374
|
const parentHandle = parent.define(parentOnly);
|
|
1264
1375
|
return Object.freeze({
|
|
@@ -1269,11 +1380,7 @@ function specFromExtension(extension, opts = {}) {
|
|
|
1269
1380
|
const parse = isRoot ? rootParse(extension) : parent.parse;
|
|
1270
1381
|
const pathOf = (handle) => {
|
|
1271
1382
|
if (pathTemplate) {
|
|
1272
|
-
return resolvePathTemplate(
|
|
1273
|
-
pathTemplate,
|
|
1274
|
-
handle,
|
|
1275
|
-
slugName
|
|
1276
|
-
);
|
|
1383
|
+
return resolvePathTemplate(pathTemplate, handle, slugName);
|
|
1277
1384
|
}
|
|
1278
1385
|
return parent.pathOf(handle);
|
|
1279
1386
|
};
|
|
@@ -1290,33 +1397,36 @@ function verifyTightening(extension, parent) {
|
|
|
1290
1397
|
const t = extension.tighten ?? {};
|
|
1291
1398
|
for (const [field, override] of Object.entries(t)) {
|
|
1292
1399
|
if (typeof override.minLength === "number" && typeof override.maxLength === "number" && override.minLength > override.maxLength) {
|
|
1293
|
-
throw new Error(
|
|
1294
|
-
`specFromExtension (AIP-40): extension '${extension.slug}' tighten.${field}: minLength (${override.minLength}) > maxLength (${override.maxLength})`
|
|
1295
|
-
);
|
|
1400
|
+
throw new Error(`specFromExtension (AIP-40): extension '${extension.slug}' tighten.${field}: minLength (${override.minLength}) > maxLength (${override.maxLength})`);
|
|
1296
1401
|
}
|
|
1297
1402
|
if (typeof override.minimum === "number" && typeof override.maximum === "number" && override.minimum > override.maximum) {
|
|
1298
|
-
throw new Error(
|
|
1299
|
-
`specFromExtension (AIP-40): extension '${extension.slug}' tighten.${field}: minimum (${override.minimum}) > maximum (${override.maximum})`
|
|
1300
|
-
);
|
|
1403
|
+
throw new Error(`specFromExtension (AIP-40): extension '${extension.slug}' tighten.${field}: minimum (${override.minimum}) > maximum (${override.maximum})`);
|
|
1301
1404
|
}
|
|
1302
1405
|
if (override.enum !== void 0 && !Array.isArray(override.enum)) {
|
|
1303
|
-
throw new Error(
|
|
1304
|
-
`specFromExtension (AIP-40): extension '${extension.slug}' tighten.${field}: enum must be an array`
|
|
1305
|
-
);
|
|
1406
|
+
throw new Error(`specFromExtension (AIP-40): extension '${extension.slug}' tighten.${field}: enum must be an array`);
|
|
1306
1407
|
}
|
|
1307
1408
|
}
|
|
1308
1409
|
}
|
|
1309
1410
|
function rootParse(extension) {
|
|
1310
1411
|
return (source) => {
|
|
1311
|
-
throw new Error(
|
|
1312
|
-
`specFromExtension (AIP-40): extension '${extension.slug}' is a root doctype \u2014 supply your own parser or extend a public AIP for parser inheritance`
|
|
1313
|
-
);
|
|
1412
|
+
throw new Error(`specFromExtension (AIP-40): extension '${extension.slug}' is a root doctype \u2014 supply your own parser or extend a public AIP for parser inheritance`);
|
|
1314
1413
|
};
|
|
1315
1414
|
}
|
|
1316
1415
|
function resolvePathTemplate(template, handle, doctypeSlug) {
|
|
1317
1416
|
const identity = typeof handle.id === "string" && handle.id || typeof handle.slug === "string" && handle.slug || typeof handle.name === "string" && handle.name || "unknown";
|
|
1318
1417
|
return template.replace(/<slug>/g, identity).replace(/<DOCTYPE>/g, doctypeSlug.toUpperCase());
|
|
1319
1418
|
}
|
|
1419
|
+
function parseExtensionManifest(source) {
|
|
1420
|
+
const parsed = matter(source);
|
|
1421
|
+
if (Object.keys(parsed.data).length === 0) {
|
|
1422
|
+
throw new Error("parseExtensionManifest: missing or empty frontmatter");
|
|
1423
|
+
}
|
|
1424
|
+
const result = extensionFrontmatterSchema.safeParse(parsed.data);
|
|
1425
|
+
if (!result.success) {
|
|
1426
|
+
throw new Error(`parseExtensionManifest: invalid frontmatter \u2014 ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
|
|
1427
|
+
}
|
|
1428
|
+
return { frontmatter: result.data, body: parsed.content };
|
|
1429
|
+
}
|
|
1320
1430
|
|
|
1321
1431
|
// ../mcp-server/dist/index.mjs
|
|
1322
1432
|
async function createMcpServer(opts) {
|
|
@@ -1503,12 +1613,55 @@ async function writeRuntimeMeta(workspace, meta) {
|
|
|
1503
1613
|
await writeFile(
|
|
1504
1614
|
join(dir, "runtime.json"),
|
|
1505
1615
|
JSON.stringify(meta, null, 2) + "\n",
|
|
1506
|
-
"utf8"
|
|
1616
|
+
{ encoding: "utf8", mode: 384 }
|
|
1507
1617
|
);
|
|
1508
1618
|
} catch (err) {
|
|
1509
1619
|
console.error("[runtime] failed to write .agentproto/runtime.json:", err);
|
|
1510
1620
|
}
|
|
1511
1621
|
}
|
|
1622
|
+
async function unlinkRuntimeMeta(workspace) {
|
|
1623
|
+
try {
|
|
1624
|
+
await unlink(join(workspace, ".agentproto", "runtime.json"));
|
|
1625
|
+
} catch {
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
async function readRuntimeMeta(workspace) {
|
|
1629
|
+
const path = join(workspace, ".agentproto", "runtime.json");
|
|
1630
|
+
try {
|
|
1631
|
+
const [raw, st] = await Promise.all([readFile(path, "utf8"), stat(path)]);
|
|
1632
|
+
const meta = JSON.parse(raw);
|
|
1633
|
+
return { meta, mtime: st.mtime };
|
|
1634
|
+
} catch {
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
async function sweepStaleRuntimeMetas(workspaces, currentWorkspace) {
|
|
1639
|
+
const cleaned = [];
|
|
1640
|
+
for (const ws of workspaces) {
|
|
1641
|
+
if (ws === currentWorkspace) continue;
|
|
1642
|
+
const meta = await readRuntimeMeta(ws);
|
|
1643
|
+
if (!meta) continue;
|
|
1644
|
+
const pid = meta.meta.pid;
|
|
1645
|
+
if (typeof pid === "number" && !isPidAlive(pid)) {
|
|
1646
|
+
try {
|
|
1647
|
+
await unlink(join(ws, ".agentproto", "runtime.json"));
|
|
1648
|
+
cleaned.push(join(ws, ".agentproto", "runtime.json"));
|
|
1649
|
+
} catch {
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
return cleaned;
|
|
1654
|
+
}
|
|
1655
|
+
function isPidAlive(pid) {
|
|
1656
|
+
try {
|
|
1657
|
+
process.kill(pid, 0);
|
|
1658
|
+
return true;
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
const code = err.code;
|
|
1661
|
+
if (code === "EPERM") return true;
|
|
1662
|
+
return false;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1512
1665
|
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
1513
1666
|
var MAX_TIMEOUT_MS = 6e5;
|
|
1514
1667
|
var ALLOWLIST_REL = ".agentproto/allowed-commands.json";
|
|
@@ -1937,21 +2090,21 @@ function registerFsTools(server, opts) {
|
|
|
1937
2090
|
}
|
|
1938
2091
|
);
|
|
1939
2092
|
}
|
|
1940
|
-
var
|
|
1941
|
-
var
|
|
1942
|
-
var
|
|
1943
|
-
function
|
|
2093
|
+
var WORKSPACES_CONFIG_VERSION2 = 1;
|
|
2094
|
+
var DEFAULT_CONFIG_DIR2 = () => resolve(homedir(), ".agentproto");
|
|
2095
|
+
var DEFAULT_CONFIG_PATH2 = () => resolve(DEFAULT_CONFIG_DIR2(), "workspaces.json");
|
|
2096
|
+
function sanitizeSlug2(input2) {
|
|
1944
2097
|
const trimmed = input2.trim().toLowerCase();
|
|
1945
2098
|
const cleaned = trimmed.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
1946
2099
|
return cleaned || "workspace";
|
|
1947
2100
|
}
|
|
1948
|
-
async function
|
|
2101
|
+
async function loadWorkspacesConfig2(path = DEFAULT_CONFIG_PATH2()) {
|
|
1949
2102
|
let raw;
|
|
1950
2103
|
try {
|
|
1951
2104
|
raw = await promises.readFile(path, "utf8");
|
|
1952
2105
|
} catch (err) {
|
|
1953
2106
|
if (err.code === "ENOENT") {
|
|
1954
|
-
return { version:
|
|
2107
|
+
return { version: WORKSPACES_CONFIG_VERSION2, workspaces: [] };
|
|
1955
2108
|
}
|
|
1956
2109
|
throw err;
|
|
1957
2110
|
}
|
|
@@ -1963,18 +2116,18 @@ async function loadWorkspacesConfig(path = DEFAULT_CONFIG_PATH()) {
|
|
|
1963
2116
|
`agentproto: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete the file or fix it manually.`
|
|
1964
2117
|
);
|
|
1965
2118
|
}
|
|
1966
|
-
return
|
|
2119
|
+
return normalizeConfig2(parsed);
|
|
1967
2120
|
}
|
|
1968
2121
|
function findWorkspace(config, slug) {
|
|
1969
|
-
return config.workspaces.find((w) => w.slug ===
|
|
2122
|
+
return config.workspaces.find((w) => w.slug === sanitizeSlug2(slug));
|
|
1970
2123
|
}
|
|
1971
2124
|
function getActiveWorkspace(config) {
|
|
1972
2125
|
if (!config.active) return config.workspaces[0];
|
|
1973
2126
|
return findWorkspace(config, config.active) ?? config.workspaces[0];
|
|
1974
2127
|
}
|
|
1975
|
-
function
|
|
2128
|
+
function normalizeConfig2(parsed) {
|
|
1976
2129
|
if (!parsed || typeof parsed !== "object") {
|
|
1977
|
-
return { version:
|
|
2130
|
+
return { version: WORKSPACES_CONFIG_VERSION2, workspaces: [] };
|
|
1978
2131
|
}
|
|
1979
2132
|
const obj = parsed;
|
|
1980
2133
|
const workspaces = [];
|
|
@@ -1982,7 +2135,7 @@ function normaliseConfig(parsed) {
|
|
|
1982
2135
|
for (const entry of obj.workspaces) {
|
|
1983
2136
|
if (!entry || typeof entry !== "object") continue;
|
|
1984
2137
|
const e = entry;
|
|
1985
|
-
const slug = typeof e.slug === "string" ?
|
|
2138
|
+
const slug = typeof e.slug === "string" ? sanitizeSlug2(e.slug) : "";
|
|
1986
2139
|
const path = typeof e.path === "string" ? e.path : "";
|
|
1987
2140
|
if (!slug || !path) continue;
|
|
1988
2141
|
const addedAt = typeof e.addedAt === "string" ? e.addedAt : (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1999,7 +2152,7 @@ function normaliseConfig(parsed) {
|
|
|
1999
2152
|
const finalList = Array.from(dedup.values());
|
|
2000
2153
|
const active = typeof obj.active === "string" && finalList.some((w) => w.slug === obj.active) ? obj.active : finalList[0]?.slug;
|
|
2001
2154
|
const out = {
|
|
2002
|
-
version:
|
|
2155
|
+
version: WORKSPACES_CONFIG_VERSION2,
|
|
2003
2156
|
workspaces: finalList
|
|
2004
2157
|
};
|
|
2005
2158
|
if (active !== void 0) out.active = active;
|
|
@@ -2125,7 +2278,7 @@ async function scanGoose(home, out, errors) {
|
|
|
2125
2278
|
async function scanRegisteredWorkspaces(out, errors) {
|
|
2126
2279
|
let workspaces = [];
|
|
2127
2280
|
try {
|
|
2128
|
-
const cfg = await
|
|
2281
|
+
const cfg = await loadWorkspacesConfig2();
|
|
2129
2282
|
workspaces = cfg.workspaces;
|
|
2130
2283
|
} catch (err) {
|
|
2131
2284
|
errors.push(`workspaces: load failed \u2014 ${err.message}`);
|
|
@@ -2243,7 +2396,7 @@ async function loadImportedMcps(path = IMPORTED_MCPS_PATH()) {
|
|
|
2243
2396
|
`agentproto: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete or fix manually.`
|
|
2244
2397
|
);
|
|
2245
2398
|
}
|
|
2246
|
-
return
|
|
2399
|
+
return normalize3(parsed);
|
|
2247
2400
|
}
|
|
2248
2401
|
async function saveImportedMcps(config, path = IMPORTED_MCPS_PATH()) {
|
|
2249
2402
|
await promises.mkdir(dirname(path), { recursive: true });
|
|
@@ -2269,7 +2422,7 @@ function addImport(config, input2) {
|
|
|
2269
2422
|
function removeImport(config, id) {
|
|
2270
2423
|
return { ...config, imports: config.imports.filter((e) => e.id !== id) };
|
|
2271
2424
|
}
|
|
2272
|
-
function
|
|
2425
|
+
function normalize3(parsed) {
|
|
2273
2426
|
if (!parsed || typeof parsed !== "object") return EMPTY;
|
|
2274
2427
|
const obj = parsed;
|
|
2275
2428
|
const imports = [];
|
|
@@ -2289,6 +2442,7 @@ function normalise(parsed) {
|
|
|
2289
2442
|
}
|
|
2290
2443
|
function registerSessionTools(server, opts) {
|
|
2291
2444
|
const { registry, resolveAgentAdapter, listAgentAdapters, mcpProxy } = opts;
|
|
2445
|
+
const ptyEnabled = opts.ptyEnabled === true;
|
|
2292
2446
|
server.tool(
|
|
2293
2447
|
"start_agent_session",
|
|
2294
2448
|
"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.",
|
|
@@ -2325,7 +2479,7 @@ function registerSessionTools(server, opts) {
|
|
|
2325
2479
|
let resolvedSlug = input2.workspaceSlug ?? "default";
|
|
2326
2480
|
if (!cwd) {
|
|
2327
2481
|
try {
|
|
2328
|
-
const config = await
|
|
2482
|
+
const config = await loadWorkspacesConfig2();
|
|
2329
2483
|
const ws = input2.workspaceSlug ? findWorkspace(config, input2.workspaceSlug) : getActiveWorkspace(config);
|
|
2330
2484
|
if (ws) {
|
|
2331
2485
|
cwd = ws.path;
|
|
@@ -2420,17 +2574,44 @@ function registerSessionTools(server, opts) {
|
|
|
2420
2574
|
}
|
|
2421
2575
|
}
|
|
2422
2576
|
);
|
|
2577
|
+
server.tool(
|
|
2578
|
+
"list_sessions",
|
|
2579
|
+
"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.",
|
|
2580
|
+
{
|
|
2581
|
+
kind: z.enum(["terminal", "agent-cli", "command", "all"]).optional().describe(
|
|
2582
|
+
"Filter by session kind. `all` (default) returns every kind. Use `terminal` to list only PTY sessions, `agent-cli` for structured ACP agents."
|
|
2583
|
+
),
|
|
2584
|
+
onlyAlive: z.boolean().optional().describe("When true, only running/starting sessions. Default false."),
|
|
2585
|
+
status: z.enum(["starting", "running", "exited", "killed", "error"]).optional().describe("Filter by exact status (overrides onlyAlive).")
|
|
2586
|
+
},
|
|
2587
|
+
async (input2) => {
|
|
2588
|
+
let rows = registry.list();
|
|
2589
|
+
if (input2.kind && input2.kind !== "all") {
|
|
2590
|
+
rows = rows.filter((s) => s.kind === input2.kind);
|
|
2591
|
+
}
|
|
2592
|
+
if (input2.status) {
|
|
2593
|
+
rows = rows.filter((s) => s.status === input2.status);
|
|
2594
|
+
} else if (input2.onlyAlive) {
|
|
2595
|
+
rows = rows.filter(
|
|
2596
|
+
(s) => s.status === "running" || s.status === "starting"
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
return {
|
|
2600
|
+
content: [
|
|
2601
|
+
{ type: "text", text: JSON.stringify({ sessions: rows }, null, 2) }
|
|
2602
|
+
]
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
);
|
|
2423
2606
|
server.tool(
|
|
2424
2607
|
"list_agent_sessions",
|
|
2425
|
-
"
|
|
2608
|
+
"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.",
|
|
2426
2609
|
{
|
|
2427
|
-
onlyAlive: z.boolean().optional().describe("Filter to status running/starting
|
|
2610
|
+
onlyAlive: z.boolean().optional().describe("Filter to status running/starting only. Default false.")
|
|
2428
2611
|
},
|
|
2429
2612
|
async (input2) => {
|
|
2430
2613
|
const all = registry.list();
|
|
2431
|
-
const filtered = input2.onlyAlive ? all.filter(
|
|
2432
|
-
(s) => s.status === "running" || s.status === "starting" || s.status === "mounting"
|
|
2433
|
-
) : all;
|
|
2614
|
+
const filtered = input2.onlyAlive ? all.filter((s) => s.status === "running" || s.status === "starting") : all;
|
|
2434
2615
|
return {
|
|
2435
2616
|
content: [
|
|
2436
2617
|
{
|
|
@@ -2792,69 +2973,269 @@ function registerSessionTools(server, opts) {
|
|
|
2792
2973
|
};
|
|
2793
2974
|
}
|
|
2794
2975
|
);
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
let timer = null;
|
|
2801
|
-
let currentEveryMs = null;
|
|
2802
|
-
let firing = false;
|
|
2803
|
-
async function load() {
|
|
2804
|
-
if (!existsSync(path)) return null;
|
|
2805
|
-
let source;
|
|
2806
|
-
try {
|
|
2807
|
-
source = await readFile(path, "utf8");
|
|
2808
|
-
} catch {
|
|
2809
|
-
return null;
|
|
2810
|
-
}
|
|
2811
|
-
const parsed = matter(source);
|
|
2812
|
-
const fm = parsed.data;
|
|
2813
|
-
const agent = typeof fm.agent === "string" ? fm.agent : null;
|
|
2814
|
-
if (!agent) return null;
|
|
2815
|
-
const enabled = fm.enabled === void 0 ? true : Boolean(fm.enabled);
|
|
2816
|
-
const everyRaw = typeof fm.every === "string" ? fm.every : "60s";
|
|
2817
|
-
const everyMs = parseDuration(everyRaw);
|
|
2818
|
-
if (!everyMs) return null;
|
|
2819
|
-
const conversationTemplate = typeof fm.conversation === "string" ? fm.conversation : "heartbeat-{date}";
|
|
2820
|
-
return {
|
|
2821
|
-
agent,
|
|
2822
|
-
everyMs,
|
|
2823
|
-
enabled,
|
|
2824
|
-
conversationTemplate,
|
|
2825
|
-
body: parsed.content.trim()
|
|
2826
|
-
};
|
|
2827
|
-
}
|
|
2828
|
-
async function tick() {
|
|
2829
|
-
if (firing) return;
|
|
2830
|
-
if (opts.shouldFire && !opts.shouldFire()) return;
|
|
2831
|
-
firing = true;
|
|
2832
|
-
let agentId;
|
|
2833
|
-
try {
|
|
2834
|
-
const hb = await load();
|
|
2835
|
-
if (!hb || !hb.enabled) return;
|
|
2836
|
-
agentId = hb.agent;
|
|
2837
|
-
const agent = await opts.buildAgent(hb.agent);
|
|
2838
|
-
if (!agent) {
|
|
2839
|
-
opts.events.emit({
|
|
2840
|
-
type: "heartbeat-error",
|
|
2841
|
-
at: now().toISOString(),
|
|
2842
|
-
agent: hb.agent,
|
|
2843
|
-
error: `buildAgent('${hb.agent}') returned null`
|
|
2844
|
-
});
|
|
2845
|
-
return;
|
|
2976
|
+
const ptyNotConfigured = (toolName) => ({
|
|
2977
|
+
content: [
|
|
2978
|
+
{
|
|
2979
|
+
type: "text",
|
|
2980
|
+
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).`
|
|
2846
2981
|
}
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2982
|
+
],
|
|
2983
|
+
isError: true
|
|
2984
|
+
});
|
|
2985
|
+
server.tool(
|
|
2986
|
+
"start_terminal_session",
|
|
2987
|
+
"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.",
|
|
2988
|
+
{
|
|
2989
|
+
argv: z.array(z.string()).min(1).describe(
|
|
2990
|
+
"Argv array. First element is the binary, rest are arguments. e.g. ['claude'] or ['bash', '-l']."
|
|
2991
|
+
),
|
|
2992
|
+
workspaceSlug: z.string().optional().describe(
|
|
2993
|
+
"Workspace slug from `agentproto workspace list`. Resolves cwd. Omit to use `cwd` explicitly or the active workspace."
|
|
2994
|
+
),
|
|
2995
|
+
cwd: z.string().optional().describe("Absolute cwd. Wins over workspaceSlug when both set."),
|
|
2996
|
+
cols: z.number().int().min(1).max(500).optional().describe("Initial cols. Default 80."),
|
|
2997
|
+
rows: z.number().int().min(1).max(200).optional().describe("Initial rows. Default 24."),
|
|
2998
|
+
name: z.string().optional().describe(
|
|
2999
|
+
"User-friendly slug. Becomes an alias for the session id in subsequent tool calls (read/write/kill accept either)."
|
|
3000
|
+
),
|
|
3001
|
+
label: z.string().optional().describe(
|
|
3002
|
+
"Free-text label surfaced in list_agent_sessions and the UI."
|
|
3003
|
+
)
|
|
3004
|
+
},
|
|
3005
|
+
async (input2) => {
|
|
3006
|
+
if (!ptyEnabled) return ptyNotConfigured("start_terminal_session");
|
|
3007
|
+
let cwd = input2.cwd;
|
|
3008
|
+
let resolvedSlug = input2.workspaceSlug ?? "default";
|
|
3009
|
+
if (!cwd) {
|
|
3010
|
+
try {
|
|
3011
|
+
const config = await loadWorkspacesConfig2();
|
|
3012
|
+
const ws = input2.workspaceSlug ? findWorkspace(config, input2.workspaceSlug) : getActiveWorkspace(config);
|
|
3013
|
+
if (ws) {
|
|
3014
|
+
cwd = ws.path;
|
|
3015
|
+
resolvedSlug = ws.slug;
|
|
3016
|
+
}
|
|
3017
|
+
} catch {
|
|
3018
|
+
}
|
|
2855
3019
|
}
|
|
2856
|
-
|
|
2857
|
-
|
|
3020
|
+
if (!cwd) {
|
|
3021
|
+
return {
|
|
3022
|
+
content: [
|
|
3023
|
+
{
|
|
3024
|
+
type: "text",
|
|
3025
|
+
text: "start_terminal_session: no cwd resolvable. Pass `cwd` explicitly or `workspaceSlug` matching `agentproto workspace list`."
|
|
3026
|
+
}
|
|
3027
|
+
],
|
|
3028
|
+
isError: true
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
try {
|
|
3032
|
+
const desc = registry.spawnPty({
|
|
3033
|
+
argv: input2.argv,
|
|
3034
|
+
cwd,
|
|
3035
|
+
workspaceSlug: resolvedSlug,
|
|
3036
|
+
cols: input2.cols ?? 80,
|
|
3037
|
+
rows: input2.rows ?? 24,
|
|
3038
|
+
...input2.name ? { name: input2.name } : {},
|
|
3039
|
+
...input2.label ? { label: input2.label } : {}
|
|
3040
|
+
});
|
|
3041
|
+
return {
|
|
3042
|
+
content: [{ type: "text", text: JSON.stringify(desc, null, 2) }]
|
|
3043
|
+
};
|
|
3044
|
+
} catch (err) {
|
|
3045
|
+
return {
|
|
3046
|
+
content: [
|
|
3047
|
+
{
|
|
3048
|
+
type: "text",
|
|
3049
|
+
text: `start_terminal_session: ${err instanceof Error ? err.message : String(err)}`
|
|
3050
|
+
}
|
|
3051
|
+
],
|
|
3052
|
+
isError: true
|
|
3053
|
+
};
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
);
|
|
3057
|
+
server.tool(
|
|
3058
|
+
"write_terminal_input",
|
|
3059
|
+
"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.",
|
|
3060
|
+
{
|
|
3061
|
+
sessionId: z.string().describe("Session id OR name from start_terminal_session."),
|
|
3062
|
+
text: z.string().describe("Text to write. Sent as-is to the PTY's stdin.")
|
|
3063
|
+
},
|
|
3064
|
+
async (input2) => {
|
|
3065
|
+
if (!ptyEnabled) return ptyNotConfigured("write_terminal_input");
|
|
3066
|
+
const desc = registry.findByIdOrName(input2.sessionId);
|
|
3067
|
+
if (!desc) {
|
|
3068
|
+
return {
|
|
3069
|
+
content: [
|
|
3070
|
+
{
|
|
3071
|
+
type: "text",
|
|
3072
|
+
text: `write_terminal_input: no session "${input2.sessionId}"`
|
|
3073
|
+
}
|
|
3074
|
+
],
|
|
3075
|
+
isError: true
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
const ok = registry.writeTerminalInput(desc.id, input2.text);
|
|
3079
|
+
return {
|
|
3080
|
+
content: [
|
|
3081
|
+
{
|
|
3082
|
+
type: "text",
|
|
3083
|
+
text: JSON.stringify({ ok, sessionId: desc.id }, null, 2)
|
|
3084
|
+
}
|
|
3085
|
+
],
|
|
3086
|
+
...ok ? {} : { isError: true }
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
);
|
|
3090
|
+
server.tool(
|
|
3091
|
+
"read_terminal_output",
|
|
3092
|
+
"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.",
|
|
3093
|
+
{
|
|
3094
|
+
sessionId: z.string().describe("Session id OR name from start_terminal_session."),
|
|
3095
|
+
lastBytes: z.number().int().min(1).max(64 * 1024).optional().describe("Max bytes from the tail. Default: full ring buffer (~64 KiB).")
|
|
3096
|
+
},
|
|
3097
|
+
async (input2) => {
|
|
3098
|
+
if (!ptyEnabled) return ptyNotConfigured("read_terminal_output");
|
|
3099
|
+
const desc = registry.findByIdOrName(input2.sessionId);
|
|
3100
|
+
if (!desc) {
|
|
3101
|
+
return {
|
|
3102
|
+
content: [
|
|
3103
|
+
{
|
|
3104
|
+
type: "text",
|
|
3105
|
+
text: `read_terminal_output: no session "${input2.sessionId}"`
|
|
3106
|
+
}
|
|
3107
|
+
],
|
|
3108
|
+
isError: true
|
|
3109
|
+
};
|
|
3110
|
+
}
|
|
3111
|
+
const buf = registry.readTerminalOutput(
|
|
3112
|
+
desc.id,
|
|
3113
|
+
input2.lastBytes
|
|
3114
|
+
);
|
|
3115
|
+
if (!buf) {
|
|
3116
|
+
return {
|
|
3117
|
+
content: [
|
|
3118
|
+
{
|
|
3119
|
+
type: "text",
|
|
3120
|
+
text: `read_terminal_output: session "${desc.id}" is not a PTY`
|
|
3121
|
+
}
|
|
3122
|
+
],
|
|
3123
|
+
isError: true
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
return {
|
|
3127
|
+
content: [
|
|
3128
|
+
{
|
|
3129
|
+
type: "text",
|
|
3130
|
+
text: JSON.stringify(
|
|
3131
|
+
{
|
|
3132
|
+
sessionId: desc.id,
|
|
3133
|
+
status: desc.status,
|
|
3134
|
+
bytes: buf.byteLength,
|
|
3135
|
+
b64: buf.toString("base64")
|
|
3136
|
+
},
|
|
3137
|
+
null,
|
|
3138
|
+
2
|
|
3139
|
+
)
|
|
3140
|
+
}
|
|
3141
|
+
]
|
|
3142
|
+
};
|
|
3143
|
+
}
|
|
3144
|
+
);
|
|
3145
|
+
server.tool(
|
|
3146
|
+
"kill_terminal_session",
|
|
3147
|
+
"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.",
|
|
3148
|
+
{
|
|
3149
|
+
sessionId: z.string().describe("Session id OR name from start_terminal_session.")
|
|
3150
|
+
},
|
|
3151
|
+
async (input2) => {
|
|
3152
|
+
if (!ptyEnabled) return ptyNotConfigured("kill_terminal_session");
|
|
3153
|
+
const desc = registry.findByIdOrName(input2.sessionId);
|
|
3154
|
+
if (!desc) {
|
|
3155
|
+
return {
|
|
3156
|
+
content: [
|
|
3157
|
+
{
|
|
3158
|
+
type: "text",
|
|
3159
|
+
text: `kill_terminal_session: no session "${input2.sessionId}"`
|
|
3160
|
+
}
|
|
3161
|
+
],
|
|
3162
|
+
isError: true
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
const ok = registry.kill(desc.id);
|
|
3166
|
+
return {
|
|
3167
|
+
content: [
|
|
3168
|
+
{
|
|
3169
|
+
type: "text",
|
|
3170
|
+
text: JSON.stringify({ ok, sessionId: desc.id }, null, 2)
|
|
3171
|
+
}
|
|
3172
|
+
]
|
|
3173
|
+
};
|
|
3174
|
+
}
|
|
3175
|
+
);
|
|
3176
|
+
}
|
|
3177
|
+
function startHeartbeat(opts) {
|
|
3178
|
+
const filename = opts.filename ?? "HEARTBEAT.md";
|
|
3179
|
+
const path = join(opts.workspace, filename);
|
|
3180
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
3181
|
+
let timer = null;
|
|
3182
|
+
let currentEveryMs = null;
|
|
3183
|
+
let firing = false;
|
|
3184
|
+
async function load() {
|
|
3185
|
+
if (!existsSync(path)) return null;
|
|
3186
|
+
let source;
|
|
3187
|
+
try {
|
|
3188
|
+
source = await readFile(path, "utf8");
|
|
3189
|
+
} catch {
|
|
3190
|
+
return null;
|
|
3191
|
+
}
|
|
3192
|
+
const parsed = matter(source);
|
|
3193
|
+
const fm = parsed.data;
|
|
3194
|
+
const agent = typeof fm.agent === "string" ? fm.agent : null;
|
|
3195
|
+
if (!agent) return null;
|
|
3196
|
+
const enabled = fm.enabled === void 0 ? true : Boolean(fm.enabled);
|
|
3197
|
+
const everyRaw = typeof fm.every === "string" ? fm.every : "60s";
|
|
3198
|
+
const everyMs = parseDuration(everyRaw);
|
|
3199
|
+
if (!everyMs) return null;
|
|
3200
|
+
const conversationTemplate = typeof fm.conversation === "string" ? fm.conversation : "heartbeat-{date}";
|
|
3201
|
+
return {
|
|
3202
|
+
agent,
|
|
3203
|
+
everyMs,
|
|
3204
|
+
enabled,
|
|
3205
|
+
conversationTemplate,
|
|
3206
|
+
body: parsed.content.trim()
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
async function tick() {
|
|
3210
|
+
if (firing) return;
|
|
3211
|
+
if (opts.shouldFire && !opts.shouldFire()) return;
|
|
3212
|
+
firing = true;
|
|
3213
|
+
let agentId;
|
|
3214
|
+
try {
|
|
3215
|
+
const hb = await load();
|
|
3216
|
+
if (!hb || !hb.enabled) return;
|
|
3217
|
+
agentId = hb.agent;
|
|
3218
|
+
const agent = await opts.buildAgent(hb.agent);
|
|
3219
|
+
if (!agent) {
|
|
3220
|
+
opts.events.emit({
|
|
3221
|
+
type: "heartbeat-error",
|
|
3222
|
+
at: now().toISOString(),
|
|
3223
|
+
agent: hb.agent,
|
|
3224
|
+
error: `buildAgent('${hb.agent}') returned null`
|
|
3225
|
+
});
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
if (!hb.body) {
|
|
3229
|
+
opts.events.emit({
|
|
3230
|
+
type: "heartbeat-error",
|
|
3231
|
+
at: now().toISOString(),
|
|
3232
|
+
agent: hb.agent,
|
|
3233
|
+
error: "HEARTBEAT.md body is empty"
|
|
3234
|
+
});
|
|
3235
|
+
return;
|
|
3236
|
+
}
|
|
3237
|
+
const conversationId = expandTemplate(hb.conversationTemplate, now());
|
|
3238
|
+
await opts.conversations.open(conversationId, { agent: hb.agent });
|
|
2858
3239
|
const startedAt = now();
|
|
2859
3240
|
await opts.conversations.appendTurn(
|
|
2860
3241
|
conversationId,
|
|
@@ -2964,6 +3345,11 @@ function preview(text3, max = 120) {
|
|
|
2964
3345
|
const flat = text3.replace(/\s+/g, " ").trim();
|
|
2965
3346
|
return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
|
|
2966
3347
|
}
|
|
3348
|
+
var DEFAULT_ALLOWED_ORIGINS = [
|
|
3349
|
+
"http://localhost:*",
|
|
3350
|
+
"http://127.0.0.1:*",
|
|
3351
|
+
"https://localhost:*"
|
|
3352
|
+
];
|
|
2967
3353
|
async function startHttpServer(opts) {
|
|
2968
3354
|
const startedAt = Date.now();
|
|
2969
3355
|
const authSource = opts.auth ?? { mode: "none" };
|
|
@@ -2988,15 +3374,69 @@ async function startHttpServer(opts) {
|
|
|
2988
3374
|
}
|
|
2989
3375
|
return true;
|
|
2990
3376
|
}
|
|
3377
|
+
function checkSessionsToken(req) {
|
|
3378
|
+
if (!opts.token) return "ok";
|
|
3379
|
+
const header = req.headers.authorization;
|
|
3380
|
+
const expected = `Bearer ${opts.token}`;
|
|
3381
|
+
if (header === expected) return "ok";
|
|
3382
|
+
const urlStr = req.url ?? "";
|
|
3383
|
+
if (urlStr.includes("?")) {
|
|
3384
|
+
const qs = new URLSearchParams(urlStr.slice(urlStr.indexOf("?") + 1));
|
|
3385
|
+
const qsToken = qs.get("token");
|
|
3386
|
+
if (qsToken && qsToken === opts.token) return "ok";
|
|
3387
|
+
}
|
|
3388
|
+
const origin = req.headers.origin;
|
|
3389
|
+
if (typeof origin === "string" && originAllowed(origin)) return "ok";
|
|
3390
|
+
return header ? "bad" : "missing";
|
|
3391
|
+
}
|
|
3392
|
+
function originAllowed(origin) {
|
|
3393
|
+
const list = opts.strictOrigins ? opts.allowedOrigins ?? [] : [...DEFAULT_ALLOWED_ORIGINS, ...opts.allowedOrigins ?? []];
|
|
3394
|
+
for (const pattern of list) {
|
|
3395
|
+
if (pattern === origin) return true;
|
|
3396
|
+
if (pattern.endsWith(":*")) {
|
|
3397
|
+
const prefix = pattern.slice(0, -2);
|
|
3398
|
+
if (origin === prefix || origin.startsWith(prefix + ":")) return true;
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
return false;
|
|
3402
|
+
}
|
|
3403
|
+
function rejectUnauthorizedSession(req, res, kind) {
|
|
3404
|
+
const origin = req.headers.origin ?? null;
|
|
3405
|
+
const allowList = opts.strictOrigins ? opts.allowedOrigins ?? [] : [...DEFAULT_ALLOWED_ORIGINS, ...opts.allowedOrigins ?? []];
|
|
3406
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
3407
|
+
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.`;
|
|
3408
|
+
res.end(
|
|
3409
|
+
JSON.stringify({
|
|
3410
|
+
error: "sessions_unauthorized",
|
|
3411
|
+
message,
|
|
3412
|
+
// Debug data so a UI / network-tab inspection points the user
|
|
3413
|
+
// straight at the fix without needing the daemon's stderr.
|
|
3414
|
+
rejectedOrigin: origin,
|
|
3415
|
+
allowedOrigins: allowList,
|
|
3416
|
+
// We never echo the token; only the absence-or-presence flag.
|
|
3417
|
+
receivedAuthHeader: typeof req.headers.authorization === "string"
|
|
3418
|
+
})
|
|
3419
|
+
);
|
|
3420
|
+
}
|
|
3421
|
+
function isMutatingSessionsRoute(method, path) {
|
|
3422
|
+
if (!path.startsWith("/sessions")) return false;
|
|
3423
|
+
if (method === "POST" || method === "DELETE" || method === "PUT" || method === "PATCH") {
|
|
3424
|
+
return true;
|
|
3425
|
+
}
|
|
3426
|
+
return false;
|
|
3427
|
+
}
|
|
2991
3428
|
async function handleMcp(req, res) {
|
|
2992
3429
|
if (!authorize(req, res)) return;
|
|
2993
3430
|
const server2 = await opts.mcpServerFactory();
|
|
2994
3431
|
const transport = new StreamableHTTPServerTransport({
|
|
2995
3432
|
sessionIdGenerator: void 0
|
|
2996
3433
|
});
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3434
|
+
const transportInternal = transport;
|
|
3435
|
+
if ("onerror" in transport) {
|
|
3436
|
+
transportInternal["onerror"] = (err) => {
|
|
3437
|
+
console.error("[mcp transport.onerror]", err);
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3000
3440
|
res.on("close", () => {
|
|
3001
3441
|
void transport.close();
|
|
3002
3442
|
void server2.close();
|
|
@@ -3073,6 +3513,77 @@ async function startHttpServer(opts) {
|
|
|
3073
3513
|
res.writeHead(202, { "content-type": "application/json" });
|
|
3074
3514
|
res.end(JSON.stringify({ status: "fired" }));
|
|
3075
3515
|
}
|
|
3516
|
+
async function handleFileUpload(req, res, url) {
|
|
3517
|
+
const reply = (status, body) => {
|
|
3518
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
3519
|
+
res.end(JSON.stringify(body));
|
|
3520
|
+
};
|
|
3521
|
+
const qs = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
|
|
3522
|
+
const cwd = qs.get("cwd");
|
|
3523
|
+
const rawName = qs.get("name");
|
|
3524
|
+
if (!cwd || !rawName) {
|
|
3525
|
+
reply(400, { error: "missing_cwd_or_name" });
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
if (!isAbsolute(cwd)) {
|
|
3529
|
+
reply(400, { error: "cwd_not_absolute" });
|
|
3530
|
+
return;
|
|
3531
|
+
}
|
|
3532
|
+
const safeName = rawName.replace(/[/\\]/g, "_").replace(/\.\.+/g, ".").replace(/^\.+/, "").slice(0, 200);
|
|
3533
|
+
if (safeName.length === 0) {
|
|
3534
|
+
reply(400, { error: "invalid_name" });
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
try {
|
|
3538
|
+
const st = await stat(cwd);
|
|
3539
|
+
if (!st.isDirectory()) {
|
|
3540
|
+
reply(400, { error: "cwd_not_a_directory" });
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
} catch {
|
|
3544
|
+
reply(400, { error: "cwd_not_found" });
|
|
3545
|
+
return;
|
|
3546
|
+
}
|
|
3547
|
+
const dir = join(cwd, ".agentproto-attachments");
|
|
3548
|
+
const target = resolve(dir, safeName);
|
|
3549
|
+
if (!target.startsWith(resolve(dir) + (dir.endsWith("/") ? "" : "/"))) {
|
|
3550
|
+
reply(400, { error: "path_traversal_blocked" });
|
|
3551
|
+
return;
|
|
3552
|
+
}
|
|
3553
|
+
const MAX_BYTES = 32 * 1024 * 1024;
|
|
3554
|
+
const chunks = [];
|
|
3555
|
+
let total = 0;
|
|
3556
|
+
let aborted = false;
|
|
3557
|
+
await new Promise((resolveBody, rejectBody) => {
|
|
3558
|
+
req.on("data", (chunk) => {
|
|
3559
|
+
if (aborted) return;
|
|
3560
|
+
const buf = chunk;
|
|
3561
|
+
total += buf.length;
|
|
3562
|
+
if (total > MAX_BYTES) {
|
|
3563
|
+
aborted = true;
|
|
3564
|
+
reply(413, { error: "file_too_large", maxBytes: MAX_BYTES });
|
|
3565
|
+
rejectBody(new Error("too_large"));
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
chunks.push(buf);
|
|
3569
|
+
});
|
|
3570
|
+
req.on("end", () => {
|
|
3571
|
+
if (!aborted) resolveBody();
|
|
3572
|
+
});
|
|
3573
|
+
req.on("error", (err) => rejectBody(err));
|
|
3574
|
+
}).catch(() => void 0);
|
|
3575
|
+
if (aborted) return;
|
|
3576
|
+
try {
|
|
3577
|
+
await mkdir(dir, { recursive: true });
|
|
3578
|
+
await writeFile(target, Buffer.concat(chunks));
|
|
3579
|
+
reply(200, { path: target, bytes: total });
|
|
3580
|
+
} catch (err) {
|
|
3581
|
+
reply(500, {
|
|
3582
|
+
error: "write_failed",
|
|
3583
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3584
|
+
});
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3076
3587
|
function applyCors(req, res) {
|
|
3077
3588
|
const origin = req.headers.origin ?? "*";
|
|
3078
3589
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
@@ -3086,6 +3597,9 @@ async function startHttpServer(opts) {
|
|
|
3086
3597
|
"Access-Control-Allow-Methods",
|
|
3087
3598
|
"GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
|
3088
3599
|
);
|
|
3600
|
+
if (req.headers["access-control-request-private-network"] === "true") {
|
|
3601
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
3602
|
+
}
|
|
3089
3603
|
}
|
|
3090
3604
|
const server = createServer((req, res) => {
|
|
3091
3605
|
void (async () => {
|
|
@@ -3130,19 +3644,36 @@ async function startHttpServer(opts) {
|
|
|
3130
3644
|
await handleHeartbeatTick(req, res);
|
|
3131
3645
|
return;
|
|
3132
3646
|
}
|
|
3647
|
+
if (path === "/files/upload" && req.method === "POST") {
|
|
3648
|
+
const gate = checkSessionsToken(req);
|
|
3649
|
+
if (gate !== "ok") {
|
|
3650
|
+
rejectUnauthorizedSession(req, res, gate);
|
|
3651
|
+
return;
|
|
3652
|
+
}
|
|
3653
|
+
await handleFileUpload(req, res, url);
|
|
3654
|
+
return;
|
|
3655
|
+
}
|
|
3133
3656
|
if (opts.sessions && path.startsWith("/sessions")) {
|
|
3657
|
+
if (isMutatingSessionsRoute(req.method ?? "GET", path)) {
|
|
3658
|
+
const gate = checkSessionsToken(req);
|
|
3659
|
+
if (gate !== "ok") {
|
|
3660
|
+
rejectUnauthorizedSession(req, res, gate);
|
|
3661
|
+
return;
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3134
3664
|
const handled = await handleSessions(
|
|
3135
3665
|
req,
|
|
3136
3666
|
res,
|
|
3137
3667
|
path,
|
|
3138
3668
|
opts.sessions,
|
|
3139
|
-
opts.resolveAgentAdapter
|
|
3669
|
+
opts.resolveAgentAdapter,
|
|
3670
|
+
opts.ptyEnabled === true
|
|
3140
3671
|
);
|
|
3141
3672
|
if (handled) return;
|
|
3142
3673
|
}
|
|
3143
3674
|
if (path === "/workspaces" && req.method === "GET") {
|
|
3144
3675
|
try {
|
|
3145
|
-
const config = await
|
|
3676
|
+
const config = await loadWorkspacesConfig2();
|
|
3146
3677
|
res.writeHead(200, { "content-type": "application/json" });
|
|
3147
3678
|
res.end(JSON.stringify(config));
|
|
3148
3679
|
} catch (err) {
|
|
@@ -3326,19 +3857,179 @@ async function startHttpServer(opts) {
|
|
|
3326
3857
|
}
|
|
3327
3858
|
})();
|
|
3328
3859
|
});
|
|
3860
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
3861
|
+
server.on("upgrade", (req, socket, head) => {
|
|
3862
|
+
const url = req.url ?? "/";
|
|
3863
|
+
const path = url.split("?")[0] ?? "/";
|
|
3864
|
+
const ptyMatch = path.match(/^\/sessions\/([^/]+)\/pty$/);
|
|
3865
|
+
if (!ptyMatch) {
|
|
3866
|
+
socket.destroy();
|
|
3867
|
+
return;
|
|
3868
|
+
}
|
|
3869
|
+
if (!opts.sessions || opts.ptyEnabled !== true) {
|
|
3870
|
+
rejectUpgrade(socket, 501, "pty_not_configured");
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
const gate = checkSessionsToken(req);
|
|
3874
|
+
if (gate !== "ok") {
|
|
3875
|
+
rejectUpgrade(socket, 401, gate === "missing" ? "missing_token" : "bad_token");
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
3878
|
+
const id = ptyMatch[1];
|
|
3879
|
+
if (!id) {
|
|
3880
|
+
rejectUpgrade(socket, 400, "missing_id");
|
|
3881
|
+
return;
|
|
3882
|
+
}
|
|
3883
|
+
const desc = opts.sessions.findByIdOrName(id);
|
|
3884
|
+
if (!desc) {
|
|
3885
|
+
rejectUpgrade(socket, 404, "session_not_found");
|
|
3886
|
+
return;
|
|
3887
|
+
}
|
|
3888
|
+
if (desc.pty !== true) {
|
|
3889
|
+
rejectUpgrade(socket, 400, "session_not_pty");
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
if (desc.status === "exited" || desc.status === "killed" || desc.status === "error") {
|
|
3893
|
+
rejectUpgrade(
|
|
3894
|
+
socket,
|
|
3895
|
+
410,
|
|
3896
|
+
`session_${desc.status}`,
|
|
3897
|
+
`Session ${desc.id}${desc.name ? ` (${desc.name})` : ""} has ${desc.status}. Spawn a fresh one with: agentproto sessions restart ${desc.name ?? desc.id}`
|
|
3898
|
+
);
|
|
3899
|
+
return;
|
|
3900
|
+
}
|
|
3901
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
3902
|
+
handlePtyWebSocket(ws, req, desc.id, opts.sessions);
|
|
3903
|
+
});
|
|
3904
|
+
});
|
|
3329
3905
|
const bind = opts.bind ?? "127.0.0.1";
|
|
3330
|
-
await new Promise((
|
|
3906
|
+
await new Promise((resolve8, reject) => {
|
|
3331
3907
|
server.once("error", reject);
|
|
3332
|
-
server.listen(opts.port, bind, () =>
|
|
3908
|
+
server.listen(opts.port, bind, () => resolve8());
|
|
3333
3909
|
});
|
|
3334
3910
|
return {
|
|
3335
3911
|
url: `http://${bind}:${opts.port}`,
|
|
3336
3912
|
async stop() {
|
|
3337
|
-
|
|
3913
|
+
wss.close();
|
|
3914
|
+
await new Promise((resolve8) => server.close(() => resolve8()));
|
|
3338
3915
|
}
|
|
3339
3916
|
};
|
|
3340
3917
|
}
|
|
3341
|
-
|
|
3918
|
+
function rejectUpgrade(socket, status, reason, message) {
|
|
3919
|
+
const reasonText = `${status} ${reason}`;
|
|
3920
|
+
const body = { error: reason };
|
|
3921
|
+
if (message) body.message = message;
|
|
3922
|
+
try {
|
|
3923
|
+
socket.write(
|
|
3924
|
+
`HTTP/1.1 ${reasonText}\r
|
|
3925
|
+
Content-Type: application/json\r
|
|
3926
|
+
Connection: close\r
|
|
3927
|
+
\r
|
|
3928
|
+
` + JSON.stringify(body) + `
|
|
3929
|
+
`
|
|
3930
|
+
);
|
|
3931
|
+
} catch {
|
|
3932
|
+
}
|
|
3933
|
+
socket.destroy();
|
|
3934
|
+
}
|
|
3935
|
+
function handlePtyWebSocket(ws, req, sessionId, registry) {
|
|
3936
|
+
const query = new URLSearchParams(
|
|
3937
|
+
(req.url ?? "").includes("?") ? (req.url ?? "").split("?")[1] : ""
|
|
3938
|
+
);
|
|
3939
|
+
const cols = clampPositiveInt(query.get("cols"), 80);
|
|
3940
|
+
const rows = clampPositiveInt(query.get("rows"), 24);
|
|
3941
|
+
const BACKPRESSURE_BYTES = 256 * 1024;
|
|
3942
|
+
const handle = registry.attachPty(
|
|
3943
|
+
sessionId,
|
|
3944
|
+
{ cols, rows },
|
|
3945
|
+
(chunk) => {
|
|
3946
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
3947
|
+
if (ws.bufferedAmount > BACKPRESSURE_BYTES) return;
|
|
3948
|
+
ws.send(
|
|
3949
|
+
JSON.stringify({ kind: "data", b64: chunk.toString("base64") })
|
|
3950
|
+
);
|
|
3951
|
+
},
|
|
3952
|
+
(evt) => {
|
|
3953
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
3954
|
+
ws.send(
|
|
3955
|
+
JSON.stringify({
|
|
3956
|
+
kind: "exit",
|
|
3957
|
+
exitCode: evt.exitCode,
|
|
3958
|
+
...evt.signal != null ? { signal: evt.signal } : {}
|
|
3959
|
+
})
|
|
3960
|
+
);
|
|
3961
|
+
try {
|
|
3962
|
+
ws.close(1e3, "session exited");
|
|
3963
|
+
} catch {
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
);
|
|
3967
|
+
if (!handle) {
|
|
3968
|
+
try {
|
|
3969
|
+
ws.close(1011, "session not attachable");
|
|
3970
|
+
} catch {
|
|
3971
|
+
}
|
|
3972
|
+
return;
|
|
3973
|
+
}
|
|
3974
|
+
ws.on("message", (raw, isBinary) => {
|
|
3975
|
+
if (isBinary) {
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
let frame;
|
|
3979
|
+
try {
|
|
3980
|
+
frame = JSON.parse(raw.toString("utf8"));
|
|
3981
|
+
} catch {
|
|
3982
|
+
return;
|
|
3983
|
+
}
|
|
3984
|
+
if (!frame || typeof frame !== "object") return;
|
|
3985
|
+
const f = frame;
|
|
3986
|
+
switch (f.kind) {
|
|
3987
|
+
case "input": {
|
|
3988
|
+
if (typeof f.text === "string") {
|
|
3989
|
+
handle.write(f.text);
|
|
3990
|
+
} else if (typeof f.b64 === "string") {
|
|
3991
|
+
try {
|
|
3992
|
+
handle.write(Buffer.from(f.b64, "base64").toString("utf8"));
|
|
3993
|
+
} catch {
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
break;
|
|
3997
|
+
}
|
|
3998
|
+
case "resize": {
|
|
3999
|
+
const c = typeof f.cols === "number" && f.cols > 0 ? Math.floor(f.cols) : null;
|
|
4000
|
+
const r = typeof f.rows === "number" && f.rows > 0 ? Math.floor(f.rows) : null;
|
|
4001
|
+
if (c && r) handle.resize(c, r);
|
|
4002
|
+
break;
|
|
4003
|
+
}
|
|
4004
|
+
case "ping":
|
|
4005
|
+
try {
|
|
4006
|
+
ws.send(JSON.stringify({ kind: "pong" }));
|
|
4007
|
+
} catch {
|
|
4008
|
+
}
|
|
4009
|
+
break;
|
|
4010
|
+
}
|
|
4011
|
+
});
|
|
4012
|
+
ws.on("close", () => {
|
|
4013
|
+
handle.detach();
|
|
4014
|
+
});
|
|
4015
|
+
ws.on("error", () => {
|
|
4016
|
+
handle.detach();
|
|
4017
|
+
});
|
|
4018
|
+
}
|
|
4019
|
+
function clampPositiveInt(raw, fallback) {
|
|
4020
|
+
if (!raw) return fallback;
|
|
4021
|
+
const n = Number.parseInt(raw, 10);
|
|
4022
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
4023
|
+
}
|
|
4024
|
+
function clampInt(raw, fallback, min, max) {
|
|
4025
|
+
if (!raw) return fallback;
|
|
4026
|
+
const n = Number.parseInt(raw, 10);
|
|
4027
|
+
if (!Number.isFinite(n)) return fallback;
|
|
4028
|
+
if (n < min) return min;
|
|
4029
|
+
if (n > max) return max;
|
|
4030
|
+
return n;
|
|
4031
|
+
}
|
|
4032
|
+
async function handleSessions(req, res, path, registry, resolveAgentAdapter, ptyEnabled = false) {
|
|
3342
4033
|
const json = (status, body) => {
|
|
3343
4034
|
res.writeHead(status, { "content-type": "application/json" });
|
|
3344
4035
|
res.end(JSON.stringify(body));
|
|
@@ -3370,7 +4061,7 @@ async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
|
|
|
3370
4061
|
let workspaceSlug = typeof b.workspaceSlug === "string" ? b.workspaceSlug : "";
|
|
3371
4062
|
if (!cwd) {
|
|
3372
4063
|
try {
|
|
3373
|
-
const config = await
|
|
4064
|
+
const config = await loadWorkspacesConfig2();
|
|
3374
4065
|
const ws = workspaceSlug ? findWorkspace(config, workspaceSlug) : getActiveWorkspace(config);
|
|
3375
4066
|
if (ws) {
|
|
3376
4067
|
cwd = ws.path;
|
|
@@ -3392,7 +4083,11 @@ async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
|
|
|
3392
4083
|
return true;
|
|
3393
4084
|
}
|
|
3394
4085
|
try {
|
|
3395
|
-
const
|
|
4086
|
+
const resumeSessionId = typeof b.resumeSessionId === "string" && b.resumeSessionId.length > 0 ? b.resumeSessionId : void 0;
|
|
4087
|
+
const agentSession = await resolved.startSession({
|
|
4088
|
+
cwd,
|
|
4089
|
+
...resumeSessionId ? { resumeSessionId } : {}
|
|
4090
|
+
});
|
|
3396
4091
|
const desc = registry.spawnAgent({
|
|
3397
4092
|
workspaceSlug,
|
|
3398
4093
|
cwd,
|
|
@@ -3411,14 +4106,78 @@ async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
|
|
|
3411
4106
|
}
|
|
3412
4107
|
return true;
|
|
3413
4108
|
}
|
|
4109
|
+
if (path === "/sessions/terminal" && req.method === "POST") {
|
|
4110
|
+
if (!ptyEnabled) {
|
|
4111
|
+
json(501, {
|
|
4112
|
+
error: "pty_not_configured",
|
|
4113
|
+
message: "POST /sessions/terminal needs the host to inject `spawnPty` into createGateway (node-pty optional dep \u2014 install in @agentproto/cli)."
|
|
4114
|
+
});
|
|
4115
|
+
return true;
|
|
4116
|
+
}
|
|
4117
|
+
const body = await readJsonBody(req);
|
|
4118
|
+
if (!body || typeof body !== "object") {
|
|
4119
|
+
json(400, { error: "invalid_body" });
|
|
4120
|
+
return true;
|
|
4121
|
+
}
|
|
4122
|
+
const b = body;
|
|
4123
|
+
const argv = Array.isArray(b.argv) ? b.argv.filter((v) => typeof v === "string") : [];
|
|
4124
|
+
if (argv.length === 0) {
|
|
4125
|
+
json(400, { error: "missing_argv" });
|
|
4126
|
+
return true;
|
|
4127
|
+
}
|
|
4128
|
+
const cols = typeof b.cols === "number" && b.cols > 0 ? Math.floor(b.cols) : 80;
|
|
4129
|
+
const rows = typeof b.rows === "number" && b.rows > 0 ? Math.floor(b.rows) : 24;
|
|
4130
|
+
let cwd = typeof b.cwd === "string" && b.cwd.length > 0 ? b.cwd : null;
|
|
4131
|
+
let workspaceSlug = typeof b.workspaceSlug === "string" ? b.workspaceSlug : "";
|
|
4132
|
+
if (!cwd) {
|
|
4133
|
+
try {
|
|
4134
|
+
const config = await loadWorkspacesConfig2();
|
|
4135
|
+
const ws = workspaceSlug ? findWorkspace(config, workspaceSlug) : getActiveWorkspace(config);
|
|
4136
|
+
if (ws) {
|
|
4137
|
+
cwd = ws.path;
|
|
4138
|
+
workspaceSlug = ws.slug;
|
|
4139
|
+
}
|
|
4140
|
+
} catch {
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
if (!cwd) {
|
|
4144
|
+
cwd = process.cwd();
|
|
4145
|
+
console.warn(
|
|
4146
|
+
`[/sessions/terminal] no cwd resolvable for argv=${argv.join(" ")} \u2014 falling back to daemon's cwd ${cwd}`
|
|
4147
|
+
);
|
|
4148
|
+
}
|
|
4149
|
+
if (!workspaceSlug) workspaceSlug = "default";
|
|
4150
|
+
try {
|
|
4151
|
+
const desc = registry.spawnPty({
|
|
4152
|
+
argv,
|
|
4153
|
+
cwd,
|
|
4154
|
+
workspaceSlug,
|
|
4155
|
+
cols,
|
|
4156
|
+
rows,
|
|
4157
|
+
...b.env && typeof b.env === "object" ? { env: b.env } : {},
|
|
4158
|
+
...typeof b.name === "string" ? { name: b.name } : {},
|
|
4159
|
+
...typeof b.label === "string" ? { label: b.label } : {}
|
|
4160
|
+
});
|
|
4161
|
+
json(201, desc);
|
|
4162
|
+
} catch (err) {
|
|
4163
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4164
|
+
const status = msg.includes("already in use") ? 409 : msg.includes("no PTY factory") ? 501 : 500;
|
|
4165
|
+
json(status, { error: "pty_spawn_failed", message: msg });
|
|
4166
|
+
}
|
|
4167
|
+
return true;
|
|
4168
|
+
}
|
|
3414
4169
|
const promptMatch = path.match(/^\/sessions\/([^/]+)\/prompt$/);
|
|
3415
4170
|
if (promptMatch && req.method === "POST") {
|
|
3416
4171
|
const id2 = promptMatch[1];
|
|
3417
4172
|
if (!id2) return false;
|
|
3418
4173
|
const body = await readJsonBody(req);
|
|
3419
4174
|
const prompt = body?.prompt;
|
|
3420
|
-
|
|
3421
|
-
|
|
4175
|
+
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);
|
|
4176
|
+
if (!validPrompt) {
|
|
4177
|
+
json(400, {
|
|
4178
|
+
error: "missing_prompt",
|
|
4179
|
+
message: "Body `prompt` must be a non-empty string, a content block object, or an array of content blocks."
|
|
4180
|
+
});
|
|
3422
4181
|
return true;
|
|
3423
4182
|
}
|
|
3424
4183
|
const reqUrl = req.url ?? "";
|
|
@@ -3470,14 +4229,43 @@ async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
|
|
|
3470
4229
|
}
|
|
3471
4230
|
return true;
|
|
3472
4231
|
}
|
|
3473
|
-
const idMatch = path.match(
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
if (!
|
|
4232
|
+
const idMatch = path.match(
|
|
4233
|
+
/^\/sessions\/([^/]+)(\/stream|\/kill|\/preview)?$/
|
|
4234
|
+
);
|
|
4235
|
+
if (!idMatch) return false;
|
|
4236
|
+
const [, rawIdOrName, suffix] = idMatch;
|
|
4237
|
+
if (!rawIdOrName) return false;
|
|
4238
|
+
const resolvedDesc = registry.findByIdOrName(rawIdOrName);
|
|
4239
|
+
const id = resolvedDesc?.id ?? rawIdOrName;
|
|
4240
|
+
if (suffix === "/preview" && req.method === "GET") {
|
|
4241
|
+
if (!resolvedDesc) {
|
|
4242
|
+
json(404, { error: "session_not_found", id: rawIdOrName });
|
|
4243
|
+
return true;
|
|
4244
|
+
}
|
|
4245
|
+
const reqUrl = req.url ?? "";
|
|
4246
|
+
const qs = new URLSearchParams(
|
|
4247
|
+
reqUrl.includes("?") ? reqUrl.slice(reqUrl.indexOf("?") + 1) : ""
|
|
4248
|
+
);
|
|
4249
|
+
const lineCap = clampInt(qs.get("lines"), 10, 1, 200);
|
|
4250
|
+
const byteCap = clampInt(qs.get("bytes"), 16 * 1024, 256, 64 * 1024);
|
|
4251
|
+
const lines = [];
|
|
4252
|
+
if (resolvedDesc.pty !== true) {
|
|
4253
|
+
const unsub = registry.attach(id, (line) => {
|
|
4254
|
+
lines.push(line);
|
|
4255
|
+
});
|
|
4256
|
+
if (unsub) unsub();
|
|
4257
|
+
}
|
|
4258
|
+
const bufBytes = registry.readTerminalOutput(id, byteCap);
|
|
4259
|
+
json(200, {
|
|
4260
|
+
id: resolvedDesc.id,
|
|
4261
|
+
lines: lines.slice(-lineCap),
|
|
4262
|
+
bytes: bufBytes ? bufBytes.toString("base64") : null
|
|
4263
|
+
});
|
|
4264
|
+
return true;
|
|
4265
|
+
}
|
|
3477
4266
|
if (suffix === "/stream" && req.method === "GET") {
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
json(404, { error: "session_not_found", id });
|
|
4267
|
+
if (!resolvedDesc) {
|
|
4268
|
+
json(404, { error: "session_not_found", id: rawIdOrName });
|
|
3481
4269
|
return true;
|
|
3482
4270
|
}
|
|
3483
4271
|
res.writeHead(200, {
|
|
@@ -3521,12 +4309,11 @@ async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
|
|
|
3521
4309
|
return true;
|
|
3522
4310
|
}
|
|
3523
4311
|
if (!suffix && req.method === "GET") {
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
json(404, { error: "session_not_found", id });
|
|
4312
|
+
if (!resolvedDesc) {
|
|
4313
|
+
json(404, { error: "session_not_found", id: rawIdOrName });
|
|
3527
4314
|
return true;
|
|
3528
4315
|
}
|
|
3529
|
-
json(200,
|
|
4316
|
+
json(200, resolvedDesc);
|
|
3530
4317
|
return true;
|
|
3531
4318
|
}
|
|
3532
4319
|
if (!suffix && req.method === "DELETE") {
|
|
@@ -3548,14 +4335,80 @@ async function readJsonBody(req) {
|
|
|
3548
4335
|
return null;
|
|
3549
4336
|
}
|
|
3550
4337
|
}
|
|
4338
|
+
var RESUME_STRATEGIES = {
|
|
4339
|
+
"claude-code": {
|
|
4340
|
+
// Printed by claude on graceful exit when session persistence
|
|
4341
|
+
// is on (default). Example: `claude --resume 0e483f81-1a44-4bec-9667-b37158450296`
|
|
4342
|
+
outputHint: /claude\s+--resume\s+([0-9a-f-]{8,})/i,
|
|
4343
|
+
storeAs: "claudeResumeId",
|
|
4344
|
+
fsProbe: probeMtimeLatestJsonl(".claude/projects"),
|
|
4345
|
+
spawnArgs: (id) => ["claude", "--resume", id]
|
|
4346
|
+
}
|
|
4347
|
+
// Stubs for other shipped adapters — fill in as we learn each
|
|
4348
|
+
// provider's resume mechanism. Today they fall back to ACP-level
|
|
4349
|
+
// resume (whatever the agent-cli runtime supports) or fresh spawn.
|
|
4350
|
+
//
|
|
4351
|
+
// hermes: { storeAs: "hermesResumeId", ... }
|
|
4352
|
+
// codex: { storeAs: "codexResumeId", ... }
|
|
4353
|
+
// openclaw: { storeAs: "openClawResumeId", ... }
|
|
4354
|
+
// opencode: { storeAs: "openCodeResumeId", ... }
|
|
4355
|
+
};
|
|
4356
|
+
function probeMtimeLatestJsonl(storeRel) {
|
|
4357
|
+
return async (cwd, prevStartedAt) => {
|
|
4358
|
+
const encoded = cwd.replace(/\//g, "-");
|
|
4359
|
+
const dir = resolve(homedir(), storeRel, encoded);
|
|
4360
|
+
let entries;
|
|
4361
|
+
try {
|
|
4362
|
+
entries = await promises.readdir(dir);
|
|
4363
|
+
} catch {
|
|
4364
|
+
return null;
|
|
4365
|
+
}
|
|
4366
|
+
const jsonl = entries.filter((e) => e.endsWith(".jsonl"));
|
|
4367
|
+
if (jsonl.length === 0) return null;
|
|
4368
|
+
const startedAtMs = Date.parse(prevStartedAt);
|
|
4369
|
+
const candidates = [];
|
|
4370
|
+
for (const f of jsonl) {
|
|
4371
|
+
try {
|
|
4372
|
+
const st = await promises.stat(join(dir, f));
|
|
4373
|
+
if (!Number.isFinite(startedAtMs) || st.mtimeMs >= startedAtMs - 1e3) {
|
|
4374
|
+
candidates.push({ name: f, mtime: st.mtimeMs });
|
|
4375
|
+
}
|
|
4376
|
+
} catch {
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
4379
|
+
if (candidates.length === 0) return null;
|
|
4380
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
4381
|
+
return candidates[0].name.replace(/\.jsonl$/, "");
|
|
4382
|
+
};
|
|
4383
|
+
}
|
|
3551
4384
|
var SESSIONS_FILE_PATH = () => resolve(homedir(), ".agentproto", "sessions.json");
|
|
3552
4385
|
var RECENT_LINES_CAP = 500;
|
|
4386
|
+
var RECENT_BYTES_CAP = 64 * 1024;
|
|
3553
4387
|
var PERSIST_DEBOUNCE_MS = 1500;
|
|
4388
|
+
var HISTORY_CAP = 200;
|
|
4389
|
+
function stripAnsiCodes(s) {
|
|
4390
|
+
return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
|
|
4391
|
+
}
|
|
3554
4392
|
function createSessionsRegistry(opts) {
|
|
3555
|
-
const persistPath = SESSIONS_FILE_PATH();
|
|
4393
|
+
const persistPath = opts?.persistPath ?? SESSIONS_FILE_PATH();
|
|
4394
|
+
const persist = opts?.persist ?? true;
|
|
4395
|
+
const ptyFactory = opts?.spawnPty;
|
|
4396
|
+
const resumeAgent = opts?.resumeAgent;
|
|
3556
4397
|
const sessions = /* @__PURE__ */ new Map();
|
|
3557
4398
|
let persistTimer = null;
|
|
4399
|
+
let nextSubId = 1;
|
|
4400
|
+
let shutdownDone = false;
|
|
4401
|
+
if (persist) {
|
|
4402
|
+
loadHistorySnapshot(persistPath, sessions);
|
|
4403
|
+
}
|
|
4404
|
+
const onProcessExit = () => {
|
|
4405
|
+
shutdownImpl();
|
|
4406
|
+
};
|
|
4407
|
+
if (persist) {
|
|
4408
|
+
process.on("exit", onProcessExit);
|
|
4409
|
+
}
|
|
3558
4410
|
const schedulePersist = () => {
|
|
4411
|
+
if (!persist) return;
|
|
3559
4412
|
if (persistTimer) clearTimeout(persistTimer);
|
|
3560
4413
|
persistTimer = setTimeout(() => {
|
|
3561
4414
|
void persistSnapshot();
|
|
@@ -3581,8 +4434,47 @@ function createSessionsRegistry(opts) {
|
|
|
3581
4434
|
rt.recentLines.splice(0, rt.recentLines.length - RECENT_LINES_CAP);
|
|
3582
4435
|
}
|
|
3583
4436
|
rt.desc.lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4437
|
+
sniffResumeHints(rt, line);
|
|
3584
4438
|
rt.emitter.emit("line", { line, stream });
|
|
3585
4439
|
};
|
|
4440
|
+
const sniffResumeHints = (rt, line) => {
|
|
4441
|
+
if (rt.desc.kind !== "agent-cli") return;
|
|
4442
|
+
const strategy = rt.desc.adapterSlug ? RESUME_STRATEGIES[rt.desc.adapterSlug] : void 0;
|
|
4443
|
+
if (!strategy?.outputHint) return;
|
|
4444
|
+
const plain = stripAnsiCodes(line);
|
|
4445
|
+
const m = plain.match(strategy.outputHint);
|
|
4446
|
+
if (m && m[1]) {
|
|
4447
|
+
rt.desc.resumeMetadata = {
|
|
4448
|
+
...rt.desc.resumeMetadata ?? {},
|
|
4449
|
+
[strategy.storeAs]: m[1]
|
|
4450
|
+
};
|
|
4451
|
+
schedulePersist();
|
|
4452
|
+
}
|
|
4453
|
+
};
|
|
4454
|
+
const appendBytes = (rt, chunk) => {
|
|
4455
|
+
rt.recentBytes.push(chunk);
|
|
4456
|
+
rt.recentBytesSize += chunk.byteLength;
|
|
4457
|
+
while (rt.recentBytesSize > RECENT_BYTES_CAP && rt.recentBytes.length > 1) {
|
|
4458
|
+
const dropped = rt.recentBytes.shift();
|
|
4459
|
+
if (dropped) rt.recentBytesSize -= dropped.byteLength;
|
|
4460
|
+
}
|
|
4461
|
+
rt.desc.lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4462
|
+
rt.emitter.emit("data", chunk);
|
|
4463
|
+
};
|
|
4464
|
+
const reconcilePtySize = (rt) => {
|
|
4465
|
+
if (!rt.pty || !rt.ptySubscribers || rt.ptySubscribers.size === 0) return;
|
|
4466
|
+
let cols = Infinity;
|
|
4467
|
+
let rows = Infinity;
|
|
4468
|
+
for (const dim of rt.ptySubscribers.values()) {
|
|
4469
|
+
if (dim.cols < cols) cols = dim.cols;
|
|
4470
|
+
if (dim.rows < rows) rows = dim.rows;
|
|
4471
|
+
}
|
|
4472
|
+
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
|
4473
|
+
try {
|
|
4474
|
+
rt.pty.resize(cols, rows);
|
|
4475
|
+
} catch {
|
|
4476
|
+
}
|
|
4477
|
+
};
|
|
3586
4478
|
const projectEvent = (rt, evt) => {
|
|
3587
4479
|
switch (evt.kind) {
|
|
3588
4480
|
case "text-delta":
|
|
@@ -3671,6 +4563,59 @@ function createSessionsRegistry(opts) {
|
|
|
3671
4563
|
}
|
|
3672
4564
|
return rt;
|
|
3673
4565
|
};
|
|
4566
|
+
const maybeResumeAgent = async (rt) => {
|
|
4567
|
+
if (rt.agentSession) return;
|
|
4568
|
+
if (!resumeAgent) return;
|
|
4569
|
+
if (rt.desc.kind !== "agent-cli") return;
|
|
4570
|
+
const adapterSlug = rt.desc.adapterSlug ?? rt.adapterSlug;
|
|
4571
|
+
const adapterSessionId = rt.desc.adapterSessionId;
|
|
4572
|
+
const cwd = rt.desc.cwd;
|
|
4573
|
+
if (!adapterSlug || !adapterSessionId || !cwd) return;
|
|
4574
|
+
if (rt.resumePromise) {
|
|
4575
|
+
await rt.resumePromise;
|
|
4576
|
+
return;
|
|
4577
|
+
}
|
|
4578
|
+
rt.resumePromise = (async () => {
|
|
4579
|
+
appendLine(
|
|
4580
|
+
rt,
|
|
4581
|
+
`\u2500\u2500 resuming ${adapterSlug} session ${adapterSessionId} \u2500\u2500`,
|
|
4582
|
+
"stdout"
|
|
4583
|
+
);
|
|
4584
|
+
try {
|
|
4585
|
+
const fresh = await resumeAgent({
|
|
4586
|
+
adapterSlug,
|
|
4587
|
+
cwd,
|
|
4588
|
+
resumeSessionId: adapterSessionId
|
|
4589
|
+
});
|
|
4590
|
+
if (!fresh) {
|
|
4591
|
+
appendLine(
|
|
4592
|
+
rt,
|
|
4593
|
+
`[error] resume failed: adapter '${adapterSlug}' returned null`,
|
|
4594
|
+
"stderr"
|
|
4595
|
+
);
|
|
4596
|
+
return;
|
|
4597
|
+
}
|
|
4598
|
+
rt.agentSession = fresh;
|
|
4599
|
+
rt.adapterSlug = adapterSlug;
|
|
4600
|
+
rt.desc.adapterSessionId = fresh.sessionId;
|
|
4601
|
+
if (rt.desc.status !== "running") {
|
|
4602
|
+
rt.desc.status = "running";
|
|
4603
|
+
delete rt.desc.endedAt;
|
|
4604
|
+
delete rt.desc.exitCode;
|
|
4605
|
+
rt.emitter.emit("status", rt.desc.status);
|
|
4606
|
+
}
|
|
4607
|
+
schedulePersist();
|
|
4608
|
+
} catch (err) {
|
|
4609
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4610
|
+
appendLine(rt, `[error] resume failed: ${msg}`, "stderr");
|
|
4611
|
+
}
|
|
4612
|
+
})();
|
|
4613
|
+
try {
|
|
4614
|
+
await rt.resumePromise;
|
|
4615
|
+
} finally {
|
|
4616
|
+
rt.resumePromise = void 0;
|
|
4617
|
+
}
|
|
4618
|
+
};
|
|
3674
4619
|
const runAgentTurn = async (rt, message) => {
|
|
3675
4620
|
if (!rt.agentSession) {
|
|
3676
4621
|
throw new Error("runAgentTurn: session has no agentSession");
|
|
@@ -3734,12 +4679,16 @@ function createSessionsRegistry(opts) {
|
|
|
3734
4679
|
pid: child.pid ?? null,
|
|
3735
4680
|
status: "starting",
|
|
3736
4681
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4682
|
+
argv: input2.argv,
|
|
4683
|
+
cwd: input2.cwd,
|
|
3737
4684
|
...input2.label ? { label: input2.label } : {}
|
|
3738
4685
|
};
|
|
3739
4686
|
const rt = {
|
|
3740
4687
|
desc,
|
|
3741
4688
|
child,
|
|
3742
4689
|
recentLines: [],
|
|
4690
|
+
recentBytes: [],
|
|
4691
|
+
recentBytesSize: 0,
|
|
3743
4692
|
emitter: new EventEmitter(),
|
|
3744
4693
|
busy: false,
|
|
3745
4694
|
textBuf: "",
|
|
@@ -3792,6 +4741,8 @@ function createSessionsRegistry(opts) {
|
|
|
3792
4741
|
desc,
|
|
3793
4742
|
child: input2.child,
|
|
3794
4743
|
recentLines: [],
|
|
4744
|
+
recentBytes: [],
|
|
4745
|
+
recentBytesSize: 0,
|
|
3795
4746
|
emitter: new EventEmitter(),
|
|
3796
4747
|
busy: false,
|
|
3797
4748
|
textBuf: "",
|
|
@@ -3829,6 +4780,13 @@ function createSessionsRegistry(opts) {
|
|
|
3829
4780
|
status: "running",
|
|
3830
4781
|
// driver already started the session
|
|
3831
4782
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4783
|
+
cwd: input2.cwd,
|
|
4784
|
+
adapterSlug: input2.adapterSlug,
|
|
4785
|
+
// ACP-level session id — sticks across daemon restart so
|
|
4786
|
+
// `agentproto sessions restart <id>` can pass it as
|
|
4787
|
+
// `resumeSessionId` and the adapter reattaches to the prior
|
|
4788
|
+
// conversation rather than starting blank.
|
|
4789
|
+
adapterSessionId: input2.agentSession.sessionId,
|
|
3832
4790
|
...input2.label ? { label: input2.label } : {}
|
|
3833
4791
|
};
|
|
3834
4792
|
const rt = {
|
|
@@ -3836,6 +4794,8 @@ function createSessionsRegistry(opts) {
|
|
|
3836
4794
|
agentSession: input2.agentSession,
|
|
3837
4795
|
adapterSlug: input2.adapterSlug,
|
|
3838
4796
|
recentLines: [],
|
|
4797
|
+
recentBytes: [],
|
|
4798
|
+
recentBytesSize: 0,
|
|
3839
4799
|
emitter: new EventEmitter(),
|
|
3840
4800
|
busy: false,
|
|
3841
4801
|
textBuf: "",
|
|
@@ -3860,14 +4820,117 @@ function createSessionsRegistry(opts) {
|
|
|
3860
4820
|
}
|
|
3861
4821
|
return desc;
|
|
3862
4822
|
},
|
|
4823
|
+
spawnPty(input2) {
|
|
4824
|
+
if (!ptyFactory) {
|
|
4825
|
+
throw new Error(
|
|
4826
|
+
"sessions.spawnPty: no PTY factory configured (node-pty optional dep missing in the daemon)"
|
|
4827
|
+
);
|
|
4828
|
+
}
|
|
4829
|
+
const id = `sess_${randomUUID().slice(0, 8)}`;
|
|
4830
|
+
if (input2.name) {
|
|
4831
|
+
for (const rt2 of sessions.values()) {
|
|
4832
|
+
if (rt2.desc.name !== input2.name) continue;
|
|
4833
|
+
const status = rt2.desc.status;
|
|
4834
|
+
if (status === "running" || status === "starting") {
|
|
4835
|
+
throw new Error(
|
|
4836
|
+
`sessions.spawnPty: name "${input2.name}" already in use by ${rt2.desc.id} (status=${status})`
|
|
4837
|
+
);
|
|
4838
|
+
}
|
|
4839
|
+
rt2.desc.name = void 0;
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
const [bin, ...args] = input2.argv;
|
|
4843
|
+
if (!bin) {
|
|
4844
|
+
throw new Error("sessions.spawnPty: argv must include a binary");
|
|
4845
|
+
}
|
|
4846
|
+
const env = { ...process.env, ...input2.env ?? {} };
|
|
4847
|
+
delete env.NODE_OPTIONS;
|
|
4848
|
+
const pty = ptyFactory({
|
|
4849
|
+
command: bin,
|
|
4850
|
+
args,
|
|
4851
|
+
cwd: input2.cwd,
|
|
4852
|
+
env,
|
|
4853
|
+
cols: input2.cols,
|
|
4854
|
+
rows: input2.rows
|
|
4855
|
+
});
|
|
4856
|
+
const desc = {
|
|
4857
|
+
id,
|
|
4858
|
+
kind: "terminal",
|
|
4859
|
+
workspaceSlug: input2.workspaceSlug,
|
|
4860
|
+
command: [bin, ...args].map(quoteArg).join(" "),
|
|
4861
|
+
pid: pty.pid,
|
|
4862
|
+
status: "running",
|
|
4863
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4864
|
+
pty: true,
|
|
4865
|
+
argv: input2.argv,
|
|
4866
|
+
cwd: input2.cwd,
|
|
4867
|
+
...input2.name ? { name: input2.name } : {},
|
|
4868
|
+
...input2.label ? { label: input2.label } : {}
|
|
4869
|
+
};
|
|
4870
|
+
const rt = {
|
|
4871
|
+
desc,
|
|
4872
|
+
pty,
|
|
4873
|
+
ptySubscribers: /* @__PURE__ */ new Map(),
|
|
4874
|
+
recentLines: [],
|
|
4875
|
+
recentBytes: [],
|
|
4876
|
+
recentBytesSize: 0,
|
|
4877
|
+
emitter: new EventEmitter(),
|
|
4878
|
+
busy: false,
|
|
4879
|
+
textBuf: "",
|
|
4880
|
+
thoughtBuf: ""
|
|
4881
|
+
};
|
|
4882
|
+
rt.emitter.setMaxListeners(50);
|
|
4883
|
+
sessions.set(id, rt);
|
|
4884
|
+
pty.onData((chunk) => {
|
|
4885
|
+
appendBytes(rt, Buffer.from(chunk, "utf8"));
|
|
4886
|
+
});
|
|
4887
|
+
pty.onExit((evt) => {
|
|
4888
|
+
if (rt.desc.status !== "killed") {
|
|
4889
|
+
rt.desc.status = "exited";
|
|
4890
|
+
}
|
|
4891
|
+
rt.desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4892
|
+
if (typeof evt.exitCode === "number") rt.desc.exitCode = evt.exitCode;
|
|
4893
|
+
rt.emitter.emit("exit", evt);
|
|
4894
|
+
rt.emitter.emit("status", rt.desc.status);
|
|
4895
|
+
schedulePersist();
|
|
4896
|
+
});
|
|
4897
|
+
schedulePersist();
|
|
4898
|
+
return desc;
|
|
4899
|
+
},
|
|
3863
4900
|
async sendPrompt(id, message) {
|
|
4901
|
+
const rtPre = sessions.get(id);
|
|
4902
|
+
if (rtPre) await maybeResumeAgent(rtPre);
|
|
3864
4903
|
const rt = validateAgentTurn(id, "sendPrompt");
|
|
3865
4904
|
await runAgentTurn(rt, message);
|
|
3866
4905
|
},
|
|
3867
4906
|
enqueuePrompt(id, message) {
|
|
3868
|
-
const
|
|
3869
|
-
|
|
3870
|
-
|
|
4907
|
+
const rtPre = sessions.get(id);
|
|
4908
|
+
if (!rtPre) {
|
|
4909
|
+
throw new Error(`enqueuePrompt: no session "${id}"`);
|
|
4910
|
+
}
|
|
4911
|
+
if (rtPre.desc.kind !== "agent-cli") {
|
|
4912
|
+
throw new Error(
|
|
4913
|
+
`enqueuePrompt: session "${id}" is not an agent session (kind=${rtPre.desc.kind})`
|
|
4914
|
+
);
|
|
4915
|
+
}
|
|
4916
|
+
if (rtPre.busy) {
|
|
4917
|
+
throw new Error(
|
|
4918
|
+
`enqueuePrompt: session "${id}" is mid-turn \u2014 wait for it to finish or cancel`
|
|
4919
|
+
);
|
|
4920
|
+
}
|
|
4921
|
+
void (async () => {
|
|
4922
|
+
try {
|
|
4923
|
+
await maybeResumeAgent(rtPre);
|
|
4924
|
+
const rt = validateAgentTurn(id, "enqueuePrompt");
|
|
4925
|
+
await runAgentTurn(rt, message);
|
|
4926
|
+
} catch (err) {
|
|
4927
|
+
appendLine(
|
|
4928
|
+
rtPre,
|
|
4929
|
+
`[error] ${err instanceof Error ? err.message : String(err)}`,
|
|
4930
|
+
"stderr"
|
|
4931
|
+
);
|
|
4932
|
+
}
|
|
4933
|
+
})();
|
|
3871
4934
|
},
|
|
3872
4935
|
list() {
|
|
3873
4936
|
return Array.from(sessions.values()).map((s) => s.desc).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
@@ -3883,6 +4946,65 @@ function createSessionsRegistry(opts) {
|
|
|
3883
4946
|
rt.emitter.on("line", handler);
|
|
3884
4947
|
return () => rt.emitter.off("line", handler);
|
|
3885
4948
|
},
|
|
4949
|
+
attachPty(id, initial, onData, onExit) {
|
|
4950
|
+
const rt = sessions.get(id);
|
|
4951
|
+
if (!rt || !rt.pty || !rt.ptySubscribers) return null;
|
|
4952
|
+
for (const chunk of rt.recentBytes) onData(chunk);
|
|
4953
|
+
const subId = nextSubId++;
|
|
4954
|
+
rt.ptySubscribers.set(subId, { cols: initial.cols, rows: initial.rows });
|
|
4955
|
+
reconcilePtySize(rt);
|
|
4956
|
+
const dataHandler = (chunk) => onData(chunk);
|
|
4957
|
+
const exitHandler = (evt) => onExit(evt);
|
|
4958
|
+
rt.emitter.on("data", dataHandler);
|
|
4959
|
+
rt.emitter.once("exit", exitHandler);
|
|
4960
|
+
return {
|
|
4961
|
+
write(data) {
|
|
4962
|
+
if (!rt.pty) return;
|
|
4963
|
+
try {
|
|
4964
|
+
rt.pty.write(data);
|
|
4965
|
+
} catch {
|
|
4966
|
+
}
|
|
4967
|
+
},
|
|
4968
|
+
resize(cols, rows) {
|
|
4969
|
+
if (!rt.ptySubscribers) return;
|
|
4970
|
+
rt.ptySubscribers.set(subId, { cols, rows });
|
|
4971
|
+
reconcilePtySize(rt);
|
|
4972
|
+
},
|
|
4973
|
+
detach() {
|
|
4974
|
+
rt.emitter.off("data", dataHandler);
|
|
4975
|
+
rt.emitter.off("exit", exitHandler);
|
|
4976
|
+
rt.ptySubscribers?.delete(subId);
|
|
4977
|
+
reconcilePtySize(rt);
|
|
4978
|
+
}
|
|
4979
|
+
};
|
|
4980
|
+
},
|
|
4981
|
+
findByIdOrName(query) {
|
|
4982
|
+
const direct = sessions.get(query);
|
|
4983
|
+
if (direct) return direct.desc;
|
|
4984
|
+
for (const rt of sessions.values()) {
|
|
4985
|
+
if (rt.desc.name === query) return rt.desc;
|
|
4986
|
+
}
|
|
4987
|
+
return void 0;
|
|
4988
|
+
},
|
|
4989
|
+
writeTerminalInput(id, data) {
|
|
4990
|
+
const rt = sessions.get(id);
|
|
4991
|
+
if (!rt || !rt.pty) return false;
|
|
4992
|
+
try {
|
|
4993
|
+
rt.pty.write(data);
|
|
4994
|
+
return true;
|
|
4995
|
+
} catch {
|
|
4996
|
+
return false;
|
|
4997
|
+
}
|
|
4998
|
+
},
|
|
4999
|
+
readTerminalOutput(id, lastBytes) {
|
|
5000
|
+
const rt = sessions.get(id);
|
|
5001
|
+
if (!rt || !rt.pty) return null;
|
|
5002
|
+
const joined = Buffer.concat(rt.recentBytes, rt.recentBytesSize);
|
|
5003
|
+
if (typeof lastBytes === "number" && lastBytes > 0 && joined.byteLength > lastBytes) {
|
|
5004
|
+
return joined.subarray(joined.byteLength - lastBytes);
|
|
5005
|
+
}
|
|
5006
|
+
return joined;
|
|
5007
|
+
},
|
|
3886
5008
|
kill(id, signal = "SIGTERM") {
|
|
3887
5009
|
const rt = sessions.get(id);
|
|
3888
5010
|
if (!rt) return false;
|
|
@@ -3894,6 +5016,12 @@ function createSessionsRegistry(opts) {
|
|
|
3894
5016
|
if (rt.agentSession) {
|
|
3895
5017
|
void rt.agentSession.close().catch(() => void 0);
|
|
3896
5018
|
}
|
|
5019
|
+
if (rt.pty) {
|
|
5020
|
+
try {
|
|
5021
|
+
rt.pty.kill(signal);
|
|
5022
|
+
} catch {
|
|
5023
|
+
}
|
|
5024
|
+
}
|
|
3897
5025
|
rt.child?.kill(signal);
|
|
3898
5026
|
schedulePersist();
|
|
3899
5027
|
return true;
|
|
@@ -3907,19 +5035,91 @@ function createSessionsRegistry(opts) {
|
|
|
3907
5035
|
return true;
|
|
3908
5036
|
},
|
|
3909
5037
|
shutdown() {
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
5038
|
+
shutdownImpl();
|
|
5039
|
+
}
|
|
5040
|
+
};
|
|
5041
|
+
function shutdownImpl() {
|
|
5042
|
+
if (shutdownDone) return;
|
|
5043
|
+
shutdownDone = true;
|
|
5044
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
5045
|
+
if (persist) {
|
|
5046
|
+
try {
|
|
5047
|
+
process.off("exit", onProcessExit);
|
|
5048
|
+
} catch {
|
|
5049
|
+
}
|
|
5050
|
+
}
|
|
5051
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
5052
|
+
for (const rt of sessions.values()) {
|
|
5053
|
+
rt.emitter.removeAllListeners();
|
|
5054
|
+
if (rt.desc.status === "running" || rt.desc.status === "starting") {
|
|
5055
|
+
rt.desc.status = "killed";
|
|
5056
|
+
rt.desc.endedAt = nowIso;
|
|
5057
|
+
if (rt.agentSession) {
|
|
5058
|
+
void rt.agentSession.close().catch(() => void 0);
|
|
5059
|
+
}
|
|
5060
|
+
if (rt.pty) {
|
|
5061
|
+
try {
|
|
5062
|
+
rt.pty.kill("SIGTERM");
|
|
5063
|
+
} catch {
|
|
3916
5064
|
}
|
|
3917
|
-
rt.child?.kill("SIGTERM");
|
|
3918
5065
|
}
|
|
5066
|
+
rt.child?.kill("SIGTERM");
|
|
3919
5067
|
}
|
|
3920
|
-
sessions.clear();
|
|
3921
5068
|
}
|
|
3922
|
-
|
|
5069
|
+
if (persist) {
|
|
5070
|
+
try {
|
|
5071
|
+
const snapshot = {
|
|
5072
|
+
savedAt: nowIso,
|
|
5073
|
+
sessions: Array.from(sessions.values()).map((s) => s.desc)
|
|
5074
|
+
};
|
|
5075
|
+
mkdirSync(dirname(persistPath), { recursive: true });
|
|
5076
|
+
writeFileSync(persistPath, JSON.stringify(snapshot, null, 2) + "\n");
|
|
5077
|
+
} catch {
|
|
5078
|
+
}
|
|
5079
|
+
}
|
|
5080
|
+
sessions.clear();
|
|
5081
|
+
}
|
|
5082
|
+
}
|
|
5083
|
+
function loadHistorySnapshot(persistPath, sessions) {
|
|
5084
|
+
let raw;
|
|
5085
|
+
try {
|
|
5086
|
+
raw = readFileSync(persistPath, "utf8");
|
|
5087
|
+
} catch {
|
|
5088
|
+
return;
|
|
5089
|
+
}
|
|
5090
|
+
let parsed;
|
|
5091
|
+
try {
|
|
5092
|
+
parsed = JSON.parse(raw);
|
|
5093
|
+
} catch {
|
|
5094
|
+
console.warn(
|
|
5095
|
+
`[sessions] history file ${persistPath} is malformed \u2014 ignoring`
|
|
5096
|
+
);
|
|
5097
|
+
return;
|
|
5098
|
+
}
|
|
5099
|
+
if (!Array.isArray(parsed.sessions)) return;
|
|
5100
|
+
const sorted = parsed.sessions.filter((s) => !!s && typeof s.id === "string").sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, HISTORY_CAP);
|
|
5101
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5102
|
+
for (const desc of sorted) {
|
|
5103
|
+
if (sessions.has(desc.id)) continue;
|
|
5104
|
+
const wasAlive = desc.status === "running" || desc.status === "starting";
|
|
5105
|
+
const reclassified = wasAlive ? {
|
|
5106
|
+
...desc,
|
|
5107
|
+
status: "killed",
|
|
5108
|
+
endedAt: desc.endedAt ?? now
|
|
5109
|
+
} : desc;
|
|
5110
|
+
const rt = {
|
|
5111
|
+
desc: reclassified,
|
|
5112
|
+
recentLines: [],
|
|
5113
|
+
recentBytes: [],
|
|
5114
|
+
recentBytesSize: 0,
|
|
5115
|
+
emitter: new EventEmitter(),
|
|
5116
|
+
busy: false,
|
|
5117
|
+
textBuf: "",
|
|
5118
|
+
thoughtBuf: ""
|
|
5119
|
+
};
|
|
5120
|
+
rt.emitter.setMaxListeners(50);
|
|
5121
|
+
sessions.set(desc.id, rt);
|
|
5122
|
+
}
|
|
3923
5123
|
}
|
|
3924
5124
|
function quoteArg(arg) {
|
|
3925
5125
|
if (arg === "") return '""';
|
|
@@ -4115,9 +5315,24 @@ async function openClient(entry) {
|
|
|
4115
5315
|
const transport = new StdioClientTransport({
|
|
4116
5316
|
command: snap.command,
|
|
4117
5317
|
args: snap.args ?? [],
|
|
4118
|
-
env: { ...process.env, ...expandedEnv }
|
|
5318
|
+
env: { ...process.env, ...expandedEnv },
|
|
5319
|
+
// Pipe stderr so we can include it in the error message instead of
|
|
5320
|
+
// surfacing the opaque "Connection closed" MCP error code.
|
|
5321
|
+
stderr: "pipe"
|
|
4119
5322
|
});
|
|
4120
|
-
|
|
5323
|
+
const stderrBuf = [];
|
|
5324
|
+
transport.stderr?.on("data", (chunk) => {
|
|
5325
|
+
stderrBuf.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
|
5326
|
+
if (stderrBuf.length > 40) stderrBuf.shift();
|
|
5327
|
+
});
|
|
5328
|
+
try {
|
|
5329
|
+
await client.connect(transport);
|
|
5330
|
+
} catch (err) {
|
|
5331
|
+
const stderrText = stderrBuf.join("").trim();
|
|
5332
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5333
|
+
throw new Error(stderrText ? `${msg}
|
|
5334
|
+
stderr: ${stderrText.slice(-600)}` : msg);
|
|
5335
|
+
}
|
|
4121
5336
|
return client;
|
|
4122
5337
|
}
|
|
4123
5338
|
if (snap.type === "http") {
|
|
@@ -4175,7 +5390,7 @@ function quickTunnelProvider() {
|
|
|
4175
5390
|
{ stdio: ["ignore", "pipe", "pipe"], shell: false }
|
|
4176
5391
|
);
|
|
4177
5392
|
child = proc;
|
|
4178
|
-
const url = await new Promise((
|
|
5393
|
+
const url = await new Promise((resolve8, reject) => {
|
|
4179
5394
|
let settled = false;
|
|
4180
5395
|
const timer = setTimeout(() => {
|
|
4181
5396
|
if (settled) return;
|
|
@@ -4200,7 +5415,7 @@ function quickTunnelProvider() {
|
|
|
4200
5415
|
if (match) {
|
|
4201
5416
|
settled = true;
|
|
4202
5417
|
clearTimeout(timer);
|
|
4203
|
-
|
|
5418
|
+
resolve8(match[0]);
|
|
4204
5419
|
}
|
|
4205
5420
|
};
|
|
4206
5421
|
proc.stderr?.on("data", onData);
|
|
@@ -4249,15 +5464,15 @@ function quickTunnelProvider() {
|
|
|
4249
5464
|
}
|
|
4250
5465
|
}, 3e3);
|
|
4251
5466
|
timer.unref();
|
|
4252
|
-
await new Promise((
|
|
5467
|
+
await new Promise((resolve8) => {
|
|
4253
5468
|
if (proc.exitCode !== null) {
|
|
4254
5469
|
clearTimeout(timer);
|
|
4255
|
-
|
|
5470
|
+
resolve8();
|
|
4256
5471
|
return;
|
|
4257
5472
|
}
|
|
4258
5473
|
proc.once("exit", () => {
|
|
4259
5474
|
clearTimeout(timer);
|
|
4260
|
-
|
|
5475
|
+
resolve8();
|
|
4261
5476
|
});
|
|
4262
5477
|
});
|
|
4263
5478
|
}
|
|
@@ -4494,7 +5709,7 @@ var WorkspacePathError = class extends Error {
|
|
|
4494
5709
|
};
|
|
4495
5710
|
function createWorkspaceFs(opts) {
|
|
4496
5711
|
const root = resolve(opts.workspace);
|
|
4497
|
-
function
|
|
5712
|
+
function resolvePath4(path) {
|
|
4498
5713
|
if (typeof path !== "string" || path.length === 0) {
|
|
4499
5714
|
throw new WorkspacePathError("path must be a non-empty string");
|
|
4500
5715
|
}
|
|
@@ -4514,18 +5729,18 @@ function createWorkspaceFs(opts) {
|
|
|
4514
5729
|
}
|
|
4515
5730
|
return {
|
|
4516
5731
|
async readFile(path) {
|
|
4517
|
-
const abs =
|
|
5732
|
+
const abs = resolvePath4(path);
|
|
4518
5733
|
const buf = await readFile(abs);
|
|
4519
5734
|
return buf.toString("utf8");
|
|
4520
5735
|
},
|
|
4521
5736
|
async writeFile(path, content) {
|
|
4522
|
-
const abs =
|
|
5737
|
+
const abs = resolvePath4(path);
|
|
4523
5738
|
await mkdir(dirname(abs), { recursive: true });
|
|
4524
5739
|
await writeFile(abs, content);
|
|
4525
5740
|
},
|
|
4526
5741
|
async exists(path) {
|
|
4527
5742
|
try {
|
|
4528
|
-
const abs =
|
|
5743
|
+
const abs = resolvePath4(path);
|
|
4529
5744
|
return existsSync(abs);
|
|
4530
5745
|
} catch {
|
|
4531
5746
|
return false;
|
|
@@ -4557,7 +5772,31 @@ async function createGateway(opts) {
|
|
|
4557
5772
|
name: opts.name ?? "agentproto-runtime",
|
|
4558
5773
|
version: opts.version ?? "0.1.0-alpha"
|
|
4559
5774
|
});
|
|
4560
|
-
const sessions = createSessionsRegistry(
|
|
5775
|
+
const sessions = createSessionsRegistry({
|
|
5776
|
+
...opts.spawnPty ? { spawnPty: opts.spawnPty } : {},
|
|
5777
|
+
// Resume hook: when a prompt arrives for a dead agent-cli row
|
|
5778
|
+
// (typical after daemon restart), the registry calls back into
|
|
5779
|
+
// the adapter resolver to re-create the AgentSession with
|
|
5780
|
+
// `resumeSessionId = adapterSessionId`. ACP semantics: the
|
|
5781
|
+
// upstream provider reattaches to the prior conversation.
|
|
5782
|
+
// Unwired (no resolveAgentAdapter) → legacy "not an agent
|
|
5783
|
+
// session" error, user must spawn fresh.
|
|
5784
|
+
...opts.resolveAgentAdapter ? {
|
|
5785
|
+
resumeAgent: async ({ adapterSlug, cwd, resumeSessionId }) => {
|
|
5786
|
+
const adapter = await opts.resolveAgentAdapter(adapterSlug);
|
|
5787
|
+
if (!adapter) return null;
|
|
5788
|
+
try {
|
|
5789
|
+
return await adapter.startSession({ cwd, resumeSessionId });
|
|
5790
|
+
} catch (err) {
|
|
5791
|
+
console.warn(
|
|
5792
|
+
`[agentproto] resumeAgent('${adapterSlug}', ${resumeSessionId}) failed: ${err instanceof Error ? err.message : String(err)}`
|
|
5793
|
+
);
|
|
5794
|
+
return null;
|
|
5795
|
+
}
|
|
5796
|
+
}
|
|
5797
|
+
} : {}
|
|
5798
|
+
});
|
|
5799
|
+
const token = opts.token ?? randomUUID();
|
|
4561
5800
|
const mcpProxy = new McpProxyRegistry();
|
|
4562
5801
|
const mcpServerFactory = async () => {
|
|
4563
5802
|
const { server } = await createMcpServer({
|
|
@@ -4572,6 +5811,7 @@ async function createGateway(opts) {
|
|
|
4572
5811
|
registerSessionTools(server, {
|
|
4573
5812
|
registry: sessions,
|
|
4574
5813
|
mcpProxy,
|
|
5814
|
+
ptyEnabled: opts.spawnPty != null,
|
|
4575
5815
|
...opts.resolveAgentAdapter ? { resolveAgentAdapter: opts.resolveAgentAdapter } : {},
|
|
4576
5816
|
...opts.listAgentAdapters ? { listAgentAdapters: opts.listAgentAdapters } : {}
|
|
4577
5817
|
});
|
|
@@ -4613,6 +5853,10 @@ async function createGateway(opts) {
|
|
|
4613
5853
|
heartbeat,
|
|
4614
5854
|
sessions,
|
|
4615
5855
|
mcpProxy,
|
|
5856
|
+
token,
|
|
5857
|
+
ptyEnabled: opts.spawnPty != null,
|
|
5858
|
+
...opts.allowedOrigins ? { allowedOrigins: opts.allowedOrigins } : {},
|
|
5859
|
+
...opts.strictOrigins ? { strictOrigins: true } : {},
|
|
4616
5860
|
...opts.resolveAgentAdapter ? { resolveAgentAdapter: opts.resolveAgentAdapter } : {},
|
|
4617
5861
|
...opts.listAgentAdapters ? { listAgentAdapters: opts.listAgentAdapters } : {},
|
|
4618
5862
|
meta: { workspace, registered }
|
|
@@ -4631,7 +5875,8 @@ async function createGateway(opts) {
|
|
|
4631
5875
|
pid: process.pid,
|
|
4632
5876
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4633
5877
|
name: opts.name ?? "agentproto-runtime",
|
|
4634
|
-
registered
|
|
5878
|
+
registered,
|
|
5879
|
+
token
|
|
4635
5880
|
});
|
|
4636
5881
|
return {
|
|
4637
5882
|
url: http.url,
|
|
@@ -4639,6 +5884,7 @@ async function createGateway(opts) {
|
|
|
4639
5884
|
workspaceFs,
|
|
4640
5885
|
registered,
|
|
4641
5886
|
sessions,
|
|
5887
|
+
token,
|
|
4642
5888
|
async stop() {
|
|
4643
5889
|
heartbeat.stop();
|
|
4644
5890
|
sessions.shutdown();
|
|
@@ -4687,10 +5933,17 @@ async function runServe(args) {
|
|
|
4687
5933
|
label: { type: "string", short: "l" },
|
|
4688
5934
|
workspace: { type: "string", short: "w" },
|
|
4689
5935
|
port: { type: "string", short: "p" },
|
|
4690
|
-
bind: { type: "string", short: "b" }
|
|
5936
|
+
bind: { type: "string", short: "b" },
|
|
5937
|
+
"allow-origin": { type: "string", multiple: true },
|
|
5938
|
+
interactive: { type: "boolean", short: "i" }
|
|
4691
5939
|
}
|
|
4692
5940
|
});
|
|
4693
|
-
const
|
|
5941
|
+
const cfg = await loadConfig();
|
|
5942
|
+
const cfgDaemon = cfg.daemon ?? {};
|
|
5943
|
+
const cfgTunnel = cfg.tunnel ?? {};
|
|
5944
|
+
const workspace = resolve(
|
|
5945
|
+
values.workspace ?? cfgDaemon.workspace ?? process.cwd()
|
|
5946
|
+
);
|
|
4694
5947
|
try {
|
|
4695
5948
|
const stat2 = await promises.stat(workspace);
|
|
4696
5949
|
if (!stat2.isDirectory()) {
|
|
@@ -4708,20 +5961,21 @@ async function runServe(args) {
|
|
|
4708
5961
|
);
|
|
4709
5962
|
return 2;
|
|
4710
5963
|
}
|
|
4711
|
-
const port = values.port ? Number.parseInt(values.port, 10) : 18790;
|
|
5964
|
+
const port = values.port ? Number.parseInt(values.port, 10) : typeof cfgDaemon.port === "number" ? cfgDaemon.port : 18790;
|
|
4712
5965
|
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
4713
5966
|
process.stderr.write(`agentproto serve: invalid --port "${values.port}".
|
|
4714
5967
|
`);
|
|
4715
5968
|
return 2;
|
|
4716
5969
|
}
|
|
4717
|
-
const label = values.label ?? `${userInfo().username}@${hostname()}`;
|
|
5970
|
+
const label = values.label ?? cfgDaemon.label ?? `${userInfo().username}@${hostname()}`;
|
|
5971
|
+
const connectFlag = values.connect ?? (cfgTunnel.autoconnect && cfgTunnel.host ? cfgTunnel.host : void 0);
|
|
4718
5972
|
let token = values.token ?? process.env.AGENTPROTO_TOKEN;
|
|
4719
|
-
if (!token &&
|
|
4720
|
-
const cred = await readHost(
|
|
5973
|
+
if (!token && connectFlag) {
|
|
5974
|
+
const cred = await readHost(connectFlag);
|
|
4721
5975
|
if (cred) {
|
|
4722
5976
|
if (isExpired(cred)) {
|
|
4723
5977
|
process.stderr.write(
|
|
4724
|
-
`agentproto serve: \u26A0 credentials for ${
|
|
5978
|
+
`agentproto serve: \u26A0 credentials for ${connectFlag} are expired (${formatExpiry(cred)}). Re-run \`agentproto auth login --host ${connectFlag}\`.
|
|
4725
5979
|
`
|
|
4726
5980
|
);
|
|
4727
5981
|
}
|
|
@@ -4732,21 +5986,31 @@ async function runServe(args) {
|
|
|
4732
5986
|
);
|
|
4733
5987
|
}
|
|
4734
5988
|
}
|
|
5989
|
+
const allowOriginRaw = values["allow-origin"];
|
|
5990
|
+
const cliOrigins = Array.isArray(allowOriginRaw) ? allowOriginRaw : [];
|
|
5991
|
+
const cfgOrigins = Array.isArray(cfgDaemon.allowedOrigins) ? cfgDaemon.allowedOrigins : [];
|
|
5992
|
+
const merged = [.../* @__PURE__ */ new Set([...cfgOrigins, ...cliOrigins])];
|
|
5993
|
+
const allowedOrigins = merged.length > 0 ? merged : void 0;
|
|
4735
5994
|
const opts = {
|
|
4736
5995
|
workspace,
|
|
4737
5996
|
port,
|
|
4738
|
-
bind: values.bind ?? "127.0.0.1",
|
|
4739
|
-
...
|
|
5997
|
+
bind: values.bind ?? cfgDaemon.bind ?? "127.0.0.1",
|
|
5998
|
+
...connectFlag ? { connect: connectFlag } : {},
|
|
4740
5999
|
...token ? { token } : {},
|
|
4741
|
-
label
|
|
6000
|
+
label,
|
|
6001
|
+
...allowedOrigins ? { allowedOrigins } : {},
|
|
6002
|
+
...cfgDaemon.strictOrigins === true ? { strictOrigins: true } : {}
|
|
4742
6003
|
};
|
|
4743
6004
|
const resolveAgentAdapter = async (slug) => {
|
|
4744
6005
|
try {
|
|
4745
6006
|
const adapter = await resolveAdapter(slug);
|
|
4746
6007
|
const runtime = createAgentCliRuntime(adapter.handle);
|
|
4747
6008
|
return {
|
|
4748
|
-
async startSession({ cwd }) {
|
|
4749
|
-
return runtime.start({
|
|
6009
|
+
async startSession({ cwd, resumeSessionId }) {
|
|
6010
|
+
return runtime.start({
|
|
6011
|
+
cwd,
|
|
6012
|
+
...resumeSessionId ? { resumeSessionId } : {}
|
|
6013
|
+
});
|
|
4750
6014
|
},
|
|
4751
6015
|
commandPreview: `${adapter.handle.bin} ${(adapter.handle.bin_args ?? []).join(" ")}`.trim()
|
|
4752
6016
|
};
|
|
@@ -4757,6 +6021,7 @@ async function runServe(args) {
|
|
|
4757
6021
|
return null;
|
|
4758
6022
|
}
|
|
4759
6023
|
};
|
|
6024
|
+
const spawnPty = await loadNodePtyFactory();
|
|
4760
6025
|
let gateway;
|
|
4761
6026
|
try {
|
|
4762
6027
|
gateway = await createGateway({
|
|
@@ -4771,7 +6036,10 @@ async function runServe(args) {
|
|
|
4771
6036
|
// Discovery for UIs / operators — `GET /adapters` + `list_adapters`
|
|
4772
6037
|
// MCP tool. Walks node_modules @agentproto/adapter-* on each call;
|
|
4773
6038
|
// cheap enough that we don't bother caching here.
|
|
4774
|
-
listAgentAdapters: listInstalledAdapters
|
|
6039
|
+
listAgentAdapters: listInstalledAdapters,
|
|
6040
|
+
...spawnPty ? { spawnPty } : {},
|
|
6041
|
+
...opts.allowedOrigins ? { allowedOrigins: opts.allowedOrigins } : {},
|
|
6042
|
+
...opts.strictOrigins ? { strictOrigins: true } : {}
|
|
4775
6043
|
});
|
|
4776
6044
|
} catch (err) {
|
|
4777
6045
|
process.stderr.write(
|
|
@@ -4780,48 +6048,105 @@ async function runServe(args) {
|
|
|
4780
6048
|
);
|
|
4781
6049
|
return 1;
|
|
4782
6050
|
}
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
6051
|
+
printBootBanner({
|
|
6052
|
+
url: gateway.url,
|
|
6053
|
+
workspace: gateway.workspace,
|
|
6054
|
+
ptyEnabled: spawnPty != null,
|
|
6055
|
+
allowedOrigins: opts.allowedOrigins,
|
|
6056
|
+
strictOrigins: opts.strictOrigins === true,
|
|
6057
|
+
connect: opts.connect
|
|
6058
|
+
});
|
|
6059
|
+
try {
|
|
6060
|
+
const wsConfig = await loadWorkspacesConfig();
|
|
6061
|
+
const paths = wsConfig.workspaces.map((w) => w.path);
|
|
6062
|
+
const cleaned = await sweepStaleRuntimeMetas(paths, opts.workspace);
|
|
6063
|
+
if (cleaned.length > 0) {
|
|
6064
|
+
process.stderr.write(
|
|
6065
|
+
`${color.dim}cleaned ${cleaned.length} stale runtime.json file(s) (dead PID)${color.reset}
|
|
4789
6066
|
`
|
|
4790
|
-
|
|
6067
|
+
);
|
|
6068
|
+
}
|
|
6069
|
+
} catch {
|
|
6070
|
+
}
|
|
4791
6071
|
const aborter = new AbortController();
|
|
4792
6072
|
let shuttingDown = false;
|
|
4793
6073
|
const shutdown = async (signal) => {
|
|
4794
6074
|
if (shuttingDown) return;
|
|
4795
6075
|
shuttingDown = true;
|
|
4796
|
-
process.stderr.write(
|
|
4797
|
-
|
|
4798
|
-
|
|
6076
|
+
process.stderr.write(
|
|
6077
|
+
`
|
|
6078
|
+
${color.dim}\u2500\u2500 shutting down (${signal}) \u2500\u2500${color.reset}
|
|
6079
|
+
`
|
|
6080
|
+
);
|
|
4799
6081
|
aborter.abort();
|
|
4800
6082
|
await gateway.stop().catch(() => void 0);
|
|
6083
|
+
await unlinkRuntimeMeta(opts.workspace).catch(() => void 0);
|
|
4801
6084
|
process.exit(0);
|
|
4802
6085
|
};
|
|
4803
6086
|
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
4804
6087
|
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
6088
|
+
process.once("SIGHUP", () => void shutdown("SIGHUP"));
|
|
6089
|
+
if (values.interactive) {
|
|
6090
|
+
process.stderr.write(
|
|
6091
|
+
`${color.dim}entering interactive monitor \u2014 q in the TUI to quit (daemon + TUI both).${color.reset}
|
|
6092
|
+
`
|
|
6093
|
+
);
|
|
6094
|
+
const childArgv = [process.argv[1] ?? "", "sessions", "--watch"];
|
|
6095
|
+
const child = childProc.spawn(process.execPath, childArgv, {
|
|
6096
|
+
stdio: "inherit",
|
|
6097
|
+
env: {
|
|
6098
|
+
...process.env,
|
|
6099
|
+
AGENTPROTO_DAEMON_URL: gateway.url,
|
|
6100
|
+
// `gateway.token` is the per-boot daemon bearer (different
|
|
6101
|
+
// from `token`, which was the tunnel JWT). Without this, the
|
|
6102
|
+
// child's restart/kill calls would 401 against its own
|
|
6103
|
+
// parent — exactly the bug that closed --interactive after
|
|
6104
|
+
// any failed action.
|
|
6105
|
+
AGENTPROTO_DAEMON_TOKEN: gateway.token
|
|
6106
|
+
}
|
|
6107
|
+
});
|
|
6108
|
+
await new Promise((resolve4) => {
|
|
6109
|
+
child.once("exit", () => resolve4());
|
|
6110
|
+
aborter.signal.addEventListener("abort", () => {
|
|
6111
|
+
try {
|
|
6112
|
+
child.kill("SIGTERM");
|
|
6113
|
+
} catch {
|
|
6114
|
+
}
|
|
6115
|
+
});
|
|
6116
|
+
});
|
|
6117
|
+
if (!shuttingDown) {
|
|
6118
|
+
aborter.abort();
|
|
6119
|
+
await gateway.stop().catch(() => void 0);
|
|
6120
|
+
await unlinkRuntimeMeta(opts.workspace).catch(() => void 0);
|
|
6121
|
+
}
|
|
6122
|
+
return 0;
|
|
6123
|
+
}
|
|
4805
6124
|
if (!opts.connect) {
|
|
4806
6125
|
process.stderr.write(
|
|
4807
|
-
|
|
6126
|
+
`${color.dim}Press Ctrl-C to stop.${color.reset}
|
|
4808
6127
|
`
|
|
4809
6128
|
);
|
|
4810
|
-
await new Promise((
|
|
4811
|
-
aborter.signal.addEventListener("abort", () =>
|
|
6129
|
+
await new Promise((resolve4) => {
|
|
6130
|
+
aborter.signal.addEventListener("abort", () => resolve4());
|
|
4812
6131
|
});
|
|
4813
6132
|
return 0;
|
|
4814
6133
|
}
|
|
4815
6134
|
process.stderr.write(
|
|
4816
|
-
|
|
6135
|
+
`${color.dim}tunnel \xB7 connecting to ${opts.connect} as '${opts.label}'\u2026${color.reset}
|
|
4817
6136
|
`
|
|
4818
6137
|
);
|
|
4819
6138
|
let backoffMs = opts.reconnectMinMs ?? 1e3;
|
|
4820
6139
|
const backoffMax = opts.reconnectMaxMs ?? 3e4;
|
|
6140
|
+
const reconnectState = { immediate: false };
|
|
4821
6141
|
while (!aborter.signal.aborted) {
|
|
4822
6142
|
try {
|
|
4823
|
-
await runOneTunnel(opts, gateway, aborter.signal);
|
|
6143
|
+
await runOneTunnel(opts, gateway, spawnPty, aborter.signal, reconnectState);
|
|
4824
6144
|
backoffMs = opts.reconnectMinMs ?? 1e3;
|
|
6145
|
+
if (reconnectState.immediate) {
|
|
6146
|
+
reconnectState.immediate = false;
|
|
6147
|
+
continue;
|
|
6148
|
+
}
|
|
6149
|
+
await sleep(2e3, aborter.signal);
|
|
4825
6150
|
} catch (err) {
|
|
4826
6151
|
if (aborter.signal.aborted) break;
|
|
4827
6152
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -4836,17 +6161,17 @@ agentproto serve: ${signal} \u2014 shutting down.
|
|
|
4836
6161
|
}
|
|
4837
6162
|
return 0;
|
|
4838
6163
|
}
|
|
4839
|
-
async function runOneTunnel(opts, gateway, signal) {
|
|
6164
|
+
async function runOneTunnel(opts, gateway, spawnPty, signal, reconnectState) {
|
|
4840
6165
|
if (!opts.connect) throw new Error("runOneTunnel: --connect not set");
|
|
4841
6166
|
const headers = {
|
|
4842
6167
|
"user-agent": "agentproto/0.1.0-alpha"
|
|
4843
6168
|
};
|
|
4844
6169
|
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
|
|
4845
6170
|
const ws = new WebSocket(opts.connect, { headers });
|
|
4846
|
-
await new Promise((
|
|
6171
|
+
await new Promise((resolve4, reject) => {
|
|
4847
6172
|
const onOpen = () => {
|
|
4848
6173
|
ws.off("error", onError);
|
|
4849
|
-
|
|
6174
|
+
resolve4();
|
|
4850
6175
|
};
|
|
4851
6176
|
const onError = (err) => {
|
|
4852
6177
|
ws.off("open", onOpen);
|
|
@@ -4866,16 +6191,86 @@ async function runOneTunnel(opts, gateway, signal) {
|
|
|
4866
6191
|
process.stderr.write(`agentproto serve: tunnel up.
|
|
4867
6192
|
`);
|
|
4868
6193
|
const sink = wrapWebSocket(ws);
|
|
6194
|
+
const keepaliveInterval = setInterval(() => {
|
|
6195
|
+
try {
|
|
6196
|
+
sink.send({ t: "ping", nonce: randomUUID() });
|
|
6197
|
+
} catch {
|
|
6198
|
+
}
|
|
6199
|
+
}, 3e4);
|
|
4869
6200
|
const server = createTunnelServer({
|
|
4870
6201
|
sink,
|
|
4871
6202
|
label: opts.label,
|
|
4872
|
-
pty:
|
|
6203
|
+
pty: spawnPty !== null,
|
|
6204
|
+
...spawnPty ? { spawnPty } : {},
|
|
4873
6205
|
// Generic HTTP-relay upstream for tunnel `http_request` frames.
|
|
4874
6206
|
// Cloud-side callers (e.g. the API's local-daemon filesystem
|
|
4875
6207
|
// provider) can now route MCP JSON-RPC + any other HTTP through
|
|
4876
6208
|
// the daemon without needing a public URL. We point at the local
|
|
4877
6209
|
// gateway since that's where `/mcp`, `/sessions`, `/events` live.
|
|
4878
6210
|
httpUpstream: gateway.url,
|
|
6211
|
+
// WS forwarding upstream — daemon dials the local gateway's WS
|
|
6212
|
+
// endpoints (/sessions/:id/pty, etc) and pipes frames to the host.
|
|
6213
|
+
// Used by the cloud tunnel pod so browsers on mobile can attach to
|
|
6214
|
+
// interactive PTY sessions even though the daemon is only reachable
|
|
6215
|
+
// through the host (not directly).
|
|
6216
|
+
dialUpstreamWs: async ({ url, protocols, headers: headers2 }) => {
|
|
6217
|
+
const upstreamHeaders = {
|
|
6218
|
+
...headers2
|
|
6219
|
+
};
|
|
6220
|
+
if (!upstreamHeaders["origin"] && !upstreamHeaders["Origin"]) {
|
|
6221
|
+
try {
|
|
6222
|
+
const u = new URL(url);
|
|
6223
|
+
const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
|
|
6224
|
+
upstreamHeaders["Origin"] = `${httpScheme}//${u.host}`;
|
|
6225
|
+
} catch {
|
|
6226
|
+
}
|
|
6227
|
+
}
|
|
6228
|
+
return await new Promise((resolve4, reject) => {
|
|
6229
|
+
const sock = new WebSocket(url, protocols ? [...protocols] : void 0, {
|
|
6230
|
+
headers: upstreamHeaders
|
|
6231
|
+
});
|
|
6232
|
+
const onceOpen = () => {
|
|
6233
|
+
sock.off("error", onceError);
|
|
6234
|
+
sock.off("unexpected-response", onceUnexpected);
|
|
6235
|
+
resolve4({
|
|
6236
|
+
protocol: sock.protocol ?? "",
|
|
6237
|
+
send: (data, sendOpts) => {
|
|
6238
|
+
sock.send(data, { binary: sendOpts.binary });
|
|
6239
|
+
},
|
|
6240
|
+
close: (code, reason) => {
|
|
6241
|
+
try {
|
|
6242
|
+
sock.close(code, reason);
|
|
6243
|
+
} catch {
|
|
6244
|
+
}
|
|
6245
|
+
},
|
|
6246
|
+
onMessage: (handler) => {
|
|
6247
|
+
sock.on("message", (raw, isBinary) => {
|
|
6248
|
+
handler(raw, isBinary);
|
|
6249
|
+
});
|
|
6250
|
+
},
|
|
6251
|
+
onClose: (handler) => {
|
|
6252
|
+
sock.on("close", (code, reason) => {
|
|
6253
|
+
handler(code, reason.toString("utf8"));
|
|
6254
|
+
});
|
|
6255
|
+
},
|
|
6256
|
+
onError: (handler) => {
|
|
6257
|
+
sock.on("error", (err) => handler(err));
|
|
6258
|
+
}
|
|
6259
|
+
});
|
|
6260
|
+
};
|
|
6261
|
+
const onceError = (err) => {
|
|
6262
|
+
sock.off("open", onceOpen);
|
|
6263
|
+
reject(err);
|
|
6264
|
+
};
|
|
6265
|
+
const onceUnexpected = (_req, res) => {
|
|
6266
|
+
sock.off("open", onceOpen);
|
|
6267
|
+
reject(new Error(`Unexpected server response: ${res.statusCode ?? 0}`));
|
|
6268
|
+
};
|
|
6269
|
+
sock.once("open", onceOpen);
|
|
6270
|
+
sock.once("error", onceError);
|
|
6271
|
+
sock.once("unexpected-response", onceUnexpected);
|
|
6272
|
+
});
|
|
6273
|
+
},
|
|
4879
6274
|
// v0 authorize hook: trust the bearer-authenticated host completely.
|
|
4880
6275
|
// Token possession proves the host was provisioned for this daemon.
|
|
4881
6276
|
// Per-spawn policy filtering will land alongside the policy.toml.
|
|
@@ -4895,33 +6290,93 @@ async function runOneTunnel(opts, gateway, signal) {
|
|
|
4895
6290
|
kind: "agent-cli",
|
|
4896
6291
|
label: `tunnel: ${request.command.split("/").pop() ?? request.command}`
|
|
4897
6292
|
});
|
|
6293
|
+
},
|
|
6294
|
+
// Graceful drain hook — flip the outer loop's "reconnect immediately"
|
|
6295
|
+
// flag and close the WS so the supervisor reconnects without backoff.
|
|
6296
|
+
// Host will follow up with close(1012) ~2s later as a hard backstop.
|
|
6297
|
+
onReconnectSoon: ({ reasonMs }) => {
|
|
6298
|
+
process.stderr.write(
|
|
6299
|
+
`agentproto serve: host signaled drain (reasonMs=${reasonMs ?? "?"}) \u2014 reconnecting immediately
|
|
6300
|
+
`
|
|
6301
|
+
);
|
|
6302
|
+
reconnectState.immediate = true;
|
|
6303
|
+
try {
|
|
6304
|
+
ws.close(1e3, "host_drain");
|
|
6305
|
+
} catch {
|
|
6306
|
+
}
|
|
4898
6307
|
}
|
|
4899
6308
|
});
|
|
4900
|
-
await new Promise((
|
|
6309
|
+
await new Promise((resolve4) => {
|
|
4901
6310
|
const offClose = sink.onClose(() => {
|
|
6311
|
+
clearInterval(keepaliveInterval);
|
|
4902
6312
|
offClose();
|
|
4903
|
-
|
|
6313
|
+
resolve4();
|
|
4904
6314
|
});
|
|
4905
6315
|
if (signal.aborted) {
|
|
4906
|
-
|
|
6316
|
+
clearInterval(keepaliveInterval);
|
|
6317
|
+
server.close().finally(() => resolve4());
|
|
4907
6318
|
}
|
|
4908
6319
|
signal.addEventListener("abort", () => {
|
|
4909
|
-
|
|
6320
|
+
clearInterval(keepaliveInterval);
|
|
6321
|
+
server.close().finally(() => resolve4());
|
|
4910
6322
|
});
|
|
4911
6323
|
});
|
|
4912
6324
|
process.stderr.write(`agentproto serve: tunnel closed.
|
|
4913
6325
|
`);
|
|
4914
6326
|
}
|
|
4915
6327
|
function sleep(ms, signal) {
|
|
4916
|
-
return new Promise((
|
|
4917
|
-
if (signal.aborted) return
|
|
4918
|
-
const timer = setTimeout(
|
|
6328
|
+
return new Promise((resolve4) => {
|
|
6329
|
+
if (signal.aborted) return resolve4();
|
|
6330
|
+
const timer = setTimeout(resolve4, ms);
|
|
4919
6331
|
signal.addEventListener("abort", () => {
|
|
4920
6332
|
clearTimeout(timer);
|
|
4921
|
-
|
|
6333
|
+
resolve4();
|
|
4922
6334
|
});
|
|
4923
6335
|
});
|
|
4924
6336
|
}
|
|
6337
|
+
function printBootBanner(opts) {
|
|
6338
|
+
const c = color;
|
|
6339
|
+
const home = process.env.HOME ?? "";
|
|
6340
|
+
const workspace = home && opts.workspace.startsWith(home) ? "~" + opts.workspace.slice(home.length) : opts.workspace;
|
|
6341
|
+
const ptyState = opts.ptyEnabled ? `${c.green}enabled${c.reset} ${c.dim}(node-pty)${c.reset}` : `${c.amber}disabled${c.reset} ${c.dim}(install node-pty to enable)${c.reset}`;
|
|
6342
|
+
let origins;
|
|
6343
|
+
if (opts.strictOrigins) {
|
|
6344
|
+
origins = opts.allowedOrigins && opts.allowedOrigins.length > 0 ? `${c.amber}STRICT${c.reset} ${opts.allowedOrigins.join(" \xB7 ")} ${c.dim}(localhost defaults disabled)${c.reset}` : `${c.red}STRICT \xB7 empty allowlist${c.reset} ${c.dim}(every Origin will 401 \u2014 only Bearer-token works)${c.reset}`;
|
|
6345
|
+
} else {
|
|
6346
|
+
origins = opts.allowedOrigins && opts.allowedOrigins.length > 0 ? opts.allowedOrigins.join(" \xB7 ") + ` ${c.dim}+ localhost (default)${c.reset}` : `${c.dim}localhost:* only (default)${c.reset}`;
|
|
6347
|
+
}
|
|
6348
|
+
const mode = opts.connect ? `${c.cyan}tunnel${c.reset} ${c.dim}\u2192 ${opts.connect}${c.reset}` : `${c.dim}local-only${c.reset}`;
|
|
6349
|
+
const line = `${c.dim}\u2500${c.reset}`;
|
|
6350
|
+
process.stderr.write(
|
|
6351
|
+
`
|
|
6352
|
+
${line} ${c.bold}agentproto${c.reset} ${c.dim}\xB7${c.reset} gateway up ${c.dim}\xB7${c.reset} ${c.cyan}${opts.url}${c.reset} ${line}
|
|
6353
|
+
${c.dim}workspace${c.reset} ${workspace}
|
|
6354
|
+
${c.dim}pty${c.reset} ${ptyState}
|
|
6355
|
+
${c.dim}origins${c.reset} ${origins}
|
|
6356
|
+
${c.dim}endpoints${c.reset} /mcp \xB7 /sessions \xB7 /events \xB7 /sessions/:id/pty ${c.dim}(WS)${c.reset}
|
|
6357
|
+
${c.dim}mode${c.reset} ${mode}
|
|
6358
|
+
|
|
6359
|
+
`
|
|
6360
|
+
);
|
|
6361
|
+
}
|
|
6362
|
+
var _isTty = !!process.stderr.isTTY;
|
|
6363
|
+
var color = _isTty ? {
|
|
6364
|
+
reset: "\x1B[0m",
|
|
6365
|
+
bold: "\x1B[1m",
|
|
6366
|
+
dim: "\x1B[2m",
|
|
6367
|
+
green: "\x1B[32m",
|
|
6368
|
+
amber: "\x1B[33m",
|
|
6369
|
+
cyan: "\x1B[36m",
|
|
6370
|
+
red: "\x1B[31m"
|
|
6371
|
+
} : {
|
|
6372
|
+
reset: "",
|
|
6373
|
+
bold: "",
|
|
6374
|
+
dim: "",
|
|
6375
|
+
green: "",
|
|
6376
|
+
amber: "",
|
|
6377
|
+
cyan: "",
|
|
6378
|
+
red: ""
|
|
6379
|
+
};
|
|
4925
6380
|
|
|
4926
6381
|
export { listInstalledAdapters, resolveAdapter, runInstall, runRun, runServe };
|
|
4927
6382
|
//# sourceMappingURL=index.mjs.map
|