@agentproto/cli 0.1.0-alpha.6 → 0.1.0-alpha.9

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/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, readFile, unlink, 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
- console.warn(
88
- `[agentproto/cli] listInstalledAdapters: skipping broken adapter '${slug}': ${err instanceof Error ? err.message : String(err)}`
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
  }
@@ -102,8 +106,8 @@ async function collectAgentprotoNamespaceRoots(start) {
102
106
  for (let depth = 0; depth < 16; depth++) {
103
107
  for (const candidate of candidatesAt(cur)) {
104
108
  try {
105
- const stat2 = await promises.stat(candidate);
106
- if (stat2.isDirectory()) roots.push(candidate);
109
+ const stat3 = await promises.stat(candidate);
110
+ if (stat3.isDirectory()) roots.push(candidate);
107
111
  } catch {
108
112
  }
109
113
  }
@@ -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((resolve3) => {
266
+ await new Promise((resolve4) => {
263
267
  const child = spawn(opener, args, { stdio: "ignore", detached: true });
264
- child.once("error", () => resolve3());
265
- child.once("spawn", () => resolve3());
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((resolve3) => {
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
- resolve3(buf || opts.defaultValue || "");
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((resolve3) => {
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
- resolve3({ exitCode: 127, stdout, stderr: stderr || "spawn error" });
450
+ resolve4({ exitCode: 127, stdout, stderr: stderr || "spawn error" });
447
451
  });
448
452
  child.once("exit", (code) => {
449
453
  clearTimeout(timer);
450
- resolve3({ exitCode: code ?? 0, stdout, stderr });
454
+ resolve4({ exitCode: code ?? 0, stdout, stderr });
451
455
  });
452
456
  });
453
457
  }
@@ -481,9 +485,315 @@ async function saveLedger(path, ledger) {
481
485
  await chmod(path, 384).catch(() => {
482
486
  });
483
487
  }
488
+ async function runInstallProfile(slug, args) {
489
+ const { values } = parseArgs({
490
+ args: [...args],
491
+ allowPositionals: true,
492
+ strict: true,
493
+ options: {
494
+ force: { type: "boolean", short: "f" },
495
+ "dry-run": { type: "boolean" },
496
+ "skip-setup": { type: "boolean" },
497
+ cwd: { type: "string" },
498
+ package: { type: "string" }
499
+ }
500
+ });
501
+ if (!slug.startsWith("runtime-profile/")) {
502
+ process.stderr.write(
503
+ `agentproto install: expected slug starting with 'runtime-profile/', got '${slug}'.
504
+ `
505
+ );
506
+ return 2;
507
+ }
508
+ const profileName = slug.slice("runtime-profile/".length);
509
+ if (!/^[a-z][a-z0-9-]*$/.test(profileName)) {
510
+ process.stderr.write(
511
+ `agentproto install: invalid profile name '${profileName}'. Lower-kebab only.
512
+ `
513
+ );
514
+ return 2;
515
+ }
516
+ let packageName;
517
+ if (typeof values.package === "string" && values.package) {
518
+ packageName = values.package;
519
+ } else {
520
+ const aliased = await readProfileAlias(profileName);
521
+ packageName = aliased ?? `@agentproto/runtime-profile-${profileName}`;
522
+ }
523
+ let mod;
524
+ try {
525
+ mod = await import(packageName);
526
+ } catch (err) {
527
+ const cause = err instanceof Error ? err.message : String(err);
528
+ process.stderr.write(
529
+ `agentproto install: could not load profile package '${packageName}'. Install it first with: npm i -g ${packageName}
530
+ cause: ${cause}
531
+ `
532
+ );
533
+ return 1;
534
+ }
535
+ const filesDir = typeof mod.FILES_DIR === "string" ? mod.FILES_DIR : null;
536
+ const loadProfileManifest = mod.loadProfileManifest;
537
+ if (!filesDir || !loadProfileManifest) {
538
+ process.stderr.write(
539
+ `agentproto install: profile package '${packageName}' does not export FILES_DIR + loadProfileManifest.
540
+ `
541
+ );
542
+ return 1;
543
+ }
544
+ const manifest = await loadProfileManifest();
545
+ const cwd = values.cwd ? resolve(values.cwd) : process.cwd();
546
+ const dryRun = values["dry-run"] === true;
547
+ const force = values.force === true;
548
+ process.stdout.write(
549
+ `agentproto install ${slug} v${manifest.version} \u2192 ${cwd}
550
+ ${manifest.description}
551
+ `
552
+ );
553
+ const ledgerPath = ledgerPathFor2(profileName);
554
+ const prevLedger = await loadLedger2(ledgerPath);
555
+ const newLedger = {
556
+ slug,
557
+ version: manifest.version,
558
+ installedAt: prevLedger?.installedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
559
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
560
+ files: []
561
+ };
562
+ let applied = 0;
563
+ let skipped = 0;
564
+ let failed = 0;
565
+ for (const entry of manifest.files) {
566
+ const src = resolve(filesDir, entry.src);
567
+ const dest = resolve(cwd, entry.dest);
568
+ const prev = prevLedger?.files.find((f) => f.dest === entry.dest) ?? null;
569
+ try {
570
+ const srcBuf = await readFile(src);
571
+ const result = await applyStrategy({
572
+ strategy: entry.strategy,
573
+ srcBuf,
574
+ dest,
575
+ prev,
576
+ force,
577
+ dryRun
578
+ });
579
+ newLedger.files.push({
580
+ dest: entry.dest,
581
+ strategy: entry.strategy,
582
+ hashAfter: result.hashAfter,
583
+ appliedAt: result.wrote ? (/* @__PURE__ */ new Date()).toISOString() : prev?.appliedAt ?? (/* @__PURE__ */ new Date()).toISOString()
584
+ });
585
+ if (result.wrote) {
586
+ applied += 1;
587
+ process.stdout.write(` ${result.action.padEnd(10)} ${entry.dest}
588
+ `);
589
+ if (entry.executable && !dryRun) {
590
+ await chmod(dest, 493);
591
+ }
592
+ } else {
593
+ skipped += 1;
594
+ process.stdout.write(` ${result.action.padEnd(10)} ${entry.dest}
595
+ `);
596
+ }
597
+ } catch (err) {
598
+ failed += 1;
599
+ const msg = err instanceof Error ? err.message : String(err);
600
+ process.stderr.write(` FAIL ${entry.dest} \u2014 ${msg}
601
+ `);
602
+ }
603
+ }
604
+ if (!dryRun) {
605
+ await saveLedger2(ledgerPath, newLedger);
606
+ }
607
+ process.stdout.write(
608
+ `agentproto install ${slug}: ${applied} applied, ${skipped} skipped, ${failed} failed${dryRun ? " (dry-run)" : ""}
609
+ `
610
+ );
611
+ return failed === 0 ? 0 : 1;
612
+ }
613
+ async function applyStrategy(opts) {
614
+ const { strategy, srcBuf, dest, prev, force, dryRun } = opts;
615
+ const destExists = await fileExists(dest);
616
+ if (strategy === "preserve") {
617
+ if (destExists) {
618
+ const currentHash = await hashFile(dest);
619
+ return { wrote: false, action: "preserve", hashAfter: currentHash };
620
+ }
621
+ if (!dryRun) {
622
+ await ensureDir(dirname(dest));
623
+ await writeFile(dest, srcBuf);
624
+ }
625
+ return { wrote: true, action: "create", hashAfter: sha256(srcBuf) };
626
+ }
627
+ if (strategy === "overwrite") {
628
+ const srcHash = sha256(srcBuf);
629
+ if (destExists && !force) {
630
+ const destHash = await hashFile(dest);
631
+ if (destHash === srcHash) {
632
+ return { wrote: false, action: "unchanged", hashAfter: destHash };
633
+ }
634
+ if (prev && prev.hashAfter !== destHash) {
635
+ return {
636
+ wrote: false,
637
+ action: "user-edit",
638
+ hashAfter: destHash
639
+ };
640
+ }
641
+ }
642
+ if (!dryRun) {
643
+ await ensureDir(dirname(dest));
644
+ await writeFile(dest, srcBuf);
645
+ }
646
+ return {
647
+ wrote: true,
648
+ action: destExists ? "update" : "create",
649
+ hashAfter: srcHash
650
+ };
651
+ }
652
+ if (strategy === "merge-json-deep") {
653
+ const srcJson = parseJsonOrThrow(srcBuf.toString("utf8"), "src");
654
+ let merged = srcJson;
655
+ if (destExists) {
656
+ const destRaw = await readFile(dest, "utf8");
657
+ const destJson = parseJsonOrThrow(destRaw, "dest");
658
+ merged = deepMerge(destJson, srcJson);
659
+ }
660
+ const out = `${JSON.stringify(merged, null, 2)}
661
+ `;
662
+ const outBuf = Buffer.from(out, "utf8");
663
+ const outHash = sha256(outBuf);
664
+ if (destExists) {
665
+ const destHash = await hashFile(dest);
666
+ if (destHash === outHash) {
667
+ return { wrote: false, action: "unchanged", hashAfter: destHash };
668
+ }
669
+ }
670
+ if (!dryRun) {
671
+ await ensureDir(dirname(dest));
672
+ await writeFile(dest, outBuf);
673
+ }
674
+ return {
675
+ wrote: true,
676
+ action: destExists ? "merge" : "create",
677
+ hashAfter: outHash
678
+ };
679
+ }
680
+ if (strategy === "append") {
681
+ const srcText = srcBuf.toString("utf8");
682
+ if (destExists) {
683
+ const destText = await readFile(dest, "utf8");
684
+ if (destText.includes(srcText)) {
685
+ return {
686
+ wrote: false,
687
+ action: "unchanged",
688
+ hashAfter: sha256(Buffer.from(destText, "utf8"))
689
+ };
690
+ }
691
+ const out = `${destText.endsWith("\n") ? destText : `${destText}
692
+ `}${srcText}`;
693
+ const outBuf = Buffer.from(out, "utf8");
694
+ if (!dryRun) {
695
+ await ensureDir(dirname(dest));
696
+ await writeFile(dest, outBuf);
697
+ }
698
+ return { wrote: true, action: "append", hashAfter: sha256(outBuf) };
699
+ }
700
+ if (!dryRun) {
701
+ await ensureDir(dirname(dest));
702
+ await writeFile(dest, srcBuf);
703
+ }
704
+ return { wrote: true, action: "create", hashAfter: sha256(srcBuf) };
705
+ }
706
+ throw new Error(`Unknown strategy: ${strategy}`);
707
+ }
708
+ function deepMerge(target, source) {
709
+ if (target && source && typeof target === "object" && typeof source === "object" && !Array.isArray(target) && !Array.isArray(source)) {
710
+ const out = { ...target };
711
+ for (const [k, v] of Object.entries(source)) {
712
+ out[k] = deepMerge(target[k], v);
713
+ }
714
+ return out;
715
+ }
716
+ if (Array.isArray(target) && Array.isArray(source)) {
717
+ const seen = /* @__PURE__ */ new Set();
718
+ const out = [];
719
+ for (const item of [...target, ...source]) {
720
+ const key = JSON.stringify(item);
721
+ if (seen.has(key)) continue;
722
+ seen.add(key);
723
+ out.push(item);
724
+ }
725
+ return out;
726
+ }
727
+ return source ?? target;
728
+ }
729
+ function parseJsonOrThrow(raw, side) {
730
+ try {
731
+ return JSON.parse(raw);
732
+ } catch (err) {
733
+ const msg = err instanceof Error ? err.message : String(err);
734
+ throw new Error(`merge-json-deep ${side} not valid JSON: ${msg}`);
735
+ }
736
+ }
737
+ function sha256(buf) {
738
+ return createHash("sha256").update(buf).digest("hex");
739
+ }
740
+ async function hashFile(path) {
741
+ const buf = await readFile(path);
742
+ return sha256(buf);
743
+ }
744
+ async function fileExists(path) {
745
+ try {
746
+ await stat(path);
747
+ return true;
748
+ } catch {
749
+ return false;
750
+ }
751
+ }
752
+ async function ensureDir(dir) {
753
+ try {
754
+ await stat(dir);
755
+ } catch {
756
+ await mkdir(dir, { recursive: true });
757
+ }
758
+ }
759
+ function ledgerPathFor2(profileName) {
760
+ return join(homedir(), ".agentproto", "profiles", `${profileName}.json`);
761
+ }
762
+ async function loadLedger2(path) {
763
+ try {
764
+ const raw = await readFile(path, "utf8");
765
+ return JSON.parse(raw);
766
+ } catch {
767
+ return null;
768
+ }
769
+ }
770
+ async function saveLedger2(path, ledger) {
771
+ await ensureDir(dirname(path));
772
+ await writeFile(path, `${JSON.stringify(ledger, null, 2)}
773
+ `);
774
+ }
775
+ async function readProfileAlias(profileName) {
776
+ const base = process.env["AGENTPROTO_HOME"] ?? join(homedir(), ".agentproto");
777
+ const path = join(base, "config.json");
778
+ try {
779
+ const raw = await readFile(path, "utf8");
780
+ const parsed = JSON.parse(raw);
781
+ const aliased = parsed.profileAliases?.[profileName];
782
+ return typeof aliased === "string" ? aliased : null;
783
+ } catch {
784
+ return null;
785
+ }
786
+ }
484
787
 
485
788
  // src/commands/install.ts
486
789
  async function runInstall(args) {
790
+ const slugPeek = args.find((a) => !a.startsWith("-"));
791
+ if (slugPeek?.startsWith("runtime-profile/")) {
792
+ return runInstallProfile(
793
+ slugPeek,
794
+ args.filter((a) => a !== slugPeek)
795
+ );
796
+ }
487
797
  const { values, positionals } = parseArgs({
488
798
  args: [...args],
489
799
  allowPositionals: true,
@@ -776,7 +1086,7 @@ async function runDownloadInstaller(opts) {
776
1086
  }
777
1087
  if (extractCode !== 0) return extractCode;
778
1088
  const srcBin = join(extractDir, opts.extractBin);
779
- const binDir = process.env["AGENTPROTO_BIN_DIR"] ?? join(homedir2(), ".local", "bin");
1089
+ const binDir = process.env["AGENTPROTO_BIN_DIR"] ?? join(homedir3(), ".local", "bin");
780
1090
  await mkdir(binDir, { recursive: true });
781
1091
  const destBin = join(binDir, opts.extractBin.split("/").pop());
782
1092
  const cpCode = await spawnInherit("cp", ["-p", srcBin, destBin]);
@@ -802,19 +1112,19 @@ async function runDownloadInstaller(opts) {
802
1112
  });
803
1113
  }
804
1114
  }
805
- function homedir2() {
1115
+ function homedir3() {
806
1116
  return process.env["HOME"] ?? process.env["USERPROFILE"] ?? "/";
807
1117
  }
808
1118
  function spawnInherit(cmd, argv) {
809
- return new Promise((resolve3, reject) => {
1119
+ return new Promise((resolve4, reject) => {
810
1120
  const child = spawn(cmd, argv, { stdio: "inherit" });
811
1121
  child.once("error", reject);
812
- child.once("exit", (code) => resolve3(code ?? 0));
1122
+ child.once("exit", (code) => resolve4(code ?? 0));
813
1123
  });
814
1124
  }
815
1125
  async function runVersionCheck(check) {
816
1126
  if (!check) return { ok: false, message: "no version_check declared" };
817
- return new Promise((resolve3) => {
1127
+ return new Promise((resolve4) => {
818
1128
  const child = spawn("bash", ["-lc", check.cmd], {
819
1129
  stdio: ["ignore", "pipe", "ignore"]
820
1130
  });
@@ -822,19 +1132,19 @@ async function runVersionCheck(check) {
822
1132
  child.stdout.on("data", (c) => {
823
1133
  buf += c.toString("utf8");
824
1134
  });
825
- child.once("error", () => resolve3({ ok: false, message: "check failed" }));
1135
+ child.once("error", () => resolve4({ ok: false, message: "check failed" }));
826
1136
  child.once("exit", (code) => {
827
1137
  if (code !== 0) {
828
- resolve3({ ok: false, message: `check exited ${code}` });
1138
+ resolve4({ ok: false, message: `check exited ${code}` });
829
1139
  return;
830
1140
  }
831
1141
  const re = new RegExp(check.parse);
832
1142
  const m = buf.match(re);
833
1143
  if (!m || !m[1]) {
834
- resolve3({ ok: false, message: "could not parse version" });
1144
+ resolve4({ ok: false, message: "could not parse version" });
835
1145
  return;
836
1146
  }
837
- resolve3({ ok: true, message: `version ${m[1]}` });
1147
+ resolve4({ ok: true, message: `version ${m[1]}` });
838
1148
  });
839
1149
  });
840
1150
  }
@@ -1019,6 +1329,134 @@ function formatRelative(ms) {
1019
1329
  return `${Math.round(ms / 864e5)}d`;
1020
1330
  }
1021
1331
 
1332
+ // src/util/pty-factory.ts
1333
+ async function loadNodePtyFactory() {
1334
+ try {
1335
+ const nodePtyMod = await import('node-pty');
1336
+ const nodePty = nodePtyMod.default ?? nodePtyMod;
1337
+ if (typeof nodePty.spawn !== "function") return null;
1338
+ return (opts) => {
1339
+ const pty = nodePty.spawn(opts.command, opts.args, {
1340
+ name: "xterm-256color",
1341
+ cols: opts.cols,
1342
+ rows: opts.rows,
1343
+ cwd: opts.cwd,
1344
+ env: {
1345
+ ...process.env,
1346
+ ...opts.env ?? {}
1347
+ }
1348
+ });
1349
+ const proc = {
1350
+ get pid() {
1351
+ return pty.pid;
1352
+ },
1353
+ write(data) {
1354
+ pty.write(data);
1355
+ },
1356
+ resize(cols, rows) {
1357
+ pty.resize(cols, rows);
1358
+ },
1359
+ kill(signal) {
1360
+ pty.kill(signal);
1361
+ },
1362
+ onData(handler) {
1363
+ pty.onData(handler);
1364
+ },
1365
+ onExit(handler) {
1366
+ pty.onExit(handler);
1367
+ }
1368
+ };
1369
+ return proc;
1370
+ };
1371
+ } catch {
1372
+ return null;
1373
+ }
1374
+ }
1375
+ var CONFIG_FILE_PATH = () => join(homedir(), ".agentproto", "config.json");
1376
+ async function loadConfig(path) {
1377
+ const target = CONFIG_FILE_PATH();
1378
+ try {
1379
+ const raw = await promises.readFile(target, "utf8");
1380
+ const parsed = JSON.parse(raw);
1381
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1382
+ return parsed;
1383
+ }
1384
+ console.warn(
1385
+ `[runtime/config] ${target}: top-level value is not an object \u2014 ignoring`
1386
+ );
1387
+ return {};
1388
+ } catch (err) {
1389
+ const code = err.code;
1390
+ if (code && code !== "ENOENT") {
1391
+ console.warn(
1392
+ `[runtime/config] failed to read ${target}: ${err instanceof Error ? err.message : String(err)}`
1393
+ );
1394
+ }
1395
+ return {};
1396
+ }
1397
+ }
1398
+ var WORKSPACES_CONFIG_VERSION = 1;
1399
+ var DEFAULT_CONFIG_DIR = () => resolve(homedir(), ".agentproto");
1400
+ var DEFAULT_CONFIG_PATH = () => resolve(DEFAULT_CONFIG_DIR(), "workspaces.json");
1401
+ function sanitizeSlug(input2) {
1402
+ const trimmed = input2.trim().toLowerCase();
1403
+ const cleaned = trimmed.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
1404
+ return cleaned || "workspace";
1405
+ }
1406
+ async function loadWorkspacesConfig(path = DEFAULT_CONFIG_PATH()) {
1407
+ let raw;
1408
+ try {
1409
+ raw = await promises.readFile(path, "utf8");
1410
+ } catch (err) {
1411
+ if (err.code === "ENOENT") {
1412
+ return { version: WORKSPACES_CONFIG_VERSION, workspaces: [] };
1413
+ }
1414
+ throw err;
1415
+ }
1416
+ let parsed;
1417
+ try {
1418
+ parsed = JSON.parse(raw);
1419
+ } catch (err) {
1420
+ throw new Error(
1421
+ `agentproto: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete the file or fix it manually.`
1422
+ );
1423
+ }
1424
+ return normalizeConfig(parsed);
1425
+ }
1426
+ function normalizeConfig(parsed) {
1427
+ if (!parsed || typeof parsed !== "object") {
1428
+ return { version: WORKSPACES_CONFIG_VERSION, workspaces: [] };
1429
+ }
1430
+ const obj = parsed;
1431
+ const workspaces = [];
1432
+ if (Array.isArray(obj.workspaces)) {
1433
+ for (const entry of obj.workspaces) {
1434
+ if (!entry || typeof entry !== "object") continue;
1435
+ const e = entry;
1436
+ const slug = typeof e.slug === "string" ? sanitizeSlug(e.slug) : "";
1437
+ const path = typeof e.path === "string" ? e.path : "";
1438
+ if (!slug || !path) continue;
1439
+ const addedAt = typeof e.addedAt === "string" ? e.addedAt : (/* @__PURE__ */ new Date()).toISOString();
1440
+ const updatedAt = typeof e.updatedAt === "string" ? e.updatedAt : addedAt;
1441
+ const we = { slug, path, addedAt, updatedAt };
1442
+ if (typeof e.label === "string" && e.label.trim()) {
1443
+ we.label = e.label.trim();
1444
+ }
1445
+ workspaces.push(we);
1446
+ }
1447
+ }
1448
+ const dedup = /* @__PURE__ */ new Map();
1449
+ for (const w of workspaces) dedup.set(w.slug, w);
1450
+ const finalList = Array.from(dedup.values());
1451
+ const active = typeof obj.active === "string" && finalList.some((w) => w.slug === obj.active) ? obj.active : finalList[0]?.slug;
1452
+ const out = {
1453
+ version: WORKSPACES_CONFIG_VERSION,
1454
+ workspaces: finalList
1455
+ };
1456
+ if (active !== void 0) out.active = active;
1457
+ return out;
1458
+ }
1459
+
1022
1460
  // ../define-doctype/dist/index.mjs
1023
1461
  var DEFAULT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{1,79}$/;
1024
1462
  var MAX_DESCRIPTION_LEN = 2e3;
@@ -1153,7 +1591,7 @@ function createVerbs(spec) {
1153
1591
  }
1154
1592
  return { path, handle: newHandle, rendered };
1155
1593
  }
1156
- async function resolve22(block, ctx = {}) {
1594
+ async function resolve4(block, ctx = {}) {
1157
1595
  if ("inline" in block) {
1158
1596
  return spec.define(block.inline);
1159
1597
  }
@@ -1179,7 +1617,7 @@ function createVerbs(spec) {
1179
1617
  async function deleteFn(path) {
1180
1618
  await rm(path, { force: true });
1181
1619
  }
1182
- return { create, load, list, update, resolve: resolve22, delete: deleteFn };
1620
+ return { create, load, list, update, resolve: resolve4, delete: deleteFn };
1183
1621
  }
1184
1622
  function defaultBody(spec, frontmatter) {
1185
1623
  const id = typeof frontmatter.name === "string" ? frontmatter.name : typeof frontmatter.id === "string" ? frontmatter.id : typeof frontmatter.slug === "string" ? frontmatter.slug : spec.name;
@@ -1503,12 +1941,55 @@ async function writeRuntimeMeta(workspace, meta) {
1503
1941
  await writeFile(
1504
1942
  join(dir, "runtime.json"),
1505
1943
  JSON.stringify(meta, null, 2) + "\n",
1506
- "utf8"
1944
+ { encoding: "utf8", mode: 384 }
1507
1945
  );
1508
1946
  } catch (err) {
1509
1947
  console.error("[runtime] failed to write .agentproto/runtime.json:", err);
1510
1948
  }
1511
1949
  }
1950
+ async function unlinkRuntimeMeta(workspace) {
1951
+ try {
1952
+ await unlink(join(workspace, ".agentproto", "runtime.json"));
1953
+ } catch {
1954
+ }
1955
+ }
1956
+ async function readRuntimeMeta(workspace) {
1957
+ const path = join(workspace, ".agentproto", "runtime.json");
1958
+ try {
1959
+ const [raw, st] = await Promise.all([readFile(path, "utf8"), stat(path)]);
1960
+ const meta = JSON.parse(raw);
1961
+ return { meta, mtime: st.mtime };
1962
+ } catch {
1963
+ return null;
1964
+ }
1965
+ }
1966
+ async function sweepStaleRuntimeMetas(workspaces, currentWorkspace) {
1967
+ const cleaned = [];
1968
+ for (const ws of workspaces) {
1969
+ if (ws === currentWorkspace) continue;
1970
+ const meta = await readRuntimeMeta(ws);
1971
+ if (!meta) continue;
1972
+ const pid = meta.meta.pid;
1973
+ if (typeof pid === "number" && !isPidAlive(pid)) {
1974
+ try {
1975
+ await unlink(join(ws, ".agentproto", "runtime.json"));
1976
+ cleaned.push(join(ws, ".agentproto", "runtime.json"));
1977
+ } catch {
1978
+ }
1979
+ }
1980
+ }
1981
+ return cleaned;
1982
+ }
1983
+ function isPidAlive(pid) {
1984
+ try {
1985
+ process.kill(pid, 0);
1986
+ return true;
1987
+ } catch (err) {
1988
+ const code = err.code;
1989
+ if (code === "EPERM") return true;
1990
+ return false;
1991
+ }
1992
+ }
1512
1993
  var DEFAULT_TIMEOUT_MS = 6e4;
1513
1994
  var MAX_TIMEOUT_MS = 6e5;
1514
1995
  var ALLOWLIST_REL = ".agentproto/allowed-commands.json";
@@ -1937,21 +2418,21 @@ function registerFsTools(server, opts) {
1937
2418
  }
1938
2419
  );
1939
2420
  }
1940
- var WORKSPACES_CONFIG_VERSION = 1;
1941
- var DEFAULT_CONFIG_DIR = () => resolve(homedir(), ".agentproto");
1942
- var DEFAULT_CONFIG_PATH = () => resolve(DEFAULT_CONFIG_DIR(), "workspaces.json");
1943
- function sanitizeSlug(input2) {
2421
+ var WORKSPACES_CONFIG_VERSION2 = 1;
2422
+ var DEFAULT_CONFIG_DIR2 = () => resolve(homedir(), ".agentproto");
2423
+ var DEFAULT_CONFIG_PATH2 = () => resolve(DEFAULT_CONFIG_DIR2(), "workspaces.json");
2424
+ function sanitizeSlug2(input2) {
1944
2425
  const trimmed = input2.trim().toLowerCase();
1945
2426
  const cleaned = trimmed.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
1946
2427
  return cleaned || "workspace";
1947
2428
  }
1948
- async function loadWorkspacesConfig(path = DEFAULT_CONFIG_PATH()) {
2429
+ async function loadWorkspacesConfig2(path = DEFAULT_CONFIG_PATH2()) {
1949
2430
  let raw;
1950
2431
  try {
1951
2432
  raw = await promises.readFile(path, "utf8");
1952
2433
  } catch (err) {
1953
2434
  if (err.code === "ENOENT") {
1954
- return { version: WORKSPACES_CONFIG_VERSION, workspaces: [] };
2435
+ return { version: WORKSPACES_CONFIG_VERSION2, workspaces: [] };
1955
2436
  }
1956
2437
  throw err;
1957
2438
  }
@@ -1963,18 +2444,18 @@ async function loadWorkspacesConfig(path = DEFAULT_CONFIG_PATH()) {
1963
2444
  `agentproto: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete the file or fix it manually.`
1964
2445
  );
1965
2446
  }
1966
- return normaliseConfig(parsed);
2447
+ return normalizeConfig2(parsed);
1967
2448
  }
1968
2449
  function findWorkspace(config, slug) {
1969
- return config.workspaces.find((w) => w.slug === sanitizeSlug(slug));
2450
+ return config.workspaces.find((w) => w.slug === sanitizeSlug2(slug));
1970
2451
  }
1971
2452
  function getActiveWorkspace(config) {
1972
2453
  if (!config.active) return config.workspaces[0];
1973
2454
  return findWorkspace(config, config.active) ?? config.workspaces[0];
1974
2455
  }
1975
- function normaliseConfig(parsed) {
2456
+ function normalizeConfig2(parsed) {
1976
2457
  if (!parsed || typeof parsed !== "object") {
1977
- return { version: WORKSPACES_CONFIG_VERSION, workspaces: [] };
2458
+ return { version: WORKSPACES_CONFIG_VERSION2, workspaces: [] };
1978
2459
  }
1979
2460
  const obj = parsed;
1980
2461
  const workspaces = [];
@@ -1982,7 +2463,7 @@ function normaliseConfig(parsed) {
1982
2463
  for (const entry of obj.workspaces) {
1983
2464
  if (!entry || typeof entry !== "object") continue;
1984
2465
  const e = entry;
1985
- const slug = typeof e.slug === "string" ? sanitizeSlug(e.slug) : "";
2466
+ const slug = typeof e.slug === "string" ? sanitizeSlug2(e.slug) : "";
1986
2467
  const path = typeof e.path === "string" ? e.path : "";
1987
2468
  if (!slug || !path) continue;
1988
2469
  const addedAt = typeof e.addedAt === "string" ? e.addedAt : (/* @__PURE__ */ new Date()).toISOString();
@@ -1999,7 +2480,7 @@ function normaliseConfig(parsed) {
1999
2480
  const finalList = Array.from(dedup.values());
2000
2481
  const active = typeof obj.active === "string" && finalList.some((w) => w.slug === obj.active) ? obj.active : finalList[0]?.slug;
2001
2482
  const out = {
2002
- version: WORKSPACES_CONFIG_VERSION,
2483
+ version: WORKSPACES_CONFIG_VERSION2,
2003
2484
  workspaces: finalList
2004
2485
  };
2005
2486
  if (active !== void 0) out.active = active;
@@ -2125,7 +2606,7 @@ async function scanGoose(home, out, errors) {
2125
2606
  async function scanRegisteredWorkspaces(out, errors) {
2126
2607
  let workspaces = [];
2127
2608
  try {
2128
- const cfg = await loadWorkspacesConfig();
2609
+ const cfg = await loadWorkspacesConfig2();
2129
2610
  workspaces = cfg.workspaces;
2130
2611
  } catch (err) {
2131
2612
  errors.push(`workspaces: load failed \u2014 ${err.message}`);
@@ -2243,7 +2724,7 @@ async function loadImportedMcps(path = IMPORTED_MCPS_PATH()) {
2243
2724
  `agentproto: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete or fix manually.`
2244
2725
  );
2245
2726
  }
2246
- return normalise(parsed);
2727
+ return normalize3(parsed);
2247
2728
  }
2248
2729
  async function saveImportedMcps(config, path = IMPORTED_MCPS_PATH()) {
2249
2730
  await promises.mkdir(dirname(path), { recursive: true });
@@ -2269,7 +2750,7 @@ function addImport(config, input2) {
2269
2750
  function removeImport(config, id) {
2270
2751
  return { ...config, imports: config.imports.filter((e) => e.id !== id) };
2271
2752
  }
2272
- function normalise(parsed) {
2753
+ function normalize3(parsed) {
2273
2754
  if (!parsed || typeof parsed !== "object") return EMPTY;
2274
2755
  const obj = parsed;
2275
2756
  const imports = [];
@@ -2289,6 +2770,7 @@ function normalise(parsed) {
2289
2770
  }
2290
2771
  function registerSessionTools(server, opts) {
2291
2772
  const { registry, resolveAgentAdapter, listAgentAdapters, mcpProxy } = opts;
2773
+ const ptyEnabled = opts.ptyEnabled === true;
2292
2774
  server.tool(
2293
2775
  "start_agent_session",
2294
2776
  "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 +2807,7 @@ function registerSessionTools(server, opts) {
2325
2807
  let resolvedSlug = input2.workspaceSlug ?? "default";
2326
2808
  if (!cwd) {
2327
2809
  try {
2328
- const config = await loadWorkspacesConfig();
2810
+ const config = await loadWorkspacesConfig2();
2329
2811
  const ws = input2.workspaceSlug ? findWorkspace(config, input2.workspaceSlug) : getActiveWorkspace(config);
2330
2812
  if (ws) {
2331
2813
  cwd = ws.path;
@@ -2421,22 +2903,49 @@ function registerSessionTools(server, opts) {
2421
2903
  }
2422
2904
  );
2423
2905
  server.tool(
2424
- "list_agent_sessions",
2425
- "List all sessions tracked by the daemon \u2014 running, exited, killed. Use to find a session id when the operator's previous start call returned long ago, or to tell the user what's currently active on their machine.",
2906
+ "list_sessions",
2907
+ "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.",
2426
2908
  {
2427
- onlyAlive: z.boolean().optional().describe("Filter to status running/starting/mounting only. Default false.")
2909
+ kind: z.enum(["terminal", "agent-cli", "command", "all"]).optional().describe(
2910
+ "Filter by session kind. `all` (default) returns every kind. Use `terminal` to list only PTY sessions, `agent-cli` for structured ACP agents."
2911
+ ),
2912
+ onlyAlive: z.boolean().optional().describe("When true, only running/starting sessions. Default false."),
2913
+ status: z.enum(["starting", "running", "exited", "killed", "error"]).optional().describe("Filter by exact status (overrides onlyAlive).")
2428
2914
  },
2429
2915
  async (input2) => {
2430
- const all = registry.list();
2431
- const filtered = input2.onlyAlive ? all.filter(
2432
- (s) => s.status === "running" || s.status === "starting" || s.status === "mounting"
2433
- ) : all;
2916
+ let rows = registry.list();
2917
+ if (input2.kind && input2.kind !== "all") {
2918
+ rows = rows.filter((s) => s.kind === input2.kind);
2919
+ }
2920
+ if (input2.status) {
2921
+ rows = rows.filter((s) => s.status === input2.status);
2922
+ } else if (input2.onlyAlive) {
2923
+ rows = rows.filter(
2924
+ (s) => s.status === "running" || s.status === "starting"
2925
+ );
2926
+ }
2434
2927
  return {
2435
2928
  content: [
2436
- {
2437
- type: "text",
2438
- text: JSON.stringify({ sessions: filtered }, null, 2)
2439
- }
2929
+ { type: "text", text: JSON.stringify({ sessions: rows }, null, 2) }
2930
+ ]
2931
+ };
2932
+ }
2933
+ );
2934
+ server.tool(
2935
+ "list_agent_sessions",
2936
+ "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.",
2937
+ {
2938
+ onlyAlive: z.boolean().optional().describe("Filter to status running/starting only. Default false.")
2939
+ },
2940
+ async (input2) => {
2941
+ const all = registry.list();
2942
+ const filtered = input2.onlyAlive ? all.filter((s) => s.status === "running" || s.status === "starting") : all;
2943
+ return {
2944
+ content: [
2945
+ {
2946
+ type: "text",
2947
+ text: JSON.stringify({ sessions: filtered }, null, 2)
2948
+ }
2440
2949
  ]
2441
2950
  };
2442
2951
  }
@@ -2792,6 +3301,206 @@ function registerSessionTools(server, opts) {
2792
3301
  };
2793
3302
  }
2794
3303
  );
3304
+ const ptyNotConfigured = (toolName) => ({
3305
+ content: [
3306
+ {
3307
+ type: "text",
3308
+ 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).`
3309
+ }
3310
+ ],
3311
+ isError: true
3312
+ });
3313
+ server.tool(
3314
+ "start_terminal_session",
3315
+ "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.",
3316
+ {
3317
+ argv: z.array(z.string()).min(1).describe(
3318
+ "Argv array. First element is the binary, rest are arguments. e.g. ['claude'] or ['bash', '-l']."
3319
+ ),
3320
+ workspaceSlug: z.string().optional().describe(
3321
+ "Workspace slug from `agentproto workspace list`. Resolves cwd. Omit to use `cwd` explicitly or the active workspace."
3322
+ ),
3323
+ cwd: z.string().optional().describe("Absolute cwd. Wins over workspaceSlug when both set."),
3324
+ cols: z.number().int().min(1).max(500).optional().describe("Initial cols. Default 80."),
3325
+ rows: z.number().int().min(1).max(200).optional().describe("Initial rows. Default 24."),
3326
+ name: z.string().optional().describe(
3327
+ "User-friendly slug. Becomes an alias for the session id in subsequent tool calls (read/write/kill accept either)."
3328
+ ),
3329
+ label: z.string().optional().describe(
3330
+ "Free-text label surfaced in list_agent_sessions and the UI."
3331
+ )
3332
+ },
3333
+ async (input2) => {
3334
+ if (!ptyEnabled) return ptyNotConfigured("start_terminal_session");
3335
+ let cwd = input2.cwd;
3336
+ let resolvedSlug = input2.workspaceSlug ?? "default";
3337
+ if (!cwd) {
3338
+ try {
3339
+ const config = await loadWorkspacesConfig2();
3340
+ const ws = input2.workspaceSlug ? findWorkspace(config, input2.workspaceSlug) : getActiveWorkspace(config);
3341
+ if (ws) {
3342
+ cwd = ws.path;
3343
+ resolvedSlug = ws.slug;
3344
+ }
3345
+ } catch {
3346
+ }
3347
+ }
3348
+ if (!cwd) {
3349
+ return {
3350
+ content: [
3351
+ {
3352
+ type: "text",
3353
+ text: "start_terminal_session: no cwd resolvable. Pass `cwd` explicitly or `workspaceSlug` matching `agentproto workspace list`."
3354
+ }
3355
+ ],
3356
+ isError: true
3357
+ };
3358
+ }
3359
+ try {
3360
+ const desc = registry.spawnPty({
3361
+ argv: input2.argv,
3362
+ cwd,
3363
+ workspaceSlug: resolvedSlug,
3364
+ cols: input2.cols ?? 80,
3365
+ rows: input2.rows ?? 24,
3366
+ ...input2.name ? { name: input2.name } : {},
3367
+ ...input2.label ? { label: input2.label } : {}
3368
+ });
3369
+ return {
3370
+ content: [{ type: "text", text: JSON.stringify(desc, null, 2) }]
3371
+ };
3372
+ } catch (err) {
3373
+ return {
3374
+ content: [
3375
+ {
3376
+ type: "text",
3377
+ text: `start_terminal_session: ${err instanceof Error ? err.message : String(err)}`
3378
+ }
3379
+ ],
3380
+ isError: true
3381
+ };
3382
+ }
3383
+ }
3384
+ );
3385
+ server.tool(
3386
+ "write_terminal_input",
3387
+ "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.",
3388
+ {
3389
+ sessionId: z.string().describe("Session id OR name from start_terminal_session."),
3390
+ text: z.string().describe("Text to write. Sent as-is to the PTY's stdin.")
3391
+ },
3392
+ async (input2) => {
3393
+ if (!ptyEnabled) return ptyNotConfigured("write_terminal_input");
3394
+ const desc = registry.findByIdOrName(input2.sessionId);
3395
+ if (!desc) {
3396
+ return {
3397
+ content: [
3398
+ {
3399
+ type: "text",
3400
+ text: `write_terminal_input: no session "${input2.sessionId}"`
3401
+ }
3402
+ ],
3403
+ isError: true
3404
+ };
3405
+ }
3406
+ const ok = registry.writeTerminalInput(desc.id, input2.text);
3407
+ return {
3408
+ content: [
3409
+ {
3410
+ type: "text",
3411
+ text: JSON.stringify({ ok, sessionId: desc.id }, null, 2)
3412
+ }
3413
+ ],
3414
+ ...ok ? {} : { isError: true }
3415
+ };
3416
+ }
3417
+ );
3418
+ server.tool(
3419
+ "read_terminal_output",
3420
+ "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.",
3421
+ {
3422
+ sessionId: z.string().describe("Session id OR name from start_terminal_session."),
3423
+ lastBytes: z.number().int().min(1).max(64 * 1024).optional().describe("Max bytes from the tail. Default: full ring buffer (~64 KiB).")
3424
+ },
3425
+ async (input2) => {
3426
+ if (!ptyEnabled) return ptyNotConfigured("read_terminal_output");
3427
+ const desc = registry.findByIdOrName(input2.sessionId);
3428
+ if (!desc) {
3429
+ return {
3430
+ content: [
3431
+ {
3432
+ type: "text",
3433
+ text: `read_terminal_output: no session "${input2.sessionId}"`
3434
+ }
3435
+ ],
3436
+ isError: true
3437
+ };
3438
+ }
3439
+ const buf = registry.readTerminalOutput(
3440
+ desc.id,
3441
+ input2.lastBytes
3442
+ );
3443
+ if (!buf) {
3444
+ return {
3445
+ content: [
3446
+ {
3447
+ type: "text",
3448
+ text: `read_terminal_output: session "${desc.id}" is not a PTY`
3449
+ }
3450
+ ],
3451
+ isError: true
3452
+ };
3453
+ }
3454
+ return {
3455
+ content: [
3456
+ {
3457
+ type: "text",
3458
+ text: JSON.stringify(
3459
+ {
3460
+ sessionId: desc.id,
3461
+ status: desc.status,
3462
+ bytes: buf.byteLength,
3463
+ b64: buf.toString("base64")
3464
+ },
3465
+ null,
3466
+ 2
3467
+ )
3468
+ }
3469
+ ]
3470
+ };
3471
+ }
3472
+ );
3473
+ server.tool(
3474
+ "kill_terminal_session",
3475
+ "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.",
3476
+ {
3477
+ sessionId: z.string().describe("Session id OR name from start_terminal_session.")
3478
+ },
3479
+ async (input2) => {
3480
+ if (!ptyEnabled) return ptyNotConfigured("kill_terminal_session");
3481
+ const desc = registry.findByIdOrName(input2.sessionId);
3482
+ if (!desc) {
3483
+ return {
3484
+ content: [
3485
+ {
3486
+ type: "text",
3487
+ text: `kill_terminal_session: no session "${input2.sessionId}"`
3488
+ }
3489
+ ],
3490
+ isError: true
3491
+ };
3492
+ }
3493
+ const ok = registry.kill(desc.id);
3494
+ return {
3495
+ content: [
3496
+ {
3497
+ type: "text",
3498
+ text: JSON.stringify({ ok, sessionId: desc.id }, null, 2)
3499
+ }
3500
+ ]
3501
+ };
3502
+ }
3503
+ );
2795
3504
  }
2796
3505
  function startHeartbeat(opts) {
2797
3506
  const filename = opts.filename ?? "HEARTBEAT.md";
@@ -2964,6 +3673,11 @@ function preview(text3, max = 120) {
2964
3673
  const flat = text3.replace(/\s+/g, " ").trim();
2965
3674
  return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
2966
3675
  }
3676
+ var DEFAULT_ALLOWED_ORIGINS = [
3677
+ "http://localhost:*",
3678
+ "http://127.0.0.1:*",
3679
+ "https://localhost:*"
3680
+ ];
2967
3681
  async function startHttpServer(opts) {
2968
3682
  const startedAt = Date.now();
2969
3683
  const authSource = opts.auth ?? { mode: "none" };
@@ -2988,15 +3702,69 @@ async function startHttpServer(opts) {
2988
3702
  }
2989
3703
  return true;
2990
3704
  }
3705
+ function checkSessionsToken(req) {
3706
+ if (!opts.token) return "ok";
3707
+ const header = req.headers.authorization;
3708
+ const expected = `Bearer ${opts.token}`;
3709
+ if (header === expected) return "ok";
3710
+ const urlStr = req.url ?? "";
3711
+ if (urlStr.includes("?")) {
3712
+ const qs = new URLSearchParams(urlStr.slice(urlStr.indexOf("?") + 1));
3713
+ const qsToken = qs.get("token");
3714
+ if (qsToken && qsToken === opts.token) return "ok";
3715
+ }
3716
+ const origin = req.headers.origin;
3717
+ if (typeof origin === "string" && originAllowed(origin)) return "ok";
3718
+ return header ? "bad" : "missing";
3719
+ }
3720
+ function originAllowed(origin) {
3721
+ const list = opts.strictOrigins ? opts.allowedOrigins ?? [] : [...DEFAULT_ALLOWED_ORIGINS, ...opts.allowedOrigins ?? []];
3722
+ for (const pattern of list) {
3723
+ if (pattern === origin) return true;
3724
+ if (pattern.endsWith(":*")) {
3725
+ const prefix = pattern.slice(0, -2);
3726
+ if (origin === prefix || origin.startsWith(prefix + ":")) return true;
3727
+ }
3728
+ }
3729
+ return false;
3730
+ }
3731
+ function rejectUnauthorizedSession(req, res, kind) {
3732
+ const origin = req.headers.origin ?? null;
3733
+ const allowList = opts.strictOrigins ? opts.allowedOrigins ?? [] : [...DEFAULT_ALLOWED_ORIGINS, ...opts.allowedOrigins ?? []];
3734
+ res.writeHead(401, { "content-type": "application/json" });
3735
+ 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.`;
3736
+ res.end(
3737
+ JSON.stringify({
3738
+ error: "sessions_unauthorized",
3739
+ message,
3740
+ // Debug data so a UI / network-tab inspection points the user
3741
+ // straight at the fix without needing the daemon's stderr.
3742
+ rejectedOrigin: origin,
3743
+ allowedOrigins: allowList,
3744
+ // We never echo the token; only the absence-or-presence flag.
3745
+ receivedAuthHeader: typeof req.headers.authorization === "string"
3746
+ })
3747
+ );
3748
+ }
3749
+ function isMutatingSessionsRoute(method, path) {
3750
+ if (!path.startsWith("/sessions")) return false;
3751
+ if (method === "POST" || method === "DELETE" || method === "PUT" || method === "PATCH") {
3752
+ return true;
3753
+ }
3754
+ return false;
3755
+ }
2991
3756
  async function handleMcp(req, res) {
2992
3757
  if (!authorize(req, res)) return;
2993
3758
  const server2 = await opts.mcpServerFactory();
2994
3759
  const transport = new StreamableHTTPServerTransport({
2995
3760
  sessionIdGenerator: void 0
2996
3761
  });
2997
- transport.onerror = (err) => {
2998
- console.error("[mcp transport.onerror]", err);
2999
- };
3762
+ const transportInternal = transport;
3763
+ if ("onerror" in transport) {
3764
+ transportInternal["onerror"] = (err) => {
3765
+ console.error("[mcp transport.onerror]", err);
3766
+ };
3767
+ }
3000
3768
  res.on("close", () => {
3001
3769
  void transport.close();
3002
3770
  void server2.close();
@@ -3073,6 +3841,77 @@ async function startHttpServer(opts) {
3073
3841
  res.writeHead(202, { "content-type": "application/json" });
3074
3842
  res.end(JSON.stringify({ status: "fired" }));
3075
3843
  }
3844
+ async function handleFileUpload(req, res, url) {
3845
+ const reply = (status, body) => {
3846
+ res.writeHead(status, { "content-type": "application/json" });
3847
+ res.end(JSON.stringify(body));
3848
+ };
3849
+ const qs = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
3850
+ const cwd = qs.get("cwd");
3851
+ const rawName = qs.get("name");
3852
+ if (!cwd || !rawName) {
3853
+ reply(400, { error: "missing_cwd_or_name" });
3854
+ return;
3855
+ }
3856
+ if (!isAbsolute(cwd)) {
3857
+ reply(400, { error: "cwd_not_absolute" });
3858
+ return;
3859
+ }
3860
+ const safeName = rawName.replace(/[/\\]/g, "_").replace(/\.\.+/g, ".").replace(/^\.+/, "").slice(0, 200);
3861
+ if (safeName.length === 0) {
3862
+ reply(400, { error: "invalid_name" });
3863
+ return;
3864
+ }
3865
+ try {
3866
+ const st = await stat(cwd);
3867
+ if (!st.isDirectory()) {
3868
+ reply(400, { error: "cwd_not_a_directory" });
3869
+ return;
3870
+ }
3871
+ } catch {
3872
+ reply(400, { error: "cwd_not_found" });
3873
+ return;
3874
+ }
3875
+ const dir = join(cwd, ".agentproto-attachments");
3876
+ const target = resolve(dir, safeName);
3877
+ if (!target.startsWith(resolve(dir) + (dir.endsWith("/") ? "" : "/"))) {
3878
+ reply(400, { error: "path_traversal_blocked" });
3879
+ return;
3880
+ }
3881
+ const MAX_BYTES = 32 * 1024 * 1024;
3882
+ const chunks = [];
3883
+ let total = 0;
3884
+ let aborted = false;
3885
+ await new Promise((resolveBody, rejectBody) => {
3886
+ req.on("data", (chunk) => {
3887
+ if (aborted) return;
3888
+ const buf = chunk;
3889
+ total += buf.length;
3890
+ if (total > MAX_BYTES) {
3891
+ aborted = true;
3892
+ reply(413, { error: "file_too_large", maxBytes: MAX_BYTES });
3893
+ rejectBody(new Error("too_large"));
3894
+ return;
3895
+ }
3896
+ chunks.push(buf);
3897
+ });
3898
+ req.on("end", () => {
3899
+ if (!aborted) resolveBody();
3900
+ });
3901
+ req.on("error", (err) => rejectBody(err));
3902
+ }).catch(() => void 0);
3903
+ if (aborted) return;
3904
+ try {
3905
+ await mkdir(dir, { recursive: true });
3906
+ await writeFile(target, Buffer.concat(chunks));
3907
+ reply(200, { path: target, bytes: total });
3908
+ } catch (err) {
3909
+ reply(500, {
3910
+ error: "write_failed",
3911
+ message: err instanceof Error ? err.message : String(err)
3912
+ });
3913
+ }
3914
+ }
3076
3915
  function applyCors(req, res) {
3077
3916
  const origin = req.headers.origin ?? "*";
3078
3917
  res.setHeader("Access-Control-Allow-Origin", origin);
@@ -3086,6 +3925,9 @@ async function startHttpServer(opts) {
3086
3925
  "Access-Control-Allow-Methods",
3087
3926
  "GET,POST,PUT,PATCH,DELETE,OPTIONS"
3088
3927
  );
3928
+ if (req.headers["access-control-request-private-network"] === "true") {
3929
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
3930
+ }
3089
3931
  }
3090
3932
  const server = createServer((req, res) => {
3091
3933
  void (async () => {
@@ -3130,19 +3972,36 @@ async function startHttpServer(opts) {
3130
3972
  await handleHeartbeatTick(req, res);
3131
3973
  return;
3132
3974
  }
3975
+ if (path === "/files/upload" && req.method === "POST") {
3976
+ const gate = checkSessionsToken(req);
3977
+ if (gate !== "ok") {
3978
+ rejectUnauthorizedSession(req, res, gate);
3979
+ return;
3980
+ }
3981
+ await handleFileUpload(req, res, url);
3982
+ return;
3983
+ }
3133
3984
  if (opts.sessions && path.startsWith("/sessions")) {
3985
+ if (isMutatingSessionsRoute(req.method ?? "GET", path)) {
3986
+ const gate = checkSessionsToken(req);
3987
+ if (gate !== "ok") {
3988
+ rejectUnauthorizedSession(req, res, gate);
3989
+ return;
3990
+ }
3991
+ }
3134
3992
  const handled = await handleSessions(
3135
3993
  req,
3136
3994
  res,
3137
3995
  path,
3138
3996
  opts.sessions,
3139
- opts.resolveAgentAdapter
3997
+ opts.resolveAgentAdapter,
3998
+ opts.ptyEnabled === true
3140
3999
  );
3141
4000
  if (handled) return;
3142
4001
  }
3143
4002
  if (path === "/workspaces" && req.method === "GET") {
3144
4003
  try {
3145
- const config = await loadWorkspacesConfig();
4004
+ const config = await loadWorkspacesConfig2();
3146
4005
  res.writeHead(200, { "content-type": "application/json" });
3147
4006
  res.end(JSON.stringify(config));
3148
4007
  } catch (err) {
@@ -3326,19 +4185,179 @@ async function startHttpServer(opts) {
3326
4185
  }
3327
4186
  })();
3328
4187
  });
4188
+ const wss = new WebSocketServer({ noServer: true });
4189
+ server.on("upgrade", (req, socket, head) => {
4190
+ const url = req.url ?? "/";
4191
+ const path = url.split("?")[0] ?? "/";
4192
+ const ptyMatch = path.match(/^\/sessions\/([^/]+)\/pty$/);
4193
+ if (!ptyMatch) {
4194
+ socket.destroy();
4195
+ return;
4196
+ }
4197
+ if (!opts.sessions || opts.ptyEnabled !== true) {
4198
+ rejectUpgrade(socket, 501, "pty_not_configured");
4199
+ return;
4200
+ }
4201
+ const gate = checkSessionsToken(req);
4202
+ if (gate !== "ok") {
4203
+ rejectUpgrade(socket, 401, gate === "missing" ? "missing_token" : "bad_token");
4204
+ return;
4205
+ }
4206
+ const id = ptyMatch[1];
4207
+ if (!id) {
4208
+ rejectUpgrade(socket, 400, "missing_id");
4209
+ return;
4210
+ }
4211
+ const desc = opts.sessions.findByIdOrName(id);
4212
+ if (!desc) {
4213
+ rejectUpgrade(socket, 404, "session_not_found");
4214
+ return;
4215
+ }
4216
+ if (desc.pty !== true) {
4217
+ rejectUpgrade(socket, 400, "session_not_pty");
4218
+ return;
4219
+ }
4220
+ if (desc.status === "exited" || desc.status === "killed" || desc.status === "error") {
4221
+ rejectUpgrade(
4222
+ socket,
4223
+ 410,
4224
+ `session_${desc.status}`,
4225
+ `Session ${desc.id}${desc.name ? ` (${desc.name})` : ""} has ${desc.status}. Spawn a fresh one with: agentproto sessions restart ${desc.name ?? desc.id}`
4226
+ );
4227
+ return;
4228
+ }
4229
+ wss.handleUpgrade(req, socket, head, (ws) => {
4230
+ handlePtyWebSocket(ws, req, desc.id, opts.sessions);
4231
+ });
4232
+ });
3329
4233
  const bind = opts.bind ?? "127.0.0.1";
3330
- await new Promise((resolve7, reject) => {
4234
+ await new Promise((resolve8, reject) => {
3331
4235
  server.once("error", reject);
3332
- server.listen(opts.port, bind, () => resolve7());
4236
+ server.listen(opts.port, bind, () => resolve8());
3333
4237
  });
3334
4238
  return {
3335
4239
  url: `http://${bind}:${opts.port}`,
3336
4240
  async stop() {
3337
- await new Promise((resolve7) => server.close(() => resolve7()));
4241
+ wss.close();
4242
+ await new Promise((resolve8) => server.close(() => resolve8()));
3338
4243
  }
3339
4244
  };
3340
4245
  }
3341
- async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
4246
+ function rejectUpgrade(socket, status, reason, message) {
4247
+ const reasonText = `${status} ${reason}`;
4248
+ const body = { error: reason };
4249
+ if (message) body.message = message;
4250
+ try {
4251
+ socket.write(
4252
+ `HTTP/1.1 ${reasonText}\r
4253
+ Content-Type: application/json\r
4254
+ Connection: close\r
4255
+ \r
4256
+ ` + JSON.stringify(body) + `
4257
+ `
4258
+ );
4259
+ } catch {
4260
+ }
4261
+ socket.destroy();
4262
+ }
4263
+ function handlePtyWebSocket(ws, req, sessionId, registry) {
4264
+ const query = new URLSearchParams(
4265
+ (req.url ?? "").includes("?") ? (req.url ?? "").split("?")[1] : ""
4266
+ );
4267
+ const cols = clampPositiveInt(query.get("cols"), 80);
4268
+ const rows = clampPositiveInt(query.get("rows"), 24);
4269
+ const BACKPRESSURE_BYTES = 256 * 1024;
4270
+ const handle = registry.attachPty(
4271
+ sessionId,
4272
+ { cols, rows },
4273
+ (chunk) => {
4274
+ if (ws.readyState !== ws.OPEN) return;
4275
+ if (ws.bufferedAmount > BACKPRESSURE_BYTES) return;
4276
+ ws.send(
4277
+ JSON.stringify({ kind: "data", b64: chunk.toString("base64") })
4278
+ );
4279
+ },
4280
+ (evt) => {
4281
+ if (ws.readyState !== ws.OPEN) return;
4282
+ ws.send(
4283
+ JSON.stringify({
4284
+ kind: "exit",
4285
+ exitCode: evt.exitCode,
4286
+ ...evt.signal != null ? { signal: evt.signal } : {}
4287
+ })
4288
+ );
4289
+ try {
4290
+ ws.close(1e3, "session exited");
4291
+ } catch {
4292
+ }
4293
+ }
4294
+ );
4295
+ if (!handle) {
4296
+ try {
4297
+ ws.close(1011, "session not attachable");
4298
+ } catch {
4299
+ }
4300
+ return;
4301
+ }
4302
+ ws.on("message", (raw, isBinary) => {
4303
+ if (isBinary) {
4304
+ return;
4305
+ }
4306
+ let frame;
4307
+ try {
4308
+ frame = JSON.parse(raw.toString("utf8"));
4309
+ } catch {
4310
+ return;
4311
+ }
4312
+ if (!frame || typeof frame !== "object") return;
4313
+ const f = frame;
4314
+ switch (f.kind) {
4315
+ case "input": {
4316
+ if (typeof f.text === "string") {
4317
+ handle.write(f.text);
4318
+ } else if (typeof f.b64 === "string") {
4319
+ try {
4320
+ handle.write(Buffer.from(f.b64, "base64").toString("utf8"));
4321
+ } catch {
4322
+ }
4323
+ }
4324
+ break;
4325
+ }
4326
+ case "resize": {
4327
+ const c = typeof f.cols === "number" && f.cols > 0 ? Math.floor(f.cols) : null;
4328
+ const r = typeof f.rows === "number" && f.rows > 0 ? Math.floor(f.rows) : null;
4329
+ if (c && r) handle.resize(c, r);
4330
+ break;
4331
+ }
4332
+ case "ping":
4333
+ try {
4334
+ ws.send(JSON.stringify({ kind: "pong" }));
4335
+ } catch {
4336
+ }
4337
+ break;
4338
+ }
4339
+ });
4340
+ ws.on("close", () => {
4341
+ handle.detach();
4342
+ });
4343
+ ws.on("error", () => {
4344
+ handle.detach();
4345
+ });
4346
+ }
4347
+ function clampPositiveInt(raw, fallback) {
4348
+ if (!raw) return fallback;
4349
+ const n = Number.parseInt(raw, 10);
4350
+ return Number.isFinite(n) && n > 0 ? n : fallback;
4351
+ }
4352
+ function clampInt(raw, fallback, min, max) {
4353
+ if (!raw) return fallback;
4354
+ const n = Number.parseInt(raw, 10);
4355
+ if (!Number.isFinite(n)) return fallback;
4356
+ if (n < min) return min;
4357
+ if (n > max) return max;
4358
+ return n;
4359
+ }
4360
+ async function handleSessions(req, res, path, registry, resolveAgentAdapter, ptyEnabled = false) {
3342
4361
  const json = (status, body) => {
3343
4362
  res.writeHead(status, { "content-type": "application/json" });
3344
4363
  res.end(JSON.stringify(body));
@@ -3370,7 +4389,7 @@ async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
3370
4389
  let workspaceSlug = typeof b.workspaceSlug === "string" ? b.workspaceSlug : "";
3371
4390
  if (!cwd) {
3372
4391
  try {
3373
- const config = await loadWorkspacesConfig();
4392
+ const config = await loadWorkspacesConfig2();
3374
4393
  const ws = workspaceSlug ? findWorkspace(config, workspaceSlug) : getActiveWorkspace(config);
3375
4394
  if (ws) {
3376
4395
  cwd = ws.path;
@@ -3392,7 +4411,11 @@ async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
3392
4411
  return true;
3393
4412
  }
3394
4413
  try {
3395
- const agentSession = await resolved.startSession({ cwd });
4414
+ const resumeSessionId = typeof b.resumeSessionId === "string" && b.resumeSessionId.length > 0 ? b.resumeSessionId : void 0;
4415
+ const agentSession = await resolved.startSession({
4416
+ cwd,
4417
+ ...resumeSessionId ? { resumeSessionId } : {}
4418
+ });
3396
4419
  const desc = registry.spawnAgent({
3397
4420
  workspaceSlug,
3398
4421
  cwd,
@@ -3411,14 +4434,78 @@ async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
3411
4434
  }
3412
4435
  return true;
3413
4436
  }
4437
+ if (path === "/sessions/terminal" && req.method === "POST") {
4438
+ if (!ptyEnabled) {
4439
+ json(501, {
4440
+ error: "pty_not_configured",
4441
+ message: "POST /sessions/terminal needs the host to inject `spawnPty` into createGateway (node-pty optional dep \u2014 install in @agentproto/cli)."
4442
+ });
4443
+ return true;
4444
+ }
4445
+ const body = await readJsonBody(req);
4446
+ if (!body || typeof body !== "object") {
4447
+ json(400, { error: "invalid_body" });
4448
+ return true;
4449
+ }
4450
+ const b = body;
4451
+ const argv = Array.isArray(b.argv) ? b.argv.filter((v) => typeof v === "string") : [];
4452
+ if (argv.length === 0) {
4453
+ json(400, { error: "missing_argv" });
4454
+ return true;
4455
+ }
4456
+ const cols = typeof b.cols === "number" && b.cols > 0 ? Math.floor(b.cols) : 80;
4457
+ const rows = typeof b.rows === "number" && b.rows > 0 ? Math.floor(b.rows) : 24;
4458
+ let cwd = typeof b.cwd === "string" && b.cwd.length > 0 ? b.cwd : null;
4459
+ let workspaceSlug = typeof b.workspaceSlug === "string" ? b.workspaceSlug : "";
4460
+ if (!cwd) {
4461
+ try {
4462
+ const config = await loadWorkspacesConfig2();
4463
+ const ws = workspaceSlug ? findWorkspace(config, workspaceSlug) : getActiveWorkspace(config);
4464
+ if (ws) {
4465
+ cwd = ws.path;
4466
+ workspaceSlug = ws.slug;
4467
+ }
4468
+ } catch {
4469
+ }
4470
+ }
4471
+ if (!cwd) {
4472
+ cwd = process.cwd();
4473
+ console.warn(
4474
+ `[/sessions/terminal] no cwd resolvable for argv=${argv.join(" ")} \u2014 falling back to daemon's cwd ${cwd}`
4475
+ );
4476
+ }
4477
+ if (!workspaceSlug) workspaceSlug = "default";
4478
+ try {
4479
+ const desc = registry.spawnPty({
4480
+ argv,
4481
+ cwd,
4482
+ workspaceSlug,
4483
+ cols,
4484
+ rows,
4485
+ ...b.env && typeof b.env === "object" ? { env: b.env } : {},
4486
+ ...typeof b.name === "string" ? { name: b.name } : {},
4487
+ ...typeof b.label === "string" ? { label: b.label } : {}
4488
+ });
4489
+ json(201, desc);
4490
+ } catch (err) {
4491
+ const msg = err instanceof Error ? err.message : String(err);
4492
+ const status = msg.includes("already in use") ? 409 : msg.includes("no PTY factory") ? 501 : 500;
4493
+ json(status, { error: "pty_spawn_failed", message: msg });
4494
+ }
4495
+ return true;
4496
+ }
3414
4497
  const promptMatch = path.match(/^\/sessions\/([^/]+)\/prompt$/);
3415
4498
  if (promptMatch && req.method === "POST") {
3416
4499
  const id2 = promptMatch[1];
3417
4500
  if (!id2) return false;
3418
4501
  const body = await readJsonBody(req);
3419
4502
  const prompt = body?.prompt;
3420
- if (typeof prompt !== "string") {
3421
- json(400, { error: "missing_prompt" });
4503
+ 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);
4504
+ if (!validPrompt) {
4505
+ json(400, {
4506
+ error: "missing_prompt",
4507
+ message: "Body `prompt` must be a non-empty string, a content block object, or an array of content blocks."
4508
+ });
3422
4509
  return true;
3423
4510
  }
3424
4511
  const reqUrl = req.url ?? "";
@@ -3470,14 +4557,43 @@ async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
3470
4557
  }
3471
4558
  return true;
3472
4559
  }
3473
- const idMatch = path.match(/^\/sessions\/([^/]+)(\/stream|\/kill)?$/);
3474
- if (!idMatch) return false;
3475
- const [, id, suffix] = idMatch;
3476
- if (!id) return false;
4560
+ const idMatch = path.match(
4561
+ /^\/sessions\/([^/]+)(\/stream|\/kill|\/preview)?$/
4562
+ );
4563
+ if (!idMatch) return false;
4564
+ const [, rawIdOrName, suffix] = idMatch;
4565
+ if (!rawIdOrName) return false;
4566
+ const resolvedDesc = registry.findByIdOrName(rawIdOrName);
4567
+ const id = resolvedDesc?.id ?? rawIdOrName;
4568
+ if (suffix === "/preview" && req.method === "GET") {
4569
+ if (!resolvedDesc) {
4570
+ json(404, { error: "session_not_found", id: rawIdOrName });
4571
+ return true;
4572
+ }
4573
+ const reqUrl = req.url ?? "";
4574
+ const qs = new URLSearchParams(
4575
+ reqUrl.includes("?") ? reqUrl.slice(reqUrl.indexOf("?") + 1) : ""
4576
+ );
4577
+ const lineCap = clampInt(qs.get("lines"), 10, 1, 200);
4578
+ const byteCap = clampInt(qs.get("bytes"), 16 * 1024, 256, 64 * 1024);
4579
+ const lines = [];
4580
+ if (resolvedDesc.pty !== true) {
4581
+ const unsub = registry.attach(id, (line) => {
4582
+ lines.push(line);
4583
+ });
4584
+ if (unsub) unsub();
4585
+ }
4586
+ const bufBytes = registry.readTerminalOutput(id, byteCap);
4587
+ json(200, {
4588
+ id: resolvedDesc.id,
4589
+ lines: lines.slice(-lineCap),
4590
+ bytes: bufBytes ? bufBytes.toString("base64") : null
4591
+ });
4592
+ return true;
4593
+ }
3477
4594
  if (suffix === "/stream" && req.method === "GET") {
3478
- const desc = registry.get(id);
3479
- if (!desc) {
3480
- json(404, { error: "session_not_found", id });
4595
+ if (!resolvedDesc) {
4596
+ json(404, { error: "session_not_found", id: rawIdOrName });
3481
4597
  return true;
3482
4598
  }
3483
4599
  res.writeHead(200, {
@@ -3521,12 +4637,11 @@ async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
3521
4637
  return true;
3522
4638
  }
3523
4639
  if (!suffix && req.method === "GET") {
3524
- const desc = registry.get(id);
3525
- if (!desc) {
3526
- json(404, { error: "session_not_found", id });
4640
+ if (!resolvedDesc) {
4641
+ json(404, { error: "session_not_found", id: rawIdOrName });
3527
4642
  return true;
3528
4643
  }
3529
- json(200, desc);
4644
+ json(200, resolvedDesc);
3530
4645
  return true;
3531
4646
  }
3532
4647
  if (!suffix && req.method === "DELETE") {
@@ -3548,14 +4663,80 @@ async function readJsonBody(req) {
3548
4663
  return null;
3549
4664
  }
3550
4665
  }
4666
+ var RESUME_STRATEGIES = {
4667
+ "claude-code": {
4668
+ // Printed by claude on graceful exit when session persistence
4669
+ // is on (default). Example: `claude --resume 0e483f81-1a44-4bec-9667-b37158450296`
4670
+ outputHint: /claude\s+--resume\s+([0-9a-f-]{8,})/i,
4671
+ storeAs: "claudeResumeId",
4672
+ fsProbe: probeMtimeLatestJsonl(".claude/projects"),
4673
+ spawnArgs: (id) => ["claude", "--resume", id]
4674
+ }
4675
+ // Stubs for other shipped adapters — fill in as we learn each
4676
+ // provider's resume mechanism. Today they fall back to ACP-level
4677
+ // resume (whatever the agent-cli runtime supports) or fresh spawn.
4678
+ //
4679
+ // hermes: { storeAs: "hermesResumeId", ... }
4680
+ // codex: { storeAs: "codexResumeId", ... }
4681
+ // openclaw: { storeAs: "openClawResumeId", ... }
4682
+ // opencode: { storeAs: "openCodeResumeId", ... }
4683
+ };
4684
+ function probeMtimeLatestJsonl(storeRel) {
4685
+ return async (cwd, prevStartedAt) => {
4686
+ const encoded = cwd.replace(/\//g, "-");
4687
+ const dir = resolve(homedir(), storeRel, encoded);
4688
+ let entries;
4689
+ try {
4690
+ entries = await promises.readdir(dir);
4691
+ } catch {
4692
+ return null;
4693
+ }
4694
+ const jsonl = entries.filter((e) => e.endsWith(".jsonl"));
4695
+ if (jsonl.length === 0) return null;
4696
+ const startedAtMs = Date.parse(prevStartedAt);
4697
+ const candidates = [];
4698
+ for (const f of jsonl) {
4699
+ try {
4700
+ const st = await promises.stat(join(dir, f));
4701
+ if (!Number.isFinite(startedAtMs) || st.mtimeMs >= startedAtMs - 1e3) {
4702
+ candidates.push({ name: f, mtime: st.mtimeMs });
4703
+ }
4704
+ } catch {
4705
+ }
4706
+ }
4707
+ if (candidates.length === 0) return null;
4708
+ candidates.sort((a, b) => b.mtime - a.mtime);
4709
+ return candidates[0].name.replace(/\.jsonl$/, "");
4710
+ };
4711
+ }
3551
4712
  var SESSIONS_FILE_PATH = () => resolve(homedir(), ".agentproto", "sessions.json");
3552
4713
  var RECENT_LINES_CAP = 500;
4714
+ var RECENT_BYTES_CAP = 64 * 1024;
3553
4715
  var PERSIST_DEBOUNCE_MS = 1500;
4716
+ var HISTORY_CAP = 200;
4717
+ function stripAnsiCodes(s) {
4718
+ return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
4719
+ }
3554
4720
  function createSessionsRegistry(opts) {
3555
- const persistPath = SESSIONS_FILE_PATH();
4721
+ const persistPath = opts?.persistPath ?? SESSIONS_FILE_PATH();
4722
+ const persist = opts?.persist ?? true;
4723
+ const ptyFactory = opts?.spawnPty;
4724
+ const resumeAgent = opts?.resumeAgent;
3556
4725
  const sessions = /* @__PURE__ */ new Map();
3557
4726
  let persistTimer = null;
4727
+ let nextSubId = 1;
4728
+ let shutdownDone = false;
4729
+ if (persist) {
4730
+ loadHistorySnapshot(persistPath, sessions);
4731
+ }
4732
+ const onProcessExit = () => {
4733
+ shutdownImpl();
4734
+ };
4735
+ if (persist) {
4736
+ process.on("exit", onProcessExit);
4737
+ }
3558
4738
  const schedulePersist = () => {
4739
+ if (!persist) return;
3559
4740
  if (persistTimer) clearTimeout(persistTimer);
3560
4741
  persistTimer = setTimeout(() => {
3561
4742
  void persistSnapshot();
@@ -3581,8 +4762,47 @@ function createSessionsRegistry(opts) {
3581
4762
  rt.recentLines.splice(0, rt.recentLines.length - RECENT_LINES_CAP);
3582
4763
  }
3583
4764
  rt.desc.lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
4765
+ sniffResumeHints(rt, line);
3584
4766
  rt.emitter.emit("line", { line, stream });
3585
4767
  };
4768
+ const sniffResumeHints = (rt, line) => {
4769
+ if (rt.desc.kind !== "agent-cli") return;
4770
+ const strategy = rt.desc.adapterSlug ? RESUME_STRATEGIES[rt.desc.adapterSlug] : void 0;
4771
+ if (!strategy?.outputHint) return;
4772
+ const plain = stripAnsiCodes(line);
4773
+ const m = plain.match(strategy.outputHint);
4774
+ if (m && m[1]) {
4775
+ rt.desc.resumeMetadata = {
4776
+ ...rt.desc.resumeMetadata ?? {},
4777
+ [strategy.storeAs]: m[1]
4778
+ };
4779
+ schedulePersist();
4780
+ }
4781
+ };
4782
+ const appendBytes = (rt, chunk) => {
4783
+ rt.recentBytes.push(chunk);
4784
+ rt.recentBytesSize += chunk.byteLength;
4785
+ while (rt.recentBytesSize > RECENT_BYTES_CAP && rt.recentBytes.length > 1) {
4786
+ const dropped = rt.recentBytes.shift();
4787
+ if (dropped) rt.recentBytesSize -= dropped.byteLength;
4788
+ }
4789
+ rt.desc.lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
4790
+ rt.emitter.emit("data", chunk);
4791
+ };
4792
+ const reconcilePtySize = (rt) => {
4793
+ if (!rt.pty || !rt.ptySubscribers || rt.ptySubscribers.size === 0) return;
4794
+ let cols = Infinity;
4795
+ let rows = Infinity;
4796
+ for (const dim of rt.ptySubscribers.values()) {
4797
+ if (dim.cols < cols) cols = dim.cols;
4798
+ if (dim.rows < rows) rows = dim.rows;
4799
+ }
4800
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
4801
+ try {
4802
+ rt.pty.resize(cols, rows);
4803
+ } catch {
4804
+ }
4805
+ };
3586
4806
  const projectEvent = (rt, evt) => {
3587
4807
  switch (evt.kind) {
3588
4808
  case "text-delta":
@@ -3671,6 +4891,59 @@ function createSessionsRegistry(opts) {
3671
4891
  }
3672
4892
  return rt;
3673
4893
  };
4894
+ const maybeResumeAgent = async (rt) => {
4895
+ if (rt.agentSession) return;
4896
+ if (!resumeAgent) return;
4897
+ if (rt.desc.kind !== "agent-cli") return;
4898
+ const adapterSlug = rt.desc.adapterSlug ?? rt.adapterSlug;
4899
+ const adapterSessionId = rt.desc.adapterSessionId;
4900
+ const cwd = rt.desc.cwd;
4901
+ if (!adapterSlug || !adapterSessionId || !cwd) return;
4902
+ if (rt.resumePromise) {
4903
+ await rt.resumePromise;
4904
+ return;
4905
+ }
4906
+ rt.resumePromise = (async () => {
4907
+ appendLine(
4908
+ rt,
4909
+ `\u2500\u2500 resuming ${adapterSlug} session ${adapterSessionId} \u2500\u2500`,
4910
+ "stdout"
4911
+ );
4912
+ try {
4913
+ const fresh = await resumeAgent({
4914
+ adapterSlug,
4915
+ cwd,
4916
+ resumeSessionId: adapterSessionId
4917
+ });
4918
+ if (!fresh) {
4919
+ appendLine(
4920
+ rt,
4921
+ `[error] resume failed: adapter '${adapterSlug}' returned null`,
4922
+ "stderr"
4923
+ );
4924
+ return;
4925
+ }
4926
+ rt.agentSession = fresh;
4927
+ rt.adapterSlug = adapterSlug;
4928
+ rt.desc.adapterSessionId = fresh.sessionId;
4929
+ if (rt.desc.status !== "running") {
4930
+ rt.desc.status = "running";
4931
+ delete rt.desc.endedAt;
4932
+ delete rt.desc.exitCode;
4933
+ rt.emitter.emit("status", rt.desc.status);
4934
+ }
4935
+ schedulePersist();
4936
+ } catch (err) {
4937
+ const msg = err instanceof Error ? err.message : String(err);
4938
+ appendLine(rt, `[error] resume failed: ${msg}`, "stderr");
4939
+ }
4940
+ })();
4941
+ try {
4942
+ await rt.resumePromise;
4943
+ } finally {
4944
+ rt.resumePromise = void 0;
4945
+ }
4946
+ };
3674
4947
  const runAgentTurn = async (rt, message) => {
3675
4948
  if (!rt.agentSession) {
3676
4949
  throw new Error("runAgentTurn: session has no agentSession");
@@ -3734,12 +5007,16 @@ function createSessionsRegistry(opts) {
3734
5007
  pid: child.pid ?? null,
3735
5008
  status: "starting",
3736
5009
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5010
+ argv: input2.argv,
5011
+ cwd: input2.cwd,
3737
5012
  ...input2.label ? { label: input2.label } : {}
3738
5013
  };
3739
5014
  const rt = {
3740
5015
  desc,
3741
5016
  child,
3742
5017
  recentLines: [],
5018
+ recentBytes: [],
5019
+ recentBytesSize: 0,
3743
5020
  emitter: new EventEmitter(),
3744
5021
  busy: false,
3745
5022
  textBuf: "",
@@ -3792,6 +5069,8 @@ function createSessionsRegistry(opts) {
3792
5069
  desc,
3793
5070
  child: input2.child,
3794
5071
  recentLines: [],
5072
+ recentBytes: [],
5073
+ recentBytesSize: 0,
3795
5074
  emitter: new EventEmitter(),
3796
5075
  busy: false,
3797
5076
  textBuf: "",
@@ -3829,6 +5108,13 @@ function createSessionsRegistry(opts) {
3829
5108
  status: "running",
3830
5109
  // driver already started the session
3831
5110
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5111
+ cwd: input2.cwd,
5112
+ adapterSlug: input2.adapterSlug,
5113
+ // ACP-level session id — sticks across daemon restart so
5114
+ // `agentproto sessions restart <id>` can pass it as
5115
+ // `resumeSessionId` and the adapter reattaches to the prior
5116
+ // conversation rather than starting blank.
5117
+ adapterSessionId: input2.agentSession.sessionId,
3832
5118
  ...input2.label ? { label: input2.label } : {}
3833
5119
  };
3834
5120
  const rt = {
@@ -3836,6 +5122,8 @@ function createSessionsRegistry(opts) {
3836
5122
  agentSession: input2.agentSession,
3837
5123
  adapterSlug: input2.adapterSlug,
3838
5124
  recentLines: [],
5125
+ recentBytes: [],
5126
+ recentBytesSize: 0,
3839
5127
  emitter: new EventEmitter(),
3840
5128
  busy: false,
3841
5129
  textBuf: "",
@@ -3860,14 +5148,117 @@ function createSessionsRegistry(opts) {
3860
5148
  }
3861
5149
  return desc;
3862
5150
  },
5151
+ spawnPty(input2) {
5152
+ if (!ptyFactory) {
5153
+ throw new Error(
5154
+ "sessions.spawnPty: no PTY factory configured (node-pty optional dep missing in the daemon)"
5155
+ );
5156
+ }
5157
+ const id = `sess_${randomUUID().slice(0, 8)}`;
5158
+ if (input2.name) {
5159
+ for (const rt2 of sessions.values()) {
5160
+ if (rt2.desc.name !== input2.name) continue;
5161
+ const status = rt2.desc.status;
5162
+ if (status === "running" || status === "starting") {
5163
+ throw new Error(
5164
+ `sessions.spawnPty: name "${input2.name}" already in use by ${rt2.desc.id} (status=${status})`
5165
+ );
5166
+ }
5167
+ rt2.desc.name = void 0;
5168
+ }
5169
+ }
5170
+ const [bin, ...args] = input2.argv;
5171
+ if (!bin) {
5172
+ throw new Error("sessions.spawnPty: argv must include a binary");
5173
+ }
5174
+ const env = { ...process.env, ...input2.env ?? {} };
5175
+ delete env.NODE_OPTIONS;
5176
+ const pty = ptyFactory({
5177
+ command: bin,
5178
+ args,
5179
+ cwd: input2.cwd,
5180
+ env,
5181
+ cols: input2.cols,
5182
+ rows: input2.rows
5183
+ });
5184
+ const desc = {
5185
+ id,
5186
+ kind: "terminal",
5187
+ workspaceSlug: input2.workspaceSlug,
5188
+ command: [bin, ...args].map(quoteArg).join(" "),
5189
+ pid: pty.pid,
5190
+ status: "running",
5191
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5192
+ pty: true,
5193
+ argv: input2.argv,
5194
+ cwd: input2.cwd,
5195
+ ...input2.name ? { name: input2.name } : {},
5196
+ ...input2.label ? { label: input2.label } : {}
5197
+ };
5198
+ const rt = {
5199
+ desc,
5200
+ pty,
5201
+ ptySubscribers: /* @__PURE__ */ new Map(),
5202
+ recentLines: [],
5203
+ recentBytes: [],
5204
+ recentBytesSize: 0,
5205
+ emitter: new EventEmitter(),
5206
+ busy: false,
5207
+ textBuf: "",
5208
+ thoughtBuf: ""
5209
+ };
5210
+ rt.emitter.setMaxListeners(50);
5211
+ sessions.set(id, rt);
5212
+ pty.onData((chunk) => {
5213
+ appendBytes(rt, Buffer.from(chunk, "utf8"));
5214
+ });
5215
+ pty.onExit((evt) => {
5216
+ if (rt.desc.status !== "killed") {
5217
+ rt.desc.status = "exited";
5218
+ }
5219
+ rt.desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
5220
+ if (typeof evt.exitCode === "number") rt.desc.exitCode = evt.exitCode;
5221
+ rt.emitter.emit("exit", evt);
5222
+ rt.emitter.emit("status", rt.desc.status);
5223
+ schedulePersist();
5224
+ });
5225
+ schedulePersist();
5226
+ return desc;
5227
+ },
3863
5228
  async sendPrompt(id, message) {
5229
+ const rtPre = sessions.get(id);
5230
+ if (rtPre) await maybeResumeAgent(rtPre);
3864
5231
  const rt = validateAgentTurn(id, "sendPrompt");
3865
5232
  await runAgentTurn(rt, message);
3866
5233
  },
3867
5234
  enqueuePrompt(id, message) {
3868
- const rt = validateAgentTurn(id, "enqueuePrompt");
3869
- void runAgentTurn(rt, message).catch(() => {
3870
- });
5235
+ const rtPre = sessions.get(id);
5236
+ if (!rtPre) {
5237
+ throw new Error(`enqueuePrompt: no session "${id}"`);
5238
+ }
5239
+ if (rtPre.desc.kind !== "agent-cli") {
5240
+ throw new Error(
5241
+ `enqueuePrompt: session "${id}" is not an agent session (kind=${rtPre.desc.kind})`
5242
+ );
5243
+ }
5244
+ if (rtPre.busy) {
5245
+ throw new Error(
5246
+ `enqueuePrompt: session "${id}" is mid-turn \u2014 wait for it to finish or cancel`
5247
+ );
5248
+ }
5249
+ void (async () => {
5250
+ try {
5251
+ await maybeResumeAgent(rtPre);
5252
+ const rt = validateAgentTurn(id, "enqueuePrompt");
5253
+ await runAgentTurn(rt, message);
5254
+ } catch (err) {
5255
+ appendLine(
5256
+ rtPre,
5257
+ `[error] ${err instanceof Error ? err.message : String(err)}`,
5258
+ "stderr"
5259
+ );
5260
+ }
5261
+ })();
3871
5262
  },
3872
5263
  list() {
3873
5264
  return Array.from(sessions.values()).map((s) => s.desc).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
@@ -3883,6 +5274,65 @@ function createSessionsRegistry(opts) {
3883
5274
  rt.emitter.on("line", handler);
3884
5275
  return () => rt.emitter.off("line", handler);
3885
5276
  },
5277
+ attachPty(id, initial, onData, onExit) {
5278
+ const rt = sessions.get(id);
5279
+ if (!rt || !rt.pty || !rt.ptySubscribers) return null;
5280
+ for (const chunk of rt.recentBytes) onData(chunk);
5281
+ const subId = nextSubId++;
5282
+ rt.ptySubscribers.set(subId, { cols: initial.cols, rows: initial.rows });
5283
+ reconcilePtySize(rt);
5284
+ const dataHandler = (chunk) => onData(chunk);
5285
+ const exitHandler = (evt) => onExit(evt);
5286
+ rt.emitter.on("data", dataHandler);
5287
+ rt.emitter.once("exit", exitHandler);
5288
+ return {
5289
+ write(data) {
5290
+ if (!rt.pty) return;
5291
+ try {
5292
+ rt.pty.write(data);
5293
+ } catch {
5294
+ }
5295
+ },
5296
+ resize(cols, rows) {
5297
+ if (!rt.ptySubscribers) return;
5298
+ rt.ptySubscribers.set(subId, { cols, rows });
5299
+ reconcilePtySize(rt);
5300
+ },
5301
+ detach() {
5302
+ rt.emitter.off("data", dataHandler);
5303
+ rt.emitter.off("exit", exitHandler);
5304
+ rt.ptySubscribers?.delete(subId);
5305
+ reconcilePtySize(rt);
5306
+ }
5307
+ };
5308
+ },
5309
+ findByIdOrName(query) {
5310
+ const direct = sessions.get(query);
5311
+ if (direct) return direct.desc;
5312
+ for (const rt of sessions.values()) {
5313
+ if (rt.desc.name === query) return rt.desc;
5314
+ }
5315
+ return void 0;
5316
+ },
5317
+ writeTerminalInput(id, data) {
5318
+ const rt = sessions.get(id);
5319
+ if (!rt || !rt.pty) return false;
5320
+ try {
5321
+ rt.pty.write(data);
5322
+ return true;
5323
+ } catch {
5324
+ return false;
5325
+ }
5326
+ },
5327
+ readTerminalOutput(id, lastBytes) {
5328
+ const rt = sessions.get(id);
5329
+ if (!rt || !rt.pty) return null;
5330
+ const joined = Buffer.concat(rt.recentBytes, rt.recentBytesSize);
5331
+ if (typeof lastBytes === "number" && lastBytes > 0 && joined.byteLength > lastBytes) {
5332
+ return joined.subarray(joined.byteLength - lastBytes);
5333
+ }
5334
+ return joined;
5335
+ },
3886
5336
  kill(id, signal = "SIGTERM") {
3887
5337
  const rt = sessions.get(id);
3888
5338
  if (!rt) return false;
@@ -3894,6 +5344,12 @@ function createSessionsRegistry(opts) {
3894
5344
  if (rt.agentSession) {
3895
5345
  void rt.agentSession.close().catch(() => void 0);
3896
5346
  }
5347
+ if (rt.pty) {
5348
+ try {
5349
+ rt.pty.kill(signal);
5350
+ } catch {
5351
+ }
5352
+ }
3897
5353
  rt.child?.kill(signal);
3898
5354
  schedulePersist();
3899
5355
  return true;
@@ -3907,19 +5363,91 @@ function createSessionsRegistry(opts) {
3907
5363
  return true;
3908
5364
  },
3909
5365
  shutdown() {
3910
- if (persistTimer) clearTimeout(persistTimer);
3911
- for (const rt of sessions.values()) {
3912
- rt.emitter.removeAllListeners();
3913
- if (rt.desc.status === "running" || rt.desc.status === "starting") {
3914
- if (rt.agentSession) {
3915
- void rt.agentSession.close().catch(() => void 0);
5366
+ shutdownImpl();
5367
+ }
5368
+ };
5369
+ function shutdownImpl() {
5370
+ if (shutdownDone) return;
5371
+ shutdownDone = true;
5372
+ if (persistTimer) clearTimeout(persistTimer);
5373
+ if (persist) {
5374
+ try {
5375
+ process.off("exit", onProcessExit);
5376
+ } catch {
5377
+ }
5378
+ }
5379
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
5380
+ for (const rt of sessions.values()) {
5381
+ rt.emitter.removeAllListeners();
5382
+ if (rt.desc.status === "running" || rt.desc.status === "starting") {
5383
+ rt.desc.status = "killed";
5384
+ rt.desc.endedAt = nowIso;
5385
+ if (rt.agentSession) {
5386
+ void rt.agentSession.close().catch(() => void 0);
5387
+ }
5388
+ if (rt.pty) {
5389
+ try {
5390
+ rt.pty.kill("SIGTERM");
5391
+ } catch {
3916
5392
  }
3917
- rt.child?.kill("SIGTERM");
3918
5393
  }
5394
+ rt.child?.kill("SIGTERM");
3919
5395
  }
3920
- sessions.clear();
3921
5396
  }
3922
- };
5397
+ if (persist) {
5398
+ try {
5399
+ const snapshot = {
5400
+ savedAt: nowIso,
5401
+ sessions: Array.from(sessions.values()).map((s) => s.desc)
5402
+ };
5403
+ mkdirSync(dirname(persistPath), { recursive: true });
5404
+ writeFileSync(persistPath, JSON.stringify(snapshot, null, 2) + "\n");
5405
+ } catch {
5406
+ }
5407
+ }
5408
+ sessions.clear();
5409
+ }
5410
+ }
5411
+ function loadHistorySnapshot(persistPath, sessions) {
5412
+ let raw;
5413
+ try {
5414
+ raw = readFileSync(persistPath, "utf8");
5415
+ } catch {
5416
+ return;
5417
+ }
5418
+ let parsed;
5419
+ try {
5420
+ parsed = JSON.parse(raw);
5421
+ } catch {
5422
+ console.warn(
5423
+ `[sessions] history file ${persistPath} is malformed \u2014 ignoring`
5424
+ );
5425
+ return;
5426
+ }
5427
+ if (!Array.isArray(parsed.sessions)) return;
5428
+ const sorted = parsed.sessions.filter((s) => !!s && typeof s.id === "string").sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, HISTORY_CAP);
5429
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5430
+ for (const desc of sorted) {
5431
+ if (sessions.has(desc.id)) continue;
5432
+ const wasAlive = desc.status === "running" || desc.status === "starting";
5433
+ const reclassified = wasAlive ? {
5434
+ ...desc,
5435
+ status: "killed",
5436
+ endedAt: desc.endedAt ?? now
5437
+ } : desc;
5438
+ const rt = {
5439
+ desc: reclassified,
5440
+ recentLines: [],
5441
+ recentBytes: [],
5442
+ recentBytesSize: 0,
5443
+ emitter: new EventEmitter(),
5444
+ busy: false,
5445
+ textBuf: "",
5446
+ thoughtBuf: ""
5447
+ };
5448
+ rt.emitter.setMaxListeners(50);
5449
+ sessions.set(desc.id, rt);
5450
+ }
3923
5451
  }
3924
5452
  function quoteArg(arg) {
3925
5453
  if (arg === "") return '""';
@@ -4115,9 +5643,24 @@ async function openClient(entry) {
4115
5643
  const transport = new StdioClientTransport({
4116
5644
  command: snap.command,
4117
5645
  args: snap.args ?? [],
4118
- env: { ...process.env, ...expandedEnv }
5646
+ env: { ...process.env, ...expandedEnv },
5647
+ // Pipe stderr so we can include it in the error message instead of
5648
+ // surfacing the opaque "Connection closed" MCP error code.
5649
+ stderr: "pipe"
4119
5650
  });
4120
- await client.connect(transport);
5651
+ const stderrBuf = [];
5652
+ transport.stderr?.on("data", (chunk) => {
5653
+ stderrBuf.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
5654
+ if (stderrBuf.length > 40) stderrBuf.shift();
5655
+ });
5656
+ try {
5657
+ await client.connect(transport);
5658
+ } catch (err) {
5659
+ const stderrText = stderrBuf.join("").trim();
5660
+ const msg = err instanceof Error ? err.message : String(err);
5661
+ throw new Error(stderrText ? `${msg}
5662
+ stderr: ${stderrText.slice(-600)}` : msg);
5663
+ }
4121
5664
  return client;
4122
5665
  }
4123
5666
  if (snap.type === "http") {
@@ -4175,7 +5718,7 @@ function quickTunnelProvider() {
4175
5718
  { stdio: ["ignore", "pipe", "pipe"], shell: false }
4176
5719
  );
4177
5720
  child = proc;
4178
- const url = await new Promise((resolve7, reject) => {
5721
+ const url = await new Promise((resolve8, reject) => {
4179
5722
  let settled = false;
4180
5723
  const timer = setTimeout(() => {
4181
5724
  if (settled) return;
@@ -4200,7 +5743,7 @@ function quickTunnelProvider() {
4200
5743
  if (match) {
4201
5744
  settled = true;
4202
5745
  clearTimeout(timer);
4203
- resolve7(match[0]);
5746
+ resolve8(match[0]);
4204
5747
  }
4205
5748
  };
4206
5749
  proc.stderr?.on("data", onData);
@@ -4249,15 +5792,15 @@ function quickTunnelProvider() {
4249
5792
  }
4250
5793
  }, 3e3);
4251
5794
  timer.unref();
4252
- await new Promise((resolve7) => {
5795
+ await new Promise((resolve8) => {
4253
5796
  if (proc.exitCode !== null) {
4254
5797
  clearTimeout(timer);
4255
- resolve7();
5798
+ resolve8();
4256
5799
  return;
4257
5800
  }
4258
5801
  proc.once("exit", () => {
4259
5802
  clearTimeout(timer);
4260
- resolve7();
5803
+ resolve8();
4261
5804
  });
4262
5805
  });
4263
5806
  }
@@ -4336,7 +5879,7 @@ var RemoteController = class {
4336
5879
  let bearerTokenHash = "";
4337
5880
  if (exposesGateway) {
4338
5881
  bearerToken = randomBytes(32).toString("base64url");
4339
- bearerTokenHash = sha256(bearerToken);
5882
+ bearerTokenHash = sha2562(bearerToken);
4340
5883
  }
4341
5884
  const state = {
4342
5885
  provider: providerId,
@@ -4401,7 +5944,7 @@ function pickProvider(id) {
4401
5944
  const _exhaustive = id;
4402
5945
  throw new Error(`unknown remote provider: ${String(_exhaustive)}`);
4403
5946
  }
4404
- function sha256(input2) {
5947
+ function sha2562(input2) {
4405
5948
  return createHash("sha256").update(input2).digest("hex");
4406
5949
  }
4407
5950
  function errToMessage(err) {
@@ -4494,7 +6037,7 @@ var WorkspacePathError = class extends Error {
4494
6037
  };
4495
6038
  function createWorkspaceFs(opts) {
4496
6039
  const root = resolve(opts.workspace);
4497
- function resolvePath32(path) {
6040
+ function resolvePath42(path) {
4498
6041
  if (typeof path !== "string" || path.length === 0) {
4499
6042
  throw new WorkspacePathError("path must be a non-empty string");
4500
6043
  }
@@ -4514,18 +6057,18 @@ function createWorkspaceFs(opts) {
4514
6057
  }
4515
6058
  return {
4516
6059
  async readFile(path) {
4517
- const abs = resolvePath32(path);
6060
+ const abs = resolvePath42(path);
4518
6061
  const buf = await readFile(abs);
4519
6062
  return buf.toString("utf8");
4520
6063
  },
4521
6064
  async writeFile(path, content) {
4522
- const abs = resolvePath32(path);
6065
+ const abs = resolvePath42(path);
4523
6066
  await mkdir(dirname(abs), { recursive: true });
4524
6067
  await writeFile(abs, content);
4525
6068
  },
4526
6069
  async exists(path) {
4527
6070
  try {
4528
- const abs = resolvePath32(path);
6071
+ const abs = resolvePath42(path);
4529
6072
  return existsSync(abs);
4530
6073
  } catch {
4531
6074
  return false;
@@ -4557,7 +6100,31 @@ async function createGateway(opts) {
4557
6100
  name: opts.name ?? "agentproto-runtime",
4558
6101
  version: opts.version ?? "0.1.0-alpha"
4559
6102
  });
4560
- const sessions = createSessionsRegistry();
6103
+ const sessions = createSessionsRegistry({
6104
+ ...opts.spawnPty ? { spawnPty: opts.spawnPty } : {},
6105
+ // Resume hook: when a prompt arrives for a dead agent-cli row
6106
+ // (typical after daemon restart), the registry calls back into
6107
+ // the adapter resolver to re-create the AgentSession with
6108
+ // `resumeSessionId = adapterSessionId`. ACP semantics: the
6109
+ // upstream provider reattaches to the prior conversation.
6110
+ // Unwired (no resolveAgentAdapter) → legacy "not an agent
6111
+ // session" error, user must spawn fresh.
6112
+ ...opts.resolveAgentAdapter ? {
6113
+ resumeAgent: async ({ adapterSlug, cwd, resumeSessionId }) => {
6114
+ const adapter = await opts.resolveAgentAdapter(adapterSlug);
6115
+ if (!adapter) return null;
6116
+ try {
6117
+ return await adapter.startSession({ cwd, resumeSessionId });
6118
+ } catch (err) {
6119
+ console.warn(
6120
+ `[agentproto] resumeAgent('${adapterSlug}', ${resumeSessionId}) failed: ${err instanceof Error ? err.message : String(err)}`
6121
+ );
6122
+ return null;
6123
+ }
6124
+ }
6125
+ } : {}
6126
+ });
6127
+ const token = opts.token ?? randomUUID();
4561
6128
  const mcpProxy = new McpProxyRegistry();
4562
6129
  const mcpServerFactory = async () => {
4563
6130
  const { server } = await createMcpServer({
@@ -4572,6 +6139,7 @@ async function createGateway(opts) {
4572
6139
  registerSessionTools(server, {
4573
6140
  registry: sessions,
4574
6141
  mcpProxy,
6142
+ ptyEnabled: opts.spawnPty != null,
4575
6143
  ...opts.resolveAgentAdapter ? { resolveAgentAdapter: opts.resolveAgentAdapter } : {},
4576
6144
  ...opts.listAgentAdapters ? { listAgentAdapters: opts.listAgentAdapters } : {}
4577
6145
  });
@@ -4613,6 +6181,10 @@ async function createGateway(opts) {
4613
6181
  heartbeat,
4614
6182
  sessions,
4615
6183
  mcpProxy,
6184
+ token,
6185
+ ptyEnabled: opts.spawnPty != null,
6186
+ ...opts.allowedOrigins ? { allowedOrigins: opts.allowedOrigins } : {},
6187
+ ...opts.strictOrigins ? { strictOrigins: true } : {},
4616
6188
  ...opts.resolveAgentAdapter ? { resolveAgentAdapter: opts.resolveAgentAdapter } : {},
4617
6189
  ...opts.listAgentAdapters ? { listAgentAdapters: opts.listAgentAdapters } : {},
4618
6190
  meta: { workspace, registered }
@@ -4631,7 +6203,8 @@ async function createGateway(opts) {
4631
6203
  pid: process.pid,
4632
6204
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4633
6205
  name: opts.name ?? "agentproto-runtime",
4634
- registered
6206
+ registered,
6207
+ token
4635
6208
  });
4636
6209
  return {
4637
6210
  url: http.url,
@@ -4639,6 +6212,7 @@ async function createGateway(opts) {
4639
6212
  workspaceFs,
4640
6213
  registered,
4641
6214
  sessions,
6215
+ token,
4642
6216
  async stop() {
4643
6217
  heartbeat.stop();
4644
6218
  sessions.shutdown();
@@ -4687,13 +6261,20 @@ async function runServe(args) {
4687
6261
  label: { type: "string", short: "l" },
4688
6262
  workspace: { type: "string", short: "w" },
4689
6263
  port: { type: "string", short: "p" },
4690
- bind: { type: "string", short: "b" }
6264
+ bind: { type: "string", short: "b" },
6265
+ "allow-origin": { type: "string", multiple: true },
6266
+ interactive: { type: "boolean", short: "i" }
4691
6267
  }
4692
6268
  });
4693
- const workspace = resolve(values.workspace ?? process.cwd());
6269
+ const cfg = await loadConfig();
6270
+ const cfgDaemon = cfg.daemon ?? {};
6271
+ const cfgTunnel = cfg.tunnel ?? {};
6272
+ const workspace = resolve(
6273
+ values.workspace ?? cfgDaemon.workspace ?? process.cwd()
6274
+ );
4694
6275
  try {
4695
- const stat2 = await promises.stat(workspace);
4696
- if (!stat2.isDirectory()) {
6276
+ const stat3 = await promises.stat(workspace);
6277
+ if (!stat3.isDirectory()) {
4697
6278
  process.stderr.write(
4698
6279
  `agentproto serve: --workspace "${workspace}" is not a directory.
4699
6280
  `
@@ -4708,20 +6289,21 @@ async function runServe(args) {
4708
6289
  );
4709
6290
  return 2;
4710
6291
  }
4711
- const port = values.port ? Number.parseInt(values.port, 10) : 18790;
6292
+ const port = values.port ? Number.parseInt(values.port, 10) : typeof cfgDaemon.port === "number" ? cfgDaemon.port : 18790;
4712
6293
  if (!Number.isFinite(port) || port <= 0 || port > 65535) {
4713
6294
  process.stderr.write(`agentproto serve: invalid --port "${values.port}".
4714
6295
  `);
4715
6296
  return 2;
4716
6297
  }
4717
- const label = values.label ?? `${userInfo().username}@${hostname()}`;
6298
+ const label = values.label ?? cfgDaemon.label ?? `${userInfo().username}@${hostname()}`;
6299
+ const connectFlag = values.connect ?? (cfgTunnel.autoconnect && cfgTunnel.host ? cfgTunnel.host : void 0);
4718
6300
  let token = values.token ?? process.env.AGENTPROTO_TOKEN;
4719
- if (!token && values.connect) {
4720
- const cred = await readHost(values.connect);
6301
+ if (!token && connectFlag) {
6302
+ const cred = await readHost(connectFlag);
4721
6303
  if (cred) {
4722
6304
  if (isExpired(cred)) {
4723
6305
  process.stderr.write(
4724
- `agentproto serve: \u26A0 credentials for ${values.connect} are expired (${formatExpiry(cred)}). Re-run \`agentproto auth login --host ${values.connect}\`.
6306
+ `agentproto serve: \u26A0 credentials for ${connectFlag} are expired (${formatExpiry(cred)}). Re-run \`agentproto auth login --host ${connectFlag}\`.
4725
6307
  `
4726
6308
  );
4727
6309
  }
@@ -4732,21 +6314,31 @@ async function runServe(args) {
4732
6314
  );
4733
6315
  }
4734
6316
  }
6317
+ const allowOriginRaw = values["allow-origin"];
6318
+ const cliOrigins = Array.isArray(allowOriginRaw) ? allowOriginRaw : [];
6319
+ const cfgOrigins = Array.isArray(cfgDaemon.allowedOrigins) ? cfgDaemon.allowedOrigins : [];
6320
+ const merged = [.../* @__PURE__ */ new Set([...cfgOrigins, ...cliOrigins])];
6321
+ const allowedOrigins = merged.length > 0 ? merged : void 0;
4735
6322
  const opts = {
4736
6323
  workspace,
4737
6324
  port,
4738
- bind: values.bind ?? "127.0.0.1",
4739
- ...values.connect ? { connect: values.connect } : {},
6325
+ bind: values.bind ?? cfgDaemon.bind ?? "127.0.0.1",
6326
+ ...connectFlag ? { connect: connectFlag } : {},
4740
6327
  ...token ? { token } : {},
4741
- label
6328
+ label,
6329
+ ...allowedOrigins ? { allowedOrigins } : {},
6330
+ ...cfgDaemon.strictOrigins === true ? { strictOrigins: true } : {}
4742
6331
  };
4743
6332
  const resolveAgentAdapter = async (slug) => {
4744
6333
  try {
4745
6334
  const adapter = await resolveAdapter(slug);
4746
6335
  const runtime = createAgentCliRuntime(adapter.handle);
4747
6336
  return {
4748
- async startSession({ cwd }) {
4749
- return runtime.start({ cwd });
6337
+ async startSession({ cwd, resumeSessionId }) {
6338
+ return runtime.start({
6339
+ cwd,
6340
+ ...resumeSessionId ? { resumeSessionId } : {}
6341
+ });
4750
6342
  },
4751
6343
  commandPreview: `${adapter.handle.bin} ${(adapter.handle.bin_args ?? []).join(" ")}`.trim()
4752
6344
  };
@@ -4757,6 +6349,7 @@ async function runServe(args) {
4757
6349
  return null;
4758
6350
  }
4759
6351
  };
6352
+ const spawnPty = await loadNodePtyFactory();
4760
6353
  let gateway;
4761
6354
  try {
4762
6355
  gateway = await createGateway({
@@ -4771,7 +6364,10 @@ async function runServe(args) {
4771
6364
  // Discovery for UIs / operators — `GET /adapters` + `list_adapters`
4772
6365
  // MCP tool. Walks node_modules @agentproto/adapter-* on each call;
4773
6366
  // cheap enough that we don't bother caching here.
4774
- listAgentAdapters: listInstalledAdapters
6367
+ listAgentAdapters: listInstalledAdapters,
6368
+ ...spawnPty ? { spawnPty } : {},
6369
+ ...opts.allowedOrigins ? { allowedOrigins: opts.allowedOrigins } : {},
6370
+ ...opts.strictOrigins ? { strictOrigins: true } : {}
4775
6371
  });
4776
6372
  } catch (err) {
4777
6373
  process.stderr.write(
@@ -4780,48 +6376,105 @@ async function runServe(args) {
4780
6376
  );
4781
6377
  return 1;
4782
6378
  }
4783
- process.stderr.write(
4784
- `agentproto serve: gateway up on ${gateway.url}
4785
- workspace: ${gateway.workspace}
4786
- mcp: ${gateway.url}/mcp
4787
- sessions: ${gateway.url}/sessions
4788
- events: ${gateway.url}/events
6379
+ printBootBanner({
6380
+ url: gateway.url,
6381
+ workspace: gateway.workspace,
6382
+ ptyEnabled: spawnPty != null,
6383
+ allowedOrigins: opts.allowedOrigins,
6384
+ strictOrigins: opts.strictOrigins === true,
6385
+ connect: opts.connect
6386
+ });
6387
+ try {
6388
+ const wsConfig = await loadWorkspacesConfig();
6389
+ const paths = wsConfig.workspaces.map((w) => w.path);
6390
+ const cleaned = await sweepStaleRuntimeMetas(paths, opts.workspace);
6391
+ if (cleaned.length > 0) {
6392
+ process.stderr.write(
6393
+ `${color.dim}cleaned ${cleaned.length} stale runtime.json file(s) (dead PID)${color.reset}
4789
6394
  `
4790
- );
6395
+ );
6396
+ }
6397
+ } catch {
6398
+ }
4791
6399
  const aborter = new AbortController();
4792
6400
  let shuttingDown = false;
4793
6401
  const shutdown = async (signal) => {
4794
6402
  if (shuttingDown) return;
4795
6403
  shuttingDown = true;
4796
- process.stderr.write(`
4797
- agentproto serve: ${signal} \u2014 shutting down.
4798
- `);
6404
+ process.stderr.write(
6405
+ `
6406
+ ${color.dim}\u2500\u2500 shutting down (${signal}) \u2500\u2500${color.reset}
6407
+ `
6408
+ );
4799
6409
  aborter.abort();
4800
6410
  await gateway.stop().catch(() => void 0);
6411
+ await unlinkRuntimeMeta(opts.workspace).catch(() => void 0);
4801
6412
  process.exit(0);
4802
6413
  };
4803
6414
  process.once("SIGINT", () => void shutdown("SIGINT"));
4804
6415
  process.once("SIGTERM", () => void shutdown("SIGTERM"));
6416
+ process.once("SIGHUP", () => void shutdown("SIGHUP"));
6417
+ if (values.interactive) {
6418
+ process.stderr.write(
6419
+ `${color.dim}entering interactive monitor \u2014 q in the TUI to quit (daemon + TUI both).${color.reset}
6420
+ `
6421
+ );
6422
+ const childArgv = [process.argv[1] ?? "", "sessions", "--watch"];
6423
+ const child = childProc.spawn(process.execPath, childArgv, {
6424
+ stdio: "inherit",
6425
+ env: {
6426
+ ...process.env,
6427
+ AGENTPROTO_DAEMON_URL: gateway.url,
6428
+ // `gateway.token` is the per-boot daemon bearer (different
6429
+ // from `token`, which was the tunnel JWT). Without this, the
6430
+ // child's restart/kill calls would 401 against its own
6431
+ // parent — exactly the bug that closed --interactive after
6432
+ // any failed action.
6433
+ AGENTPROTO_DAEMON_TOKEN: gateway.token
6434
+ }
6435
+ });
6436
+ await new Promise((resolve4) => {
6437
+ child.once("exit", () => resolve4());
6438
+ aborter.signal.addEventListener("abort", () => {
6439
+ try {
6440
+ child.kill("SIGTERM");
6441
+ } catch {
6442
+ }
6443
+ });
6444
+ });
6445
+ if (!shuttingDown) {
6446
+ aborter.abort();
6447
+ await gateway.stop().catch(() => void 0);
6448
+ await unlinkRuntimeMeta(opts.workspace).catch(() => void 0);
6449
+ }
6450
+ return 0;
6451
+ }
4805
6452
  if (!opts.connect) {
4806
6453
  process.stderr.write(
4807
- `agentproto serve: running local-only (no --connect). Press Ctrl-C to stop.
6454
+ `${color.dim}Press Ctrl-C to stop.${color.reset}
4808
6455
  `
4809
6456
  );
4810
- await new Promise((resolve3) => {
4811
- aborter.signal.addEventListener("abort", () => resolve3());
6457
+ await new Promise((resolve4) => {
6458
+ aborter.signal.addEventListener("abort", () => resolve4());
4812
6459
  });
4813
6460
  return 0;
4814
6461
  }
4815
6462
  process.stderr.write(
4816
- `agentproto serve: tunnel \u2014 connecting to ${opts.connect} as '${opts.label}'\u2026
6463
+ `${color.dim}tunnel \xB7 connecting to ${opts.connect} as '${opts.label}'\u2026${color.reset}
4817
6464
  `
4818
6465
  );
4819
6466
  let backoffMs = opts.reconnectMinMs ?? 1e3;
4820
6467
  const backoffMax = opts.reconnectMaxMs ?? 3e4;
6468
+ const reconnectState = { immediate: false };
4821
6469
  while (!aborter.signal.aborted) {
4822
6470
  try {
4823
- await runOneTunnel(opts, gateway, aborter.signal);
6471
+ await runOneTunnel(opts, gateway, spawnPty, aborter.signal, reconnectState);
4824
6472
  backoffMs = opts.reconnectMinMs ?? 1e3;
6473
+ if (reconnectState.immediate) {
6474
+ reconnectState.immediate = false;
6475
+ continue;
6476
+ }
6477
+ await sleep(2e3, aborter.signal);
4825
6478
  } catch (err) {
4826
6479
  if (aborter.signal.aborted) break;
4827
6480
  const msg = err instanceof Error ? err.message : String(err);
@@ -4836,17 +6489,17 @@ agentproto serve: ${signal} \u2014 shutting down.
4836
6489
  }
4837
6490
  return 0;
4838
6491
  }
4839
- async function runOneTunnel(opts, gateway, signal) {
6492
+ async function runOneTunnel(opts, gateway, spawnPty, signal, reconnectState) {
4840
6493
  if (!opts.connect) throw new Error("runOneTunnel: --connect not set");
4841
6494
  const headers = {
4842
6495
  "user-agent": "agentproto/0.1.0-alpha"
4843
6496
  };
4844
6497
  if (opts.token) headers.authorization = `Bearer ${opts.token}`;
4845
6498
  const ws = new WebSocket(opts.connect, { headers });
4846
- await new Promise((resolve3, reject) => {
6499
+ await new Promise((resolve4, reject) => {
4847
6500
  const onOpen = () => {
4848
6501
  ws.off("error", onError);
4849
- resolve3();
6502
+ resolve4();
4850
6503
  };
4851
6504
  const onError = (err) => {
4852
6505
  ws.off("open", onOpen);
@@ -4866,16 +6519,86 @@ async function runOneTunnel(opts, gateway, signal) {
4866
6519
  process.stderr.write(`agentproto serve: tunnel up.
4867
6520
  `);
4868
6521
  const sink = wrapWebSocket(ws);
6522
+ const keepaliveInterval = setInterval(() => {
6523
+ try {
6524
+ sink.send({ t: "ping", nonce: randomUUID() });
6525
+ } catch {
6526
+ }
6527
+ }, 3e4);
4869
6528
  const server = createTunnelServer({
4870
6529
  sink,
4871
6530
  label: opts.label,
4872
- pty: false,
6531
+ pty: spawnPty !== null,
6532
+ ...spawnPty ? { spawnPty } : {},
4873
6533
  // Generic HTTP-relay upstream for tunnel `http_request` frames.
4874
6534
  // Cloud-side callers (e.g. the API's local-daemon filesystem
4875
6535
  // provider) can now route MCP JSON-RPC + any other HTTP through
4876
6536
  // the daemon without needing a public URL. We point at the local
4877
6537
  // gateway since that's where `/mcp`, `/sessions`, `/events` live.
4878
6538
  httpUpstream: gateway.url,
6539
+ // WS forwarding upstream — daemon dials the local gateway's WS
6540
+ // endpoints (/sessions/:id/pty, etc) and pipes frames to the host.
6541
+ // Used by the cloud tunnel pod so browsers on mobile can attach to
6542
+ // interactive PTY sessions even though the daemon is only reachable
6543
+ // through the host (not directly).
6544
+ dialUpstreamWs: async ({ url, protocols, headers: headers2 }) => {
6545
+ const upstreamHeaders = {
6546
+ ...headers2
6547
+ };
6548
+ if (!upstreamHeaders["origin"] && !upstreamHeaders["Origin"]) {
6549
+ try {
6550
+ const u = new URL(url);
6551
+ const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
6552
+ upstreamHeaders["Origin"] = `${httpScheme}//${u.host}`;
6553
+ } catch {
6554
+ }
6555
+ }
6556
+ return await new Promise((resolve4, reject) => {
6557
+ const sock = new WebSocket(url, protocols ? [...protocols] : void 0, {
6558
+ headers: upstreamHeaders
6559
+ });
6560
+ const onceOpen = () => {
6561
+ sock.off("error", onceError);
6562
+ sock.off("unexpected-response", onceUnexpected);
6563
+ resolve4({
6564
+ protocol: sock.protocol ?? "",
6565
+ send: (data, sendOpts) => {
6566
+ sock.send(data, { binary: sendOpts.binary });
6567
+ },
6568
+ close: (code, reason) => {
6569
+ try {
6570
+ sock.close(code, reason);
6571
+ } catch {
6572
+ }
6573
+ },
6574
+ onMessage: (handler) => {
6575
+ sock.on("message", (raw, isBinary) => {
6576
+ handler(raw, isBinary);
6577
+ });
6578
+ },
6579
+ onClose: (handler) => {
6580
+ sock.on("close", (code, reason) => {
6581
+ handler(code, reason.toString("utf8"));
6582
+ });
6583
+ },
6584
+ onError: (handler) => {
6585
+ sock.on("error", (err) => handler(err));
6586
+ }
6587
+ });
6588
+ };
6589
+ const onceError = (err) => {
6590
+ sock.off("open", onceOpen);
6591
+ reject(err);
6592
+ };
6593
+ const onceUnexpected = (_req, res) => {
6594
+ sock.off("open", onceOpen);
6595
+ reject(new Error(`Unexpected server response: ${res.statusCode ?? 0}`));
6596
+ };
6597
+ sock.once("open", onceOpen);
6598
+ sock.once("error", onceError);
6599
+ sock.once("unexpected-response", onceUnexpected);
6600
+ });
6601
+ },
4879
6602
  // v0 authorize hook: trust the bearer-authenticated host completely.
4880
6603
  // Token possession proves the host was provisioned for this daemon.
4881
6604
  // Per-spawn policy filtering will land alongside the policy.toml.
@@ -4895,33 +6618,93 @@ async function runOneTunnel(opts, gateway, signal) {
4895
6618
  kind: "agent-cli",
4896
6619
  label: `tunnel: ${request.command.split("/").pop() ?? request.command}`
4897
6620
  });
6621
+ },
6622
+ // Graceful drain hook — flip the outer loop's "reconnect immediately"
6623
+ // flag and close the WS so the supervisor reconnects without backoff.
6624
+ // Host will follow up with close(1012) ~2s later as a hard backstop.
6625
+ onReconnectSoon: ({ reasonMs }) => {
6626
+ process.stderr.write(
6627
+ `agentproto serve: host signaled drain (reasonMs=${reasonMs ?? "?"}) \u2014 reconnecting immediately
6628
+ `
6629
+ );
6630
+ reconnectState.immediate = true;
6631
+ try {
6632
+ ws.close(1e3, "host_drain");
6633
+ } catch {
6634
+ }
4898
6635
  }
4899
6636
  });
4900
- await new Promise((resolve3) => {
6637
+ await new Promise((resolve4) => {
4901
6638
  const offClose = sink.onClose(() => {
6639
+ clearInterval(keepaliveInterval);
4902
6640
  offClose();
4903
- resolve3();
6641
+ resolve4();
4904
6642
  });
4905
6643
  if (signal.aborted) {
4906
- server.close().finally(() => resolve3());
6644
+ clearInterval(keepaliveInterval);
6645
+ server.close().finally(() => resolve4());
4907
6646
  }
4908
6647
  signal.addEventListener("abort", () => {
4909
- server.close().finally(() => resolve3());
6648
+ clearInterval(keepaliveInterval);
6649
+ server.close().finally(() => resolve4());
4910
6650
  });
4911
6651
  });
4912
6652
  process.stderr.write(`agentproto serve: tunnel closed.
4913
6653
  `);
4914
6654
  }
4915
6655
  function sleep(ms, signal) {
4916
- return new Promise((resolve3) => {
4917
- if (signal.aborted) return resolve3();
4918
- const timer = setTimeout(resolve3, ms);
6656
+ return new Promise((resolve4) => {
6657
+ if (signal.aborted) return resolve4();
6658
+ const timer = setTimeout(resolve4, ms);
4919
6659
  signal.addEventListener("abort", () => {
4920
6660
  clearTimeout(timer);
4921
- resolve3();
6661
+ resolve4();
4922
6662
  });
4923
6663
  });
4924
6664
  }
6665
+ function printBootBanner(opts) {
6666
+ const c = color;
6667
+ const home = process.env.HOME ?? "";
6668
+ const workspace = home && opts.workspace.startsWith(home) ? "~" + opts.workspace.slice(home.length) : opts.workspace;
6669
+ 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}`;
6670
+ let origins;
6671
+ if (opts.strictOrigins) {
6672
+ 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}`;
6673
+ } else {
6674
+ origins = opts.allowedOrigins && opts.allowedOrigins.length > 0 ? opts.allowedOrigins.join(" \xB7 ") + ` ${c.dim}+ localhost (default)${c.reset}` : `${c.dim}localhost:* only (default)${c.reset}`;
6675
+ }
6676
+ const mode = opts.connect ? `${c.cyan}tunnel${c.reset} ${c.dim}\u2192 ${opts.connect}${c.reset}` : `${c.dim}local-only${c.reset}`;
6677
+ const line = `${c.dim}\u2500${c.reset}`;
6678
+ process.stderr.write(
6679
+ `
6680
+ ${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}
6681
+ ${c.dim}workspace${c.reset} ${workspace}
6682
+ ${c.dim}pty${c.reset} ${ptyState}
6683
+ ${c.dim}origins${c.reset} ${origins}
6684
+ ${c.dim}endpoints${c.reset} /mcp \xB7 /sessions \xB7 /events \xB7 /sessions/:id/pty ${c.dim}(WS)${c.reset}
6685
+ ${c.dim}mode${c.reset} ${mode}
6686
+
6687
+ `
6688
+ );
6689
+ }
6690
+ var _isTty = !!process.stderr.isTTY;
6691
+ var color = _isTty ? {
6692
+ reset: "\x1B[0m",
6693
+ bold: "\x1B[1m",
6694
+ dim: "\x1B[2m",
6695
+ green: "\x1B[32m",
6696
+ amber: "\x1B[33m",
6697
+ cyan: "\x1B[36m",
6698
+ red: "\x1B[31m"
6699
+ } : {
6700
+ reset: "",
6701
+ bold: "",
6702
+ dim: "",
6703
+ green: "",
6704
+ amber: "",
6705
+ cyan: "",
6706
+ red: ""
6707
+ };
4925
6708
 
4926
6709
  export { listInstalledAdapters, resolveAdapter, runInstall, runRun, runServe };
4927
6710
  //# sourceMappingURL=index.mjs.map