@corenel/sidecar 0.8.7 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +1808 -1051
  2. package/package.json +9 -7
package/dist/cli.js CHANGED
@@ -401,7 +401,7 @@ var init_protocol = __esm({
401
401
 
402
402
  // ../tools-node/fileService.ts
403
403
  import { promises as fs3 } from "node:fs";
404
- import { join as join3, resolve, relative, dirname, sep, basename, isAbsolute } from "node:path";
404
+ import { join as join4, resolve, relative, dirname as dirname2, sep, basename, isAbsolute } from "node:path";
405
405
  async function looksLikeGeneratedCoverage(absDir) {
406
406
  try {
407
407
  const names = await fs3.readdir(absDir);
@@ -484,7 +484,7 @@ var init_fileService = __esm({
484
484
  return;
485
485
  } catch (e2) {
486
486
  if (e2 instanceof Error && e2.message.includes("via symlink")) throw e2;
487
- const parent = dirname(probe);
487
+ const parent = dirname2(probe);
488
488
  if (parent === probe) throw new Error(`cannot resolve path under the workspace root: ${a2}`);
489
489
  probe = parent;
490
490
  }
@@ -507,7 +507,7 @@ var init_fileService = __esm({
507
507
  const out = [];
508
508
  for (const e2 of entries.sort((a2, b2) => a2.name.localeCompare(b2.name))) {
509
509
  if (SKIP.has(e2.name)) continue;
510
- const absChild = join3(absDir, e2.name);
510
+ const absChild = join4(absDir, e2.name);
511
511
  const path = normalizeFilePath(relative(this.root, absChild));
512
512
  if (e2.isDirectory() && SKIP_PATHS.has(path)) continue;
513
513
  if (e2.isDirectory() && e2.name === "coverage" && await looksLikeGeneratedCoverage(absChild)) continue;
@@ -535,19 +535,19 @@ var init_fileService = __esm({
535
535
  }
536
536
  async write(path, content) {
537
537
  const a2 = this.abs(path);
538
- await fs3.mkdir(dirname(a2), { recursive: true });
538
+ await fs3.mkdir(dirname2(a2), { recursive: true });
539
539
  await this.assertReal(a2);
540
540
  await fs3.writeFile(a2, content, "utf8");
541
541
  }
542
542
  async writeBytes(path, bytes) {
543
543
  const a2 = this.abs(path);
544
- await fs3.mkdir(dirname(a2), { recursive: true });
544
+ await fs3.mkdir(dirname2(a2), { recursive: true });
545
545
  await this.assertReal(a2);
546
546
  await fs3.writeFile(a2, bytes);
547
547
  }
548
548
  async create(path, content = "") {
549
549
  const a2 = this.abs(path);
550
- await fs3.mkdir(dirname(a2), { recursive: true });
550
+ await fs3.mkdir(dirname2(a2), { recursive: true });
551
551
  await this.assertReal(a2);
552
552
  try {
553
553
  await fs3.writeFile(a2, content, { flag: "wx" });
@@ -561,7 +561,7 @@ var init_fileService = __esm({
561
561
  }
562
562
  async rename(from, to) {
563
563
  const t2 = this.abs(to);
564
- await fs3.mkdir(dirname(t2), { recursive: true });
564
+ await fs3.mkdir(dirname2(t2), { recursive: true });
565
565
  await this.assertReal(t2);
566
566
  await fs3.rename(await this.realAbs(from), t2);
567
567
  }
@@ -594,21 +594,24 @@ var init_fileService = __esm({
594
594
  // ../tools-node/auth.ts
595
595
  import { promises as fs4 } from "node:fs";
596
596
  import { homedir as homedir2 } from "node:os";
597
- import { join as join4 } from "node:path";
597
+ import { join as join5 } from "node:path";
598
598
  function corenelDir2(base = homedir2()) {
599
- return join4(base, stateDirName());
599
+ return join5(base, stateDirName());
600
600
  }
601
601
  function sidecarDir(base = homedir2()) {
602
- return join4(corenelDir2(base), "sidecar");
602
+ return join5(corenelDir2(base), "sidecar");
603
+ }
604
+ function authTokenPath(base = homedir2()) {
605
+ return join5(corenelDir2(base), "token");
603
606
  }
604
607
  function credentialDir(stateDir) {
605
608
  return stateDir ?? corenelDir2();
606
609
  }
607
610
  function tokenFilePath(stateDir) {
608
- return join4(credentialDir(stateDir), "token");
611
+ return join5(credentialDir(stateDir), "token");
609
612
  }
610
613
  function credentialFilePath(stateDir) {
611
- return join4(credentialDir(stateDir), "auth.json");
614
+ return join5(credentialDir(stateDir), "auth.json");
612
615
  }
613
616
  async function writeFilePrivateAtomic(path, contents) {
614
617
  const tmp = `${path}.tmp-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
@@ -636,6 +639,49 @@ async function writeAuthToken(token, stateDir) {
636
639
  } catch {
637
640
  }
638
641
  }
642
+ async function writeLoginCredential(token, opts = {}) {
643
+ const cred = {
644
+ accessToken: token.trim(),
645
+ ...opts.account ? { account: opts.account } : {},
646
+ ...opts.apiBase ? { apiBase: opts.apiBase } : {}
647
+ };
648
+ const dir = credentialDir(opts.stateDir);
649
+ const credPath = credentialFilePath(opts.stateDir);
650
+ const tokPath = tokenFilePath(opts.stateDir);
651
+ await fs4.mkdir(dir, { recursive: true, mode: 448 });
652
+ const credTmp = `${credPath}.tmp-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
653
+ const tokTmp = `${tokPath}.tmp-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
654
+ await fs4.writeFile(credTmp, `${JSON.stringify(cred, null, 2)}
655
+ `, { mode: 384 });
656
+ await fs4.writeFile(tokTmp, `${cred.accessToken}
657
+ `, { mode: 384 });
658
+ try {
659
+ await fs4.chmod(credTmp, 384);
660
+ } catch {
661
+ }
662
+ try {
663
+ await fs4.chmod(tokTmp, 384);
664
+ } catch {
665
+ }
666
+ try {
667
+ await fs4.rename(credTmp, credPath);
668
+ await fs4.rename(tokTmp, tokPath);
669
+ } catch (e2) {
670
+ try {
671
+ await fs4.rm(credTmp, { force: true });
672
+ } catch {
673
+ }
674
+ try {
675
+ await fs4.rm(tokTmp, { force: true });
676
+ } catch {
677
+ }
678
+ throw e2;
679
+ }
680
+ try {
681
+ await fs4.chmod(dir, 448);
682
+ } catch {
683
+ }
684
+ }
639
685
  function isMissing(err) {
640
686
  return typeof err === "object" && err !== null && err.code === "ENOENT";
641
687
  }
@@ -656,6 +702,9 @@ async function clearAuthToken(stateDir) {
656
702
  } catch {
657
703
  }
658
704
  }
705
+ async function readCredentialFile(stateDir) {
706
+ return (await readCredentialFileRead(stateDir)).value;
707
+ }
659
708
  async function readCredentialFileRead(stateDir) {
660
709
  let raw;
661
710
  try {
@@ -13820,7 +13869,7 @@ var require_compiler = __commonJS({
13820
13869
  });
13821
13870
  this._emitLine("root: root\n};");
13822
13871
  };
13823
- _proto.compile = function compile(node, frame) {
13872
+ _proto.compile = function compile2(node, frame) {
13824
13873
  var _compile = this["compile" + node.typename];
13825
13874
  if (_compile) {
13826
13875
  _compile.call(this, node, frame);
@@ -13834,7 +13883,7 @@ var require_compiler = __commonJS({
13834
13883
  return Compiler2;
13835
13884
  }(Obj);
13836
13885
  module.exports = {
13837
- compile: function compile(src2, asyncFilters, extensions, name, opts) {
13886
+ compile: function compile2(src2, asyncFilters, extensions, name, opts) {
13838
13887
  if (opts === void 0) {
13839
13888
  opts = {};
13840
13889
  }
@@ -15249,7 +15298,7 @@ var require_environment = __commonJS({
15249
15298
  }
15250
15299
  });
15251
15300
  };
15252
- _proto3.compile = function compile() {
15301
+ _proto3.compile = function compile2() {
15253
15302
  if (!this.compiled) {
15254
15303
  this._compile();
15255
15304
  }
@@ -15746,7 +15795,7 @@ var require_nunjucks = __commonJS({
15746
15795
  reset: function reset() {
15747
15796
  e2 = void 0;
15748
15797
  },
15749
- compile: function compile(src2, env, path, eagerCompile) {
15798
+ compile: function compile2(src2, env, path, eagerCompile) {
15750
15799
  if (!e2) {
15751
15800
  configure();
15752
15801
  }
@@ -15914,6 +15963,43 @@ function getLanguageAliasesForExtension(ext) {
15914
15963
  function generateCompilationId() {
15915
15964
  return `compile-${++compilationCounter}-${Date.now()}`;
15916
15965
  }
15966
+ function normalizePosix2(p2) {
15967
+ const isAbs = p2.startsWith("/");
15968
+ const out = [];
15969
+ for (const seg of p2.split("/")) {
15970
+ if (seg === "" || seg === ".") continue;
15971
+ if (seg === "..") {
15972
+ if (out.length && out[out.length - 1] !== "..") out.pop();
15973
+ else if (!isAbs) out.push("..");
15974
+ } else {
15975
+ out.push(seg);
15976
+ }
15977
+ }
15978
+ const joined = out.join("/");
15979
+ return isAbs ? "/" + joined : joined || ".";
15980
+ }
15981
+ function joinPosix2(...segments2) {
15982
+ const filtered = segments2.filter((s2) => s2 && s2.length > 0);
15983
+ if (filtered.length === 0) return ".";
15984
+ return normalizePosix2(filtered.join("/"));
15985
+ }
15986
+ function dirnamePosix2(p2) {
15987
+ const norm2 = p2.replace(/\/+$/, "");
15988
+ const idx = norm2.lastIndexOf("/");
15989
+ if (idx === -1) return ".";
15990
+ if (idx === 0) return "/";
15991
+ return norm2.slice(0, idx);
15992
+ }
15993
+ function utf8ByteLength(s2) {
15994
+ return new TextEncoder().encode(s2).length;
15995
+ }
15996
+ function toRelKey(filePath) {
15997
+ let n2 = filePath.replace(/\\/g, "/");
15998
+ if (n2.startsWith("./")) n2 = n2.substring(2);
15999
+ if (n2.startsWith("/")) n2 = n2.substring(1);
16000
+ if (n2.endsWith("/") && n2.length > 1) n2 = n2.substring(0, n2.length - 1);
16001
+ return n2;
16002
+ }
15917
16003
  function createCoreStages() {
15918
16004
  return [
15919
16005
  new LexicalAnalysisStage(),
@@ -15923,7 +16009,7 @@ function createCoreStages() {
15923
16009
  new CodeGenerationStage()
15924
16010
  ];
15925
16011
  }
15926
- var yaml, import_semver, nunjucks, PrompdParser, PACKAGE_TYPE_DIRS, VALID_PACKAGE_TYPES, PrompdError, ParseError, ValidationError, CompilationError, SecurityError, CompilationContext, DEFAULT_SECURITY_CONFIG, PROMPD_EXTENSIONS, CompilerPipeline, LexicalAnalysisStage, DependencyResolutionStage, SemanticAnalysisStage, _SectionOverrideProcessor, SectionOverrideProcessor, EXTENSION_TO_LANGUAGE_ALIASES, globalIncludeStacks, compilationCounter, PrompdLoader, TemplateProcessingStage, MarkdownFormatter, OpenAIFormatter, AnthropicFormatter, CodeGenerationStage, PrompdCompiler;
16012
+ var yaml, import_semver, nunjucks, PrompdParser, PACKAGE_TYPE_DIRS, VALID_PACKAGE_TYPES, PrompdError, ParseError, ValidationError, CompilationError, SecurityError, CompilationContext, DEFAULT_SECURITY_CONFIG, PROMPD_EXTENSIONS, CompilerPipeline, LexicalAnalysisStage, DependencyResolutionStage, SemanticAnalysisStage, _SectionOverrideProcessor, SectionOverrideProcessor, EXTENSION_TO_LANGUAGE_ALIASES, globalIncludeStacks, compilationCounter, PrompdLoader, TemplateProcessingStage, MarkdownFormatter, OpenAIFormatter, AnthropicFormatter, CodeGenerationStage, MemoryFileSystem, HybridFileSystem, PrompdCompiler;
15927
16013
  var init_dist = __esm({
15928
16014
  "../../node_modules/.pnpm/@prompd+core@0.5.0-beta.10/node_modules/@prompd/core/dist/index.js"() {
15929
16015
  yaml = __toESM(require_dist(), 1);
@@ -18503,6 +18589,150 @@ ${firstUserMsg.content}`;
18503
18589
  return "Code Generation";
18504
18590
  }
18505
18591
  };
18592
+ MemoryFileSystem = class {
18593
+ constructor(files = {}) {
18594
+ this.files = /* @__PURE__ */ new Map();
18595
+ for (const [filePath, content] of Object.entries(files)) {
18596
+ this.addFile(filePath, content);
18597
+ }
18598
+ }
18599
+ /** Add or update a file in the in-memory file system. */
18600
+ addFile(filePath, content) {
18601
+ this.files.set(this.normalizePath(filePath), content);
18602
+ }
18603
+ /** Add multiple files at once. */
18604
+ addFiles(files) {
18605
+ for (const [filePath, content] of Object.entries(files)) {
18606
+ this.addFile(filePath, content);
18607
+ }
18608
+ }
18609
+ exists(filePath) {
18610
+ return this.files.has(this.normalizePath(filePath));
18611
+ }
18612
+ readFile(filePath) {
18613
+ const normalizedPath = this.normalizePath(filePath);
18614
+ const content = this.files.get(normalizedPath);
18615
+ if (content === void 0) {
18616
+ throw new Error(`File not found: ${filePath}`);
18617
+ }
18618
+ return content;
18619
+ }
18620
+ isDirectory(filePath) {
18621
+ const normalizedPath = this.normalizePath(filePath);
18622
+ const dirPrefix = normalizedPath.endsWith("/") ? normalizedPath : normalizedPath + "/";
18623
+ for (const file of this.files.keys()) {
18624
+ if (file.startsWith(dirPrefix)) {
18625
+ return true;
18626
+ }
18627
+ }
18628
+ return false;
18629
+ }
18630
+ readdir(dirPath) {
18631
+ const normalizedDir = this.normalizePath(dirPath);
18632
+ const dirPrefix = normalizedDir.endsWith("/") ? normalizedDir : normalizedDir + "/";
18633
+ const files = /* @__PURE__ */ new Set();
18634
+ for (const file of this.files.keys()) {
18635
+ if (file.startsWith(dirPrefix)) {
18636
+ const relativePath = file.substring(dirPrefix.length);
18637
+ const slashIndex = relativePath.indexOf("/");
18638
+ const fileName = slashIndex === -1 ? relativePath : relativePath.substring(0, slashIndex);
18639
+ if (fileName) {
18640
+ files.add(fileName);
18641
+ }
18642
+ }
18643
+ }
18644
+ return Array.from(files);
18645
+ }
18646
+ resolve(...pathSegments) {
18647
+ return joinPosix2("/", ...pathSegments);
18648
+ }
18649
+ dirname(filePath) {
18650
+ return dirnamePosix2(filePath);
18651
+ }
18652
+ join(...pathSegments) {
18653
+ return joinPosix2(...pathSegments);
18654
+ }
18655
+ /** Get the virtual file system path for a package. */
18656
+ getPackagePath(packageName, version) {
18657
+ return `/packages/${packageName}@${version}`;
18658
+ }
18659
+ /** Get all files under an optional base path. */
18660
+ getAllFiles(basePath) {
18661
+ if (!basePath) {
18662
+ return new Map(this.files);
18663
+ }
18664
+ const normalizedBase = this.normalizePath(basePath);
18665
+ const prefix = normalizedBase.endsWith("/") ? normalizedBase : normalizedBase + "/";
18666
+ const filtered = /* @__PURE__ */ new Map();
18667
+ for (const [filePath, content] of this.files.entries()) {
18668
+ if (filePath.startsWith(prefix) || filePath === normalizedBase) {
18669
+ filtered.set(filePath, content);
18670
+ }
18671
+ }
18672
+ return filtered;
18673
+ }
18674
+ /** Calculate total size (bytes) and file count under a base path. */
18675
+ getTotalSize(basePath) {
18676
+ const files = this.getAllFiles(basePath);
18677
+ let totalSize = 0;
18678
+ for (const content of files.values()) {
18679
+ totalSize += utf8ByteLength(content);
18680
+ }
18681
+ return { size: totalSize, files: files.size };
18682
+ }
18683
+ /** Normalize path to forward slashes, drop leading ./ and /, no trailing slash. */
18684
+ normalizePath(filePath) {
18685
+ return toRelKey(filePath);
18686
+ }
18687
+ };
18688
+ HybridFileSystem = class {
18689
+ constructor(files = {}, backend2) {
18690
+ this.mem = new MemoryFileSystem(files);
18691
+ this.backend = backend2;
18692
+ }
18693
+ /** Add or update an in-memory (synchronously-served) file. */
18694
+ addFile(filePath, content) {
18695
+ this.mem.addFile(filePath, content);
18696
+ }
18697
+ /** Add multiple in-memory files at once. */
18698
+ addFiles(files) {
18699
+ this.mem.addFiles(files);
18700
+ }
18701
+ exists(filePath) {
18702
+ if (this.mem.exists(filePath)) return true;
18703
+ return this.backend.readFile(this.normalize(filePath)).then((content) => content !== null).catch(() => false);
18704
+ }
18705
+ readFile(filePath) {
18706
+ if (this.mem.exists(filePath)) return this.mem.readFile(filePath);
18707
+ return this.backend.readFile(this.normalize(filePath)).then((content) => {
18708
+ if (content === null) throw new Error(`File not found: ${filePath}`);
18709
+ return content;
18710
+ });
18711
+ }
18712
+ isDirectory(filePath) {
18713
+ if (this.mem.isDirectory(filePath)) return true;
18714
+ if (!this.backend.isDirectory) return false;
18715
+ return this.backend.isDirectory(this.normalize(filePath)).catch(() => false);
18716
+ }
18717
+ readdir(dirPath) {
18718
+ const local = this.mem.readdir(dirPath);
18719
+ if (!this.backend.readdir) return local;
18720
+ return this.backend.readdir(this.normalize(dirPath)).then((remote) => Array.from(/* @__PURE__ */ new Set([...local, ...remote]))).catch(() => local);
18721
+ }
18722
+ resolve(...pathSegments) {
18723
+ return this.mem.resolve(...pathSegments);
18724
+ }
18725
+ dirname(filePath) {
18726
+ return this.mem.dirname(filePath);
18727
+ }
18728
+ join(...pathSegments) {
18729
+ return this.mem.join(...pathSegments);
18730
+ }
18731
+ /** Normalize to the same relative keys MemoryFileSystem uses, for backend lookups. */
18732
+ normalize(filePath) {
18733
+ return toRelKey(filePath);
18734
+ }
18735
+ };
18506
18736
  PrompdCompiler = class {
18507
18737
  constructor(options = {}) {
18508
18738
  this.securityConfig = options.securityConfig || DEFAULT_SECURITY_CONFIG;
@@ -18536,6 +18766,66 @@ ${firstUserMsg.content}`;
18536
18766
  });
18537
18767
 
18538
18768
  // ../tools-node/promptHost.ts
18769
+ function backend(svc) {
18770
+ return {
18771
+ async readFile(p2) {
18772
+ try {
18773
+ return await svc.read(normalizeFilePath(p2));
18774
+ } catch {
18775
+ return null;
18776
+ }
18777
+ },
18778
+ async isDirectory(p2) {
18779
+ return (await svc.stat(normalizeFilePath(p2)))?.kind === "directory";
18780
+ },
18781
+ async readdir(p2) {
18782
+ return (await svc.list(normalizeFilePath(p2))).map((n2) => n2.name);
18783
+ }
18784
+ };
18785
+ }
18786
+ async function preload(svc) {
18787
+ const out = {};
18788
+ for (const f2 of flattenFiles(await svc.list())) {
18789
+ try {
18790
+ out[f2.path] = await svc.read(f2.path);
18791
+ } catch {
18792
+ }
18793
+ }
18794
+ return out;
18795
+ }
18796
+ async function compile(src2, values, fileSystem, mainPath) {
18797
+ const mp = mainPath || "/main.prmd";
18798
+ const fs17 = fileSystem || new MemoryFileSystem({ [mp]: src2 });
18799
+ const ctx = await compiler.compileWithContext(mp, { parameters: values || {}, fileSystem: fs17, outputFormat: "markdown" });
18800
+ const compiled = ctx.compiledResult;
18801
+ const output = typeof compiled === "string" ? compiled : compiled ? new TextDecoder().decode(compiled) : "";
18802
+ return { output };
18803
+ }
18804
+ function nodePromptHost(workspace, project) {
18805
+ return {
18806
+ workspace,
18807
+ /* The user's PROJECT files, when the caller has them, so skills installed
18808
+ * into `.prompd/skills/` are visible. Omitted rather than faked when there
18809
+ * is none: a CLI invoked outside a project genuinely has no installed
18810
+ * skills, and saying so beats inventing an empty directory. */
18811
+ ...project ? { projectWorkspace: () => project } : {},
18812
+ seed: async (defaults2) => {
18813
+ for (const [p2, content] of Object.entries(defaults2)) {
18814
+ if (!await workspace.stat(p2)) await workspace.write(p2, content);
18815
+ }
18816
+ },
18817
+ parse: (source) => ({ meta: parser.parseContent(source).metadata ?? {} }),
18818
+ compileBody: (source, values) => compile(source, values),
18819
+ compileTemplate: async (path, liveSrc, values) => {
18820
+ const sources = await preload(workspace);
18821
+ const p2 = normalizeFilePath(path);
18822
+ sources[p2] = liveSrc;
18823
+ return compile("", values, new HybridFileSystem(sources, backend(workspace)), "/" + p2);
18824
+ },
18825
+ siteIndex: () => ""
18826
+ // no site index in node (the help chat is browser-only)
18827
+ };
18828
+ }
18539
18829
  var parser, compiler;
18540
18830
  var init_promptHost = __esm({
18541
18831
  "../tools-node/promptHost.ts"() {
@@ -18550,7 +18840,7 @@ var init_promptHost = __esm({
18550
18840
  // ../tools-node/migrateHome.ts
18551
18841
  import { promises as fs5 } from "node:fs";
18552
18842
  import { homedir as homedir3 } from "node:os";
18553
- import { join as join5, dirname as dirname2 } from "node:path";
18843
+ import { join as join6, dirname as dirname3 } from "node:path";
18554
18844
  async function isDir(p2) {
18555
18845
  return fs5.stat(p2).then((s2) => s2.isDirectory()).catch(() => false);
18556
18846
  }
@@ -18566,7 +18856,7 @@ async function moveEntry(from, to, report) {
18566
18856
  report.skipped.push({ path: to, reason: "destination already exists" });
18567
18857
  return;
18568
18858
  }
18569
- await fs5.mkdir(dirname2(to), { recursive: true });
18859
+ await fs5.mkdir(dirname3(to), { recursive: true });
18570
18860
  try {
18571
18861
  await fs5.rename(from, to);
18572
18862
  report.moved.push(to);
@@ -18592,8 +18882,8 @@ async function moveEntry(from, to, report) {
18592
18882
  async function mergeDir(from, to, report) {
18593
18883
  const entries = await fs5.readdir(from, { withFileTypes: true }).catch(() => []);
18594
18884
  for (const e2 of entries) {
18595
- const src2 = join5(from, e2.name);
18596
- const dst = join5(to, e2.name);
18885
+ const src2 = join6(from, e2.name);
18886
+ const dst = join6(to, e2.name);
18597
18887
  if (e2.isDirectory() && await isDir(dst)) await mergeDir(src2, dst, report);
18598
18888
  else await moveEntry(src2, dst, report);
18599
18889
  }
@@ -18604,16 +18894,16 @@ async function migrateHomeLayout(base = homedir3()) {
18604
18894
  const report = { moved: [], skipped: [] };
18605
18895
  const home = corenelDir2(base);
18606
18896
  if (!await present(home)) return report;
18607
- const config = join5(home, "config");
18897
+ const config = join6(home, "config");
18608
18898
  if (await isDir(config)) await mergeDir(config, home, report);
18609
18899
  const side = sidecarDir(base);
18610
18900
  for (const name of SIDECAR_FILES) {
18611
- await moveEntry(join5(home, name), join5(side, name), report);
18901
+ await moveEntry(join6(home, name), join6(side, name), report);
18612
18902
  }
18613
18903
  for (const name of SIDECAR_DIRS) {
18614
- const src2 = join5(home, name);
18904
+ const src2 = join6(home, name);
18615
18905
  if (!await isDir(src2)) continue;
18616
- const dst = join5(side, name);
18906
+ const dst = join6(side, name);
18617
18907
  if (await isDir(dst)) await mergeDir(src2, dst, report);
18618
18908
  else await moveEntry(src2, dst, report);
18619
18909
  }
@@ -18638,7 +18928,50 @@ var init_migrateHome = __esm({
18638
18928
  }
18639
18929
  });
18640
18930
 
18931
+ // ../tools-node/openBrowser.ts
18932
+ import { spawn } from "node:child_process";
18933
+ function openBrowser(url) {
18934
+ const [cmd, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
18935
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
18936
+ child.on("error", () => {
18937
+ });
18938
+ child.unref();
18939
+ }
18940
+ var init_openBrowser = __esm({
18941
+ "../tools-node/openBrowser.ts"() {
18942
+ "use strict";
18943
+ }
18944
+ });
18945
+
18641
18946
  // ../tools-node/index.ts
18947
+ var tools_node_exports = {};
18948
+ __export(tools_node_exports, {
18949
+ API_BASE_ENV: () => API_BASE_ENV,
18950
+ NodeFileService: () => NodeFileService,
18951
+ TOKEN_ENV: () => TOKEN_ENV,
18952
+ authTokenPath: () => authTokenPath,
18953
+ clearAllCredentials: () => clearAllCredentials,
18954
+ clearAuthToken: () => clearAuthToken,
18955
+ corenelDir: () => corenelDir2,
18956
+ credentialDir: () => credentialDir,
18957
+ credentialFilePath: () => credentialFilePath,
18958
+ maskAccount: () => maskAccount,
18959
+ maskToken: () => maskToken,
18960
+ migrateHomeLayout: () => migrateHomeLayout,
18961
+ nodeAuthToken: () => nodeAuthToken,
18962
+ nodePromptHost: () => nodePromptHost,
18963
+ openBrowser: () => openBrowser,
18964
+ readAuthToken: () => readAuthToken,
18965
+ readAuthTokenRead: () => readAuthTokenRead,
18966
+ readCredentialFile: () => readCredentialFile,
18967
+ readCredentialFileRead: () => readCredentialFileRead,
18968
+ resolveApiBase: () => resolveApiBase,
18969
+ resolveNodeCredential: () => resolveNodeCredential,
18970
+ sidecarDir: () => sidecarDir,
18971
+ tokenFilePath: () => tokenFilePath,
18972
+ writeAuthToken: () => writeAuthToken,
18973
+ writeLoginCredential: () => writeLoginCredential
18974
+ });
18642
18975
  var init_tools_node = __esm({
18643
18976
  "../tools-node/index.ts"() {
18644
18977
  "use strict";
@@ -18646,59 +18979,7 @@ var init_tools_node = __esm({
18646
18979
  init_auth();
18647
18980
  init_promptHost();
18648
18981
  init_migrateHome();
18649
- }
18650
- });
18651
-
18652
- // src/auth/store.ts
18653
- import { promises as fs6 } from "node:fs";
18654
- function authDir(stateDir) {
18655
- return credentialDir(stateDir);
18656
- }
18657
- function credentialPath(stateDir) {
18658
- return credentialFilePath(stateDir);
18659
- }
18660
- async function readForeignAuthJson(path) {
18661
- let raw;
18662
- try {
18663
- raw = await fs6.readFile(path, "utf8");
18664
- } catch {
18665
- return {};
18666
- }
18667
- try {
18668
- const parsed = JSON.parse(raw);
18669
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
18670
- const obj = { ...parsed };
18671
- for (const k2 of STORED_CREDENTIAL_KEYS) delete obj[k2];
18672
- return obj;
18673
- } catch {
18674
- return {};
18675
- }
18676
- }
18677
- async function saveCredential(cred, stateDir) {
18678
- const path = credentialPath(stateDir);
18679
- await fs6.mkdir(authDir(stateDir), { recursive: true });
18680
- const foreign = await readForeignAuthJson(path);
18681
- const next = { ...foreign, ...cred };
18682
- await fs6.writeFile(path, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
18683
- try {
18684
- await fs6.chmod(path, 384);
18685
- } catch {
18686
- }
18687
- await writeAuthToken(cred.accessToken, stateDir);
18688
- }
18689
- async function clearCredential(stateDir) {
18690
- await clearAllCredentials(stateDir);
18691
- }
18692
- async function resolveCredential(env = process.env, stateDir, now2 = Date.now) {
18693
- return await resolveNodeCredential({ env, now: now2, ...stateDir ? { stateDir } : {} });
18694
- }
18695
- var TOKEN_ENV2, STORED_CREDENTIAL_KEYS;
18696
- var init_store = __esm({
18697
- "src/auth/store.ts"() {
18698
- "use strict";
18699
- init_tools_node();
18700
- TOKEN_ENV2 = TOKEN_ENV;
18701
- STORED_CREDENTIAL_KEYS = ["accessToken", "refreshToken", "expiresAt", "account", "apiBase"];
18982
+ init_openBrowser();
18702
18983
  }
18703
18984
  });
18704
18985
 
@@ -25102,270 +25383,6 @@ var init_fileFromPath = __esm({
25102
25383
  }
25103
25384
  });
25104
25385
 
25105
- // src/auth/pkce.ts
25106
- import { createHash as createHash4, randomBytes as randomBytes6 } from "node:crypto";
25107
- function base64url2(buf) {
25108
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
25109
- }
25110
- function createVerifier(bytes = 32) {
25111
- return base64url2(randomBytes6(bytes));
25112
- }
25113
- function challengeFor(verifier) {
25114
- return base64url2(createHash4("sha256").update(verifier).digest());
25115
- }
25116
- function createState() {
25117
- return base64url2(randomBytes6(16));
25118
- }
25119
- function createPkce() {
25120
- const verifier = createVerifier();
25121
- return { verifier, challenge: challengeFor(verifier), method: "S256", state: createState() };
25122
- }
25123
- function authorizeUrl(p2) {
25124
- const u2 = new URL("/oauth/authorize", p2.issuer);
25125
- u2.searchParams.set("response_type", "code");
25126
- u2.searchParams.set("client_id", p2.clientId);
25127
- u2.searchParams.set("redirect_uri", p2.redirectUri);
25128
- u2.searchParams.set("code_challenge", p2.pkce.challenge);
25129
- u2.searchParams.set("code_challenge_method", p2.pkce.method);
25130
- u2.searchParams.set("state", p2.pkce.state);
25131
- u2.searchParams.set("scope", p2.scope ?? "profile email");
25132
- return u2.toString();
25133
- }
25134
- function stateMatches(expected, got) {
25135
- if (!got || got.length !== expected.length) return false;
25136
- let diff = 0;
25137
- for (let i2 = 0; i2 < expected.length; i2++) diff |= expected.charCodeAt(i2) ^ got.charCodeAt(i2);
25138
- return diff === 0;
25139
- }
25140
- var init_pkce = __esm({
25141
- "src/auth/pkce.ts"() {
25142
- "use strict";
25143
- }
25144
- });
25145
-
25146
- // src/auth/login.ts
25147
- import { createServer as createServer2 } from "node:http";
25148
- function awaitCallback(pkce, port, timeoutMs) {
25149
- let resolveUri;
25150
- let resolveResult;
25151
- let rejectResult;
25152
- const redirectUri = new Promise((res) => {
25153
- resolveUri = res;
25154
- });
25155
- const result = new Promise((res, rej) => {
25156
- resolveResult = res;
25157
- rejectResult = rej;
25158
- });
25159
- const server = createServer2((req, res) => {
25160
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
25161
- if (url.pathname !== "/callback") {
25162
- res.writeHead(404).end();
25163
- return;
25164
- }
25165
- const err = url.searchParams.get("error");
25166
- const code2 = url.searchParams.get("code");
25167
- const state = url.searchParams.get("state");
25168
- if (!stateMatches(pkce.state, state)) {
25169
- res.writeHead(400, { "content-type": "text/html" }).end(PAGE_FAIL2);
25170
- rejectResult(new Error("state mismatch \u2014 the callback did not match this sign-in request"));
25171
- return;
25172
- }
25173
- if (err) {
25174
- res.writeHead(400, { "content-type": "text/html" }).end(PAGE_FAIL2);
25175
- rejectResult(new Error(`authorization failed: ${err}`));
25176
- return;
25177
- }
25178
- if (!code2) {
25179
- res.writeHead(400, { "content-type": "text/html" }).end(PAGE_FAIL2);
25180
- rejectResult(new Error("callback carried no authorization code"));
25181
- return;
25182
- }
25183
- res.writeHead(200, { "content-type": "text/html" }).end(PAGE_OK2);
25184
- resolveResult({ code: code2 });
25185
- });
25186
- const timer = setTimeout(() => {
25187
- rejectResult(new Error("timed out waiting for the browser callback"));
25188
- }, timeoutMs);
25189
- timer.unref?.();
25190
- const close2 = () => {
25191
- clearTimeout(timer);
25192
- server.close();
25193
- };
25194
- void result.finally(close2).catch(() => {
25195
- });
25196
- server.listen(port, "127.0.0.1", () => {
25197
- const addr = server.address();
25198
- const bound = typeof addr === "object" && addr ? addr.port : port;
25199
- resolveUri(`http://127.0.0.1:${bound}/callback`);
25200
- });
25201
- return { redirectUri, result, close: close2 };
25202
- }
25203
- async function login(deps) {
25204
- const log = deps.log ?? (() => void 0);
25205
- const now2 = deps.now ?? Date.now;
25206
- const pkce = createPkce();
25207
- const { redirectUri: uriP, result, close: close2 } = awaitCallback(
25208
- pkce,
25209
- deps.config.port ?? 0,
25210
- deps.timeoutMs ?? DEFAULT_TIMEOUT_MS4
25211
- );
25212
- try {
25213
- const redirectUri = await uriP;
25214
- const url = authorizeUrl({
25215
- issuer: deps.config.issuer,
25216
- clientId: deps.config.clientId,
25217
- redirectUri,
25218
- pkce,
25219
- ...deps.config.scope ? { scope: deps.config.scope } : {}
25220
- });
25221
- log("Opening your browser to sign in...");
25222
- log(url);
25223
- await deps.openBrowser(url);
25224
- const { code: code2 } = await result;
25225
- const tokens = await deps.exchange({
25226
- issuer: deps.config.issuer,
25227
- clientId: deps.config.clientId,
25228
- code: code2,
25229
- verifier: pkce.verifier,
25230
- redirectUri
25231
- });
25232
- if (!tokens.access_token) throw new Error("the token exchange returned no access token");
25233
- const cred = {
25234
- accessToken: tokens.access_token,
25235
- ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {},
25236
- ...typeof tokens.expires_in === "number" ? { expiresAt: now2() + tokens.expires_in * 1e3 } : {},
25237
- ...tokens.account ? { account: tokens.account } : {},
25238
- ...deps.apiBase ? { apiBase: deps.apiBase } : {}
25239
- };
25240
- await saveCredential(cred, deps.stateDir);
25241
- return {
25242
- ...cred.account ? { account: cred.account } : {},
25243
- ...cred.expiresAt ? { expiresAt: cred.expiresAt } : {}
25244
- };
25245
- } finally {
25246
- close2();
25247
- }
25248
- }
25249
- var DEFAULT_TIMEOUT_MS4, PAGE_OK2, PAGE_FAIL2;
25250
- var init_login = __esm({
25251
- "src/auth/login.ts"() {
25252
- "use strict";
25253
- init_pkce();
25254
- init_store();
25255
- DEFAULT_TIMEOUT_MS4 = 5 * 60 * 1e3;
25256
- PAGE_OK2 = `<!doctype html><meta charset="utf-8"><title>Signed in</title>
25257
- <body style="font:16px system-ui;padding:3rem;text-align:center">
25258
- <h1>Signed in to Corenel</h1><p>You can close this tab and return to your terminal.</p></body>`;
25259
- PAGE_FAIL2 = `<!doctype html><meta charset="utf-8"><title>Sign-in failed</title>
25260
- <body style="font:16px system-ui;padding:3rem;text-align:center">
25261
- <h1>Sign-in failed</h1><p>Return to your terminal for details.</p></body>`;
25262
- }
25263
- });
25264
-
25265
- // src/auth/cli.ts
25266
- var cli_exports = {};
25267
- __export(cli_exports, {
25268
- exchangeCode: () => exchangeCode2,
25269
- oauthConfigFromEnv: () => oauthConfigFromEnv,
25270
- openBrowser: () => openBrowser,
25271
- runAuthStatus: () => runAuthStatus,
25272
- runLogin: () => runLogin,
25273
- runLogout: () => runLogout
25274
- });
25275
- import { spawn as spawn6 } from "node:child_process";
25276
- function oauthConfigFromEnv(env = process.env) {
25277
- const issuer = env.CORENEL_OAUTH_ISSUER?.trim();
25278
- const clientId = env.CORENEL_OAUTH_CLIENT_ID?.trim();
25279
- if (!issuer || !clientId) return null;
25280
- return { issuer, clientId };
25281
- }
25282
- function openBrowser(url) {
25283
- const [cmd, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
25284
- const child = spawn6(cmd, args, { detached: true, stdio: "ignore" });
25285
- child.on("error", () => {
25286
- });
25287
- child.unref();
25288
- }
25289
- async function exchangeCode2(p2) {
25290
- const res = await fetch(new URL("/oauth/token", p2.issuer), {
25291
- method: "POST",
25292
- headers: { "content-type": "application/x-www-form-urlencoded" },
25293
- body: new URLSearchParams({
25294
- grant_type: "authorization_code",
25295
- client_id: p2.clientId,
25296
- code: p2.code,
25297
- code_verifier: p2.verifier,
25298
- redirect_uri: p2.redirectUri
25299
- }).toString()
25300
- });
25301
- if (!res.ok) {
25302
- throw new Error(`token exchange failed (HTTP ${res.status})`);
25303
- }
25304
- return await res.json();
25305
- }
25306
- async function runLogin(stateDir, apiBase2) {
25307
- const config = oauthConfigFromEnv();
25308
- if (!config) {
25309
- console.error("Sign-in is not configured on this build.");
25310
- console.error("Set CORENEL_OAUTH_ISSUER and CORENEL_OAUTH_CLIENT_ID, or use " + TOKEN_ENV2 + " for headless use.");
25311
- return 1;
25312
- }
25313
- try {
25314
- const res = await login({
25315
- config,
25316
- openBrowser,
25317
- exchange: exchangeCode2,
25318
- log: (l2) => console.error(l2),
25319
- ...stateDir ? { stateDir } : {},
25320
- // Recorded so the daemon does not have to guess later which API this
25321
- // credential belongs to -- see LoginDeps.apiBase.
25322
- ...apiBase2 ? { apiBase: apiBase2 } : {}
25323
- });
25324
- console.error(`Signed in${res.account ? ` as ${res.account}` : ""}.`);
25325
- return 0;
25326
- } catch (err) {
25327
- console.error(`Sign-in failed: ${err instanceof Error ? err.message : String(err)}`);
25328
- return 1;
25329
- }
25330
- }
25331
- async function runLogout(stateDir, env = process.env) {
25332
- const before = await resolveCredential(env, stateDir);
25333
- await clearCredential(stateDir);
25334
- console.error("Signed out. (Paired devices are unaffected - run `hosts forget` to unpair.)");
25335
- console.error(` Cleared ${tokenFilePath(stateDir)} and ${credentialFilePath(stateDir)}`);
25336
- if (before.source === "none") console.error(" Nothing was stored - this machine was already signed out.");
25337
- if (env[TOKEN_ENV2]?.trim()) {
25338
- console.error(` NOTE: ${TOKEN_ENV2} is still set in this environment, so this machine is STILL signed in.`);
25339
- }
25340
- return 0;
25341
- }
25342
- function sourceLabel(source) {
25343
- return source === "env" ? `${TOKEN_ENV2} environment variable` : source === "token-file" ? "token file" : source === "credential-file" ? "credential file (auth.json)" : "none";
25344
- }
25345
- async function runAuthStatus(stateDir, env = process.env) {
25346
- const r2 = await resolveCredential(env, stateDir);
25347
- if (!r2.token) {
25348
- console.error("Not signed in. Run `corenel login`, or set " + TOKEN_ENV2 + ".");
25349
- console.error(` Looked in ${tokenFilePath(stateDir)}`);
25350
- console.error(` Looked in ${credentialFilePath(stateDir)}`);
25351
- return 1;
25352
- }
25353
- console.error(`Signed in (${r2.source === "env" ? sourceLabel(r2.source) : r2.path}).`);
25354
- console.error(` account ${r2.account ?? "not recorded in this store - run `corenel login` to record it"}`);
25355
- console.error(` token ${maskToken(r2.token)}`);
25356
- console.error(` source ${sourceLabel(r2.source)}`);
25357
- console.error(` expiry ${r2.expired ? `EXPIRED at ${new Date(r2.expiresAt ?? 0).toISOString()} - run login again` : r2.expiresAt ? new Date(r2.expiresAt).toISOString() : "not recorded in this store"}`);
25358
- return r2.expired ? 1 : 0;
25359
- }
25360
- var init_cli = __esm({
25361
- "src/auth/cli.ts"() {
25362
- "use strict";
25363
- init_login();
25364
- init_store();
25365
- init_tools_node();
25366
- }
25367
- });
25368
-
25369
25386
  // src/cli.ts
25370
25387
  init_protocol();
25371
25388
  import { join as join21 } from "node:path";
@@ -28083,7 +28100,127 @@ function makeFileMirrorSource(deps) {
28083
28100
 
28084
28101
  // src/controlPlane.ts
28085
28102
  init_protocol();
28086
- init_store();
28103
+
28104
+ // ../config/src/daemonConfig.ts
28105
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
28106
+ import { dirname, join as join3 } from "node:path";
28107
+ var DAEMON_CONFIG_VERSION = 1;
28108
+ var SECRET_KEYS = ["acpAuth", "tunnelToken", "token"];
28109
+ var KNOWN_KEYS = [
28110
+ "version",
28111
+ "port",
28112
+ "host",
28113
+ "roots",
28114
+ "addUnder",
28115
+ "cloneUnder",
28116
+ "allowLan",
28117
+ "allowOrigins",
28118
+ "tlsCert",
28119
+ "tlsKey",
28120
+ "selfSigned",
28121
+ "tunnel",
28122
+ "tunnelName",
28123
+ "tunnelHostname",
28124
+ "endpoint",
28125
+ "allowShell",
28126
+ "keepTranscripts",
28127
+ "isolation",
28128
+ "msbBin",
28129
+ "guestImage",
28130
+ "maxChainDepth",
28131
+ "name",
28132
+ "appUrl",
28133
+ "defaultModel",
28134
+ "mcpServers",
28135
+ "mcpAllow"
28136
+ ];
28137
+ function daemonConfigPath(stateDir) {
28138
+ return join3(stateDir, "daemon.json");
28139
+ }
28140
+ async function readDaemonConfig(stateDir) {
28141
+ const empty = (corrupt) => ({ config: { version: DAEMON_CONFIG_VERSION }, unknown: {}, corrupt });
28142
+ let raw;
28143
+ try {
28144
+ raw = await readFile(daemonConfigPath(stateDir), "utf8");
28145
+ } catch {
28146
+ return empty(false);
28147
+ }
28148
+ let parsed;
28149
+ try {
28150
+ parsed = JSON.parse(raw);
28151
+ } catch {
28152
+ return empty(true);
28153
+ }
28154
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return empty(true);
28155
+ const src2 = parsed;
28156
+ const config = { version: DAEMON_CONFIG_VERSION };
28157
+ const unknown = {};
28158
+ for (const [k2, v2] of Object.entries(src2)) {
28159
+ if (k2 === "version") continue;
28160
+ if (KNOWN_KEYS.includes(k2)) {
28161
+ config[k2] = v2;
28162
+ } else {
28163
+ unknown[k2] = v2;
28164
+ }
28165
+ }
28166
+ return { config, unknown, corrupt: false };
28167
+ }
28168
+ async function writeDaemonConfig(stateDir, next, unknown = {}) {
28169
+ const body2 = { ...unknown, ...next, version: DAEMON_CONFIG_VERSION };
28170
+ const path = daemonConfigPath(stateDir);
28171
+ await mkdir(dirname(path), { recursive: true });
28172
+ await writeFile(path, `${JSON.stringify(body2, null, 2)}
28173
+ `, "utf8");
28174
+ }
28175
+
28176
+ // ../config/src/credential.ts
28177
+ init_tools_node();
28178
+ import { promises as fs6 } from "node:fs";
28179
+ var TOKEN_ENV2 = TOKEN_ENV;
28180
+ function authDir(stateDir) {
28181
+ return credentialDir(stateDir);
28182
+ }
28183
+ function credentialPath(stateDir) {
28184
+ return credentialFilePath(stateDir);
28185
+ }
28186
+ var STORED_CREDENTIAL_KEYS = ["accessToken", "refreshToken", "expiresAt", "account", "apiBase"];
28187
+ async function readForeignAuthJson(path) {
28188
+ let raw;
28189
+ try {
28190
+ raw = await fs6.readFile(path, "utf8");
28191
+ } catch {
28192
+ return {};
28193
+ }
28194
+ try {
28195
+ const parsed = JSON.parse(raw);
28196
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
28197
+ const obj = { ...parsed };
28198
+ for (const k2 of STORED_CREDENTIAL_KEYS) delete obj[k2];
28199
+ return obj;
28200
+ } catch {
28201
+ return {};
28202
+ }
28203
+ }
28204
+ async function saveCredential(cred, stateDir) {
28205
+ const path = credentialPath(stateDir);
28206
+ await fs6.mkdir(authDir(stateDir), { recursive: true });
28207
+ const foreign = await readForeignAuthJson(path);
28208
+ const next = { ...foreign, ...cred };
28209
+ await fs6.writeFile(path, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
28210
+ try {
28211
+ await fs6.chmod(path, 384);
28212
+ } catch {
28213
+ }
28214
+ await writeAuthToken(cred.accessToken, stateDir);
28215
+ }
28216
+ async function clearCredential(stateDir) {
28217
+ await clearAllCredentials(stateDir);
28218
+ }
28219
+ async function resolveCredential(env = process.env, stateDir, now2 = Date.now) {
28220
+ return await resolveNodeCredential({ env, now: now2, ...stateDir ? { stateDir } : {} });
28221
+ }
28222
+
28223
+ // src/controlPlane.ts
28087
28224
  function describeBackend(url, mirrors, cred, probe) {
28088
28225
  const detail = probe ? {
28089
28226
  reachable: probe.answered,
@@ -28143,8 +28280,8 @@ function makeControlPlane(deps) {
28143
28280
 
28144
28281
  // src/tools.ts
28145
28282
  import { promises as fs7 } from "node:fs";
28146
- import { spawn } from "node:child_process";
28147
- import { resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2, sep as sep2, dirname as dirname3 } from "node:path";
28283
+ import { spawn as spawn2 } from "node:child_process";
28284
+ import { resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2, sep as sep2, dirname as dirname4 } from "node:path";
28148
28285
  function withinRoot(root, abs) {
28149
28286
  const rel = relative2(root, abs);
28150
28287
  return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel) && rel.split(sep2)[0] !== "..";
@@ -28172,7 +28309,7 @@ async function assertRealWithinRoot(roots, abs) {
28172
28309
  return;
28173
28310
  } catch (e2) {
28174
28311
  if (e2 instanceof Error && e2.message.includes("via symlink")) throw e2;
28175
- const parent = dirname3(probe);
28312
+ const parent = dirname4(probe);
28176
28313
  if (parent === probe) throw new Error(`cannot resolve path under the sidecar roots: ${abs}`);
28177
28314
  probe = parent;
28178
28315
  }
@@ -28223,7 +28360,7 @@ function nodeFsTools(roots) {
28223
28360
  mutates: true,
28224
28361
  async run(args) {
28225
28362
  const abs = confine(roots, args.root, String(args.path ?? ""));
28226
- await fs7.mkdir(dirname3(abs), { recursive: true });
28363
+ await fs7.mkdir(dirname4(abs), { recursive: true });
28227
28364
  await assertRealWithinRoot(roots, abs);
28228
28365
  await fs7.writeFile(abs, String(args.content ?? ""), "utf8");
28229
28366
  return `wrote ${args.path}`;
@@ -28274,7 +28411,7 @@ function nodeFsTools(roots) {
28274
28411
  if (bytes.byteLength > MAX_READ_BYTES) {
28275
28412
  throw new Error(`file too large to write (${bytes.byteLength} bytes; max ${MAX_READ_BYTES})`);
28276
28413
  }
28277
- await fs7.mkdir(dirname3(abs), { recursive: true });
28414
+ await fs7.mkdir(dirname4(abs), { recursive: true });
28278
28415
  await assertRealWithinRoot(roots, abs);
28279
28416
  await fs7.writeFile(abs, bytes);
28280
28417
  return `wrote ${args.path} (${bytes.byteLength} bytes)`;
@@ -28389,7 +28526,7 @@ function runShell(command, cwd, timeout) {
28389
28526
  return new Promise((resolveOut) => {
28390
28527
  const isWin = process.platform === "win32";
28391
28528
  const [shell, shellArgs] = isWin ? ["cmd.exe", ["/d", "/s", "/c", command]] : ["/bin/sh", ["-c", command]];
28392
- const child = spawn(shell, shellArgs, { cwd, windowsHide: true });
28529
+ const child = spawn2(shell, shellArgs, { cwd, windowsHide: true });
28393
28530
  let out = "";
28394
28531
  let truncated = false;
28395
28532
  let timedOut = false;
@@ -28424,7 +28561,7 @@ ${truncated ? "[output truncated]\n" : ""}${body2}`);
28424
28561
  // src/configService.ts
28425
28562
  init_protocol();
28426
28563
  import { promises as fs8 } from "node:fs";
28427
- import { resolve as resolve3, relative as relative3, join as join6, dirname as dirname4, sep as sep3 } from "node:path";
28564
+ import { resolve as resolve3, relative as relative3, join as join7, dirname as dirname5, sep as sep3 } from "node:path";
28428
28565
  var MAX_LIST_DEPTH2 = 24;
28429
28566
  var MAX_LIST_ENTRIES2 = 2e4;
28430
28567
  function relUnder(root, abs) {
@@ -28459,7 +28596,7 @@ var NodeConfigService = class {
28459
28596
  real = await fs8.realpath(probe);
28460
28597
  } catch {
28461
28598
  if (mustExist && probe === abs) throw new Error(`no such harness config file: ${relUnder(this.stateDir, abs) ?? abs}`);
28462
- const parent = dirname4(probe);
28599
+ const parent = dirname5(probe);
28463
28600
  if (parent === probe) throw new Error("cannot resolve a path inside the harness config");
28464
28601
  probe = parent;
28465
28602
  continue;
@@ -28495,11 +28632,11 @@ var NodeConfigService = class {
28495
28632
  async listRoot(levels) {
28496
28633
  const out = [];
28497
28634
  for (const name of CONFIG_DEFINITION_FILES) {
28498
- const st2 = await fs8.stat(join6(this.stateDir, name)).catch(() => null);
28635
+ const st2 = await fs8.stat(join7(this.stateDir, name)).catch(() => null);
28499
28636
  if (st2?.isFile()) out.push({ path: name, size: st2.size, mtime: st2.mtimeMs });
28500
28637
  }
28501
28638
  for (const name of CONFIG_DEFINITION_DIRS) {
28502
- const abs = join6(this.stateDir, name);
28639
+ const abs = join7(this.stateDir, name);
28503
28640
  const st2 = await fs8.stat(abs).catch(() => null);
28504
28641
  if (!st2?.isDirectory()) continue;
28505
28642
  out.push({ path: name, dir: true });
@@ -28514,7 +28651,7 @@ var NodeConfigService = class {
28514
28651
  for (const e2 of entries) {
28515
28652
  if (out.length >= MAX_LIST_ENTRIES2) return;
28516
28653
  if (e2.isSymbolicLink()) continue;
28517
- const child = join6(abs, e2.name);
28654
+ const child = join7(abs, e2.name);
28518
28655
  if (e2.isDirectory()) {
28519
28656
  out.push({ path: `${prefix}${e2.name}`, dir: true });
28520
28657
  await this.walk(child, `${prefix}${e2.name}/`, levels - 1, out);
@@ -28533,7 +28670,7 @@ var NodeConfigService = class {
28533
28670
  * partially written AGENT.md is a definition that parses to nonsense. */
28534
28671
  async write(path, content) {
28535
28672
  const abs = await this.confine(path, false);
28536
- await fs8.mkdir(dirname4(abs), { recursive: true });
28673
+ await fs8.mkdir(dirname5(abs), { recursive: true });
28537
28674
  await this.assertRealAllowed(abs, false);
28538
28675
  const tmp = `${abs}.${process.pid}.${Date.now()}.tmp`;
28539
28676
  try {
@@ -28552,7 +28689,7 @@ var NodeConfigService = class {
28552
28689
  };
28553
28690
 
28554
28691
  // src/procs.ts
28555
- import { spawn as spawn2 } from "node:child_process";
28692
+ import { spawn as spawn3 } from "node:child_process";
28556
28693
  var MAX_RUNNING = 8;
28557
28694
  var MAX_CHUNKS = 400;
28558
28695
  var MAX_CHARS = 2e5;
@@ -28614,7 +28751,7 @@ function createNodeProcs(opts) {
28614
28751
  const isWin = process.platform === "win32";
28615
28752
  const shell = isWin ? "cmd.exe" : "/bin/sh";
28616
28753
  const shellArgs = isWin ? ["/d", "/s", "/c", line] : ["-c", line];
28617
- const child = spawn2(shell, shellArgs, {
28754
+ const child = spawn3(shell, shellArgs, {
28618
28755
  cwd: dir,
28619
28756
  windowsHide: true,
28620
28757
  stdio: "pipe",
@@ -29552,6 +29689,96 @@ var todoWriteTool = {
29552
29689
  }
29553
29690
  };
29554
29691
 
29692
+ // ../harness/tools/project.ts
29693
+ init_protocol();
29694
+ var manifestSuffix = () => `${stateDirName()}/craft/project.json`;
29695
+ async function findProjects(ctx) {
29696
+ const files = ctx.files;
29697
+ if (!files) return [];
29698
+ const suffix = manifestSuffix();
29699
+ const nodes = await files.list().catch(() => []);
29700
+ const out = [];
29701
+ for (const node of flattenFiles(nodes)) {
29702
+ const p2 = node.path;
29703
+ const root = p2 === suffix ? "" : p2.endsWith(`/${suffix}`) ? p2.slice(0, -(suffix.length + 1)) : null;
29704
+ if (root === null) continue;
29705
+ try {
29706
+ const m2 = JSON.parse(await files.read(p2));
29707
+ if (m2 && m2.version === 1 && typeof m2.name === "string") out.push({ root, name: m2.name });
29708
+ } catch {
29709
+ }
29710
+ }
29711
+ return out;
29712
+ }
29713
+ function projectSlug(name) {
29714
+ const s2 = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
29715
+ return s2 || "project";
29716
+ }
29717
+ async function freeRoot(ctx, name) {
29718
+ const files = ctx.files;
29719
+ const base = projectSlug(name);
29720
+ let root = base;
29721
+ for (let i2 = 2; files && await files.stat(root) !== null; i2++) root = `${base}-${i2}`;
29722
+ return root;
29723
+ }
29724
+ var startProjectTool = {
29725
+ name: "start_project",
29726
+ /* DECLARED, like ask_user's, and for the same reason: its whole effect is to
29727
+ * put a choice in front of someone who is already there, and the write it
29728
+ * may then make is the thing they just chose. A permission prompt on top of
29729
+ * the choice is two dialogs for one decision. */
29730
+ permission: "allow",
29731
+ noOffload: true,
29732
+ description: "Use when the person asks for a DOCUMENT (a memo, report, deck, spreadsheet, diagram, page) and the system context says no project is open. Documents live inside a project, where they are versioned and shown beside the conversation; a file written outside one is invisible in this surface. Propose a short project name derived from what they asked for \u2014 the person confirms it, picks an existing project instead, or declines. This call WAITS for their answer and returns the folder to write into, or that they declined. Write the document inside that folder with the ordinary file tools. Do NOT use this for ordinary code or repository files, which belong where they already are.",
29733
+ parameters: {
29734
+ type: "object",
29735
+ properties: {
29736
+ name: { type: "string", description: "Proposed project name, 2-4 words, derived from the request." },
29737
+ reason: { type: "string", description: "One short line: what is about to be written into it." }
29738
+ },
29739
+ required: ["name"]
29740
+ },
29741
+ async run(args, ctx) {
29742
+ const files = ctx.files;
29743
+ if (!files) return "start_project is unavailable: no file workspace is connected.";
29744
+ if (!ctx.ask) return "start_project is unavailable in this environment (nobody can be asked).";
29745
+ if (!files.writable) return "This workspace is read-only, so no project can be created in it.";
29746
+ const proposed = (typeof args.name === "string" ? args.name : "").trim() || "Untitled project";
29747
+ const existing = await findProjects(ctx);
29748
+ const createLabel = `Create "${proposed}"`;
29749
+ const options = [
29750
+ { label: createLabel, description: typeof args.reason === "string" && args.reason ? args.reason : "A new project for this work." },
29751
+ ...existing.slice(0, 3).map((p2) => ({ label: p2.name, description: `Put it in the existing project at ${p2.root || "the workspace root"}.` }))
29752
+ ];
29753
+ if (options.length < 2) options.push({ label: "Not now", description: "Write nothing; the document has nowhere to live yet." });
29754
+ const canMove = !!ctx.moveSessionToProject;
29755
+ const questions = [
29756
+ { header: "Project", question: "Documents live in a project. Where should this one go?", options },
29757
+ ...canMove ? [{
29758
+ header: "This chat",
29759
+ question: "Bring this conversation into the project too?",
29760
+ options: [
29761
+ { label: "Yes, move it", description: "The transcript moves with the work, and this chat carries on." },
29762
+ { label: "Keep it loose", description: "The document goes in the project; this chat stays where it is." }
29763
+ ]
29764
+ }] : []
29765
+ ];
29766
+ const answers = await ctx.ask(questions);
29767
+ const picked = answers ? answers.Project?.[0] : void 0;
29768
+ const move = answers ? answers["This chat"]?.[0] === "Yes, move it" : false;
29769
+ if (!picked) return "The person did not choose a project. Do not write the document; say what you were about to write and that it needs a project.";
29770
+ if (picked === "Not now") return "The person declined to start a project. Do not write the document.";
29771
+ const moved = async (root2) => move && ctx.moveSessionToProject ? ` ${await ctx.moveSessionToProject(root2)}` : "";
29772
+ const hit = existing.find((p2) => p2.name === picked);
29773
+ if (hit) return `Use the existing project "${hit.name}". Write the document inside ${hit.root || "the workspace root"}/.${await moved(hit.root)}`;
29774
+ const name = picked === createLabel ? proposed : picked;
29775
+ const root = await freeRoot(ctx, name);
29776
+ const manifest = { name, createdAt: Date.now(), version: 1 };
29777
+ await files.write(`${root}/${manifestSuffix()}`, JSON.stringify(manifest, null, 2));
29778
+ return `Created the project "${name}" at ${root}/. Write the document inside ${root}/ with the ordinary file tools; it will be versioned and shown beside this conversation.${await moved(root)}`;
29779
+ }
29780
+ };
29781
+
29555
29782
  // ../harness/tools/sessions.ts
29556
29783
  function str3(args, key) {
29557
29784
  const v2 = args[key];
@@ -30133,6 +30360,7 @@ var defaultTools = [
30133
30360
  webSearchTool,
30134
30361
  todoWriteTool,
30135
30362
  compilePrompdTool,
30363
+ startProjectTool,
30136
30364
  ...fileTools
30137
30365
  ];
30138
30366
  function allTools() {
@@ -30212,10 +30440,10 @@ Add a constraint :: Add a constraint or rule to the prompt instructions (what to
30212
30440
  `,
30213
30441
  "guards.json": '[\n {\n "_comment": "Declarative client-side feature guards (NON-authoritative \u2014 for defaults/UX; real enforcement is the backend entitlements API). Copy an entry and remove \\"disabled\\". rule: \\"require-auth\\" or \\"free-quota\\" (with \\"limit\\"). feature: a FeatureId like \\"llm-execution\\", or \\"*\\". action: { \\"kind\\": \\"sign-in\\" } or { \\"kind\\": \\"upgrade\\", \\"label\\": \\"...\\" }.",\n "disabled": true,\n "id": "example-free-quota",\n "feature": "llm-execution",\n "rule": "free-quota",\n "limit": 3,\n "reason": "Free limit reached \u2014 sign in to keep going.",\n "action": { "kind": "sign-in" }\n }\n]\n',
30214
30442
  "languages/prmd.md": '{% raw %}\n# PRMD File Format Overview\n\nPRMD files are structured in YAML format and are used to define prompts for various applications within the Prompd system. Each file contains essential metadata in the frontmatter followed by the body content, which is where the prompt logic and structure reside.\n\n## Field Descriptions\n- **id**: A unique identifier for the prompt file, usually in kebab-case.\n- **name**: A brief title for the prompt that identifies it.\n- **version**: The version number of the prompt file, following semantic versioning.\n- **description**: Optional human-readable summary.\n- **model** / **provider**: Optional default model + provider for runs.\n- **inherits**: A path to another prompt file whose body this one extends (the parent renders first, then this file\'s body). Resolved at compile time \u2014 siblings locally, registry refs on the backend.\n- **using**: Optional list of package refs imported for context/blocks; each `{ name: "@scope/pkg@1.2.0", prefix: "@alias" }`.\n- **parameters**: A list of typed input variables for the prompt (see below).\n\n## Parameter Types\nPrompd supports thirteen parameter types covering scalars, dates, structured data, and binary content.\n\n| Type | Description |\n|----------|-----------------------------------------------------------------|\n| string | Plain text |\n| enum | A value restricted to a fixed set of options (see Enums below) |\n| number | Integer or float (generic numeric) |\n| integer | Whole number only \u2014 rejects floats |\n| float | Decimal number |\n| boolean | true / false |\n| date | Calendar date \u2192 `YYYY-MM-DD`. Resolved at compile time. |\n| datetime | Date + time \u2192 `YYYY-MM-DDTHH:mm:ss` (local). Resolved at compile time. |\n| array | JSON array with untyped elements |\n| object | Plain key-value object (non-array) |\n| json | Any JSON value \u2014 arrays of objects, nested structures, etc. |\n| file | File path; caller supplies the file content as a string |\n| base64 | Base64-encoded binary (images, PDFs, blobs for multimodal LLMs) |\n\n### Enums (restricted values)\nAn `enum` parameter limits the value to a fixed set of choices. Provide the allowed values as a JSON array of strings in an `enum:` field, e.g. `["small", "medium", "large"]` (a YAML block list works too). Set `type: enum` \u2014 or omit `type`, since it\'s inferred when `enum` is present. The `default:`, if given, must be one of the options.\n\n```yaml\n- name: tone\n type: enum\n enum: ["detailed", "summary", "concise"]\n default: "summary"\n```\n\n### Date & datetime defaults\n`date` and `datetime` defaults accept `now` / `today`, a relative offset `now\xB1N<unit>`, or a literal ISO date. Units: `d` (days), `w` (weeks), `mo` (months), `y` (years); `datetime` also `h` (hours), `min`, `s`. The value is resolved at compile time, so each run uses the current clock.\n\nExamples (assume today is 2026-06-11):\n\n| Default | Type | Resolves to |\n|----------------|----------|------------------------|\n| `now` | date | `2026-06-11` |\n| `now-8d` | date | `2026-06-03` |\n| `now-2w` | date | `2026-05-28` |\n| `now-1mo` | date | `2026-05-11` |\n| `now+1y` | date | `2027-06-11` |\n| `now` | datetime | `2026-06-11T09:30:00` |\n| `now-3h` | datetime | `2026-06-11T06:30:00` |\n| `2025-01-15` | date | `2025-01-15` |\n\n## Body (Markdown + Jinja2/Nunjucks)\n- `{{ param }}` and `{{ param | filter }}` interpolation.\n- `{% if %}` / `{% elif %}` / `{% else %}` / `{% endif %}` conditionals.\n- `{% for x in list %} \u2026 {% endfor %}` loops.\n- `{% include %}` and inherited blocks resolve against the workspace + registry at compile time.\n- Only declared parameters are guaranteed to exist; treat any other variable as undefined.\n\n## Example PRMD File\n```yaml\n---\nid: report-window\nname: Report Window\nversion: 1.0.0\nparameters:\n - name: user_name\n type: string\n default: "Guest"\n - name: since\n type: date\n default: "now-8d" # 8 days ago, resolved at compile time\n - name: generated_at\n type: datetime\n default: "now"\n---\nHello, {{ user_name }}! Here is your report covering everything since {{ since }} (generated {{ generated_at }}).\n```\n{% endraw %}\n',
30215
- "modes/auto.md": "---\nid: auto\nname: Auto\ndescription: No extra steering; the agent picks its own approach and tools.\n---\nMode: AUTO \u2014 no extra steering; the assistant is free to choose its approach and tools.\n",
30216
- "modes/brainstorm.md": "---\nid: brainstorm\nname: Brainstorm\ndescription: Explore options; generative, makes no edits.\n---\nMode: BRAINSTORM \u2014 explore options and alternatives; be generative, do not edit the file.\n",
30217
- "modes/edit.md": "---\nid: edit\nname: Edit\ndescription: Focus on concrete file changes; prefers propose_edit.\n---\nMode: EDIT \u2014 focus on concrete changes to the file; prefer propose_edit.\n",
30218
- "modes/plan.md": "---\nid: plan\nname: Plan\ndescription: Think and outline before acting; avoids mutating tools.\n---\nMode: PLAN \u2014 think step by step and outline an approach before acting; avoid mutating tools.\n",
30443
+ "modes/auto.md": "---\nid: auto\nname: Auto\ndescription: Act on the request, deciding what is cheap to decide and asking only what is expensive to get wrong.\n---\nMode: AUTO \u2014 act.\n\nMake the smallest set of decisions needed to start, say plainly what you assumed, and build a thin working slice before elaborating it. A broad request is not a reason to stop and interview the user.\n\nAsk only where a wrong guess is expensive to undo \u2014 the data model, anything touching security, privacy or regulated data, and where the thing runs. Keep it to two or three questions and ask them before you build, not after. Everything that is cheap to change later is an assumption to state, not a question to ask.\n",
30444
+ "modes/brainstorm.md": "---\nid: brainstorm\nname: Brainstorm\ndescription: Generate genuinely different options with their trade-offs, and recommend one.\n---\nMode: BRAINSTORM \u2014 explore options and alternatives; be generative.\n\nGive at least two genuinely different approaches, not one approach with its dial turned. Name what each is good at and what it costs, then say which you would pick and why. Do not edit files.\n",
30445
+ "modes/edit.md": "---\nid: edit\nname: Edit\ndescription: Make the smallest concrete change that does the job.\n---\nMode: EDIT \u2014 make the smallest concrete change that does the job, and say what you changed.\n\nWhen a file is open in the editor, prefer propose_edit with the COMPLETE new file content, so the user reviews a diff before anything is applied.\n",
30446
+ "modes/plan.md": "---\nid: plan\nname: Plan\ndescription: Settle the shape-changing decisions first, then outline the approach without mutating anything.\n---\nMode: PLAN \u2014 decide before acting.\n\nFirst surface the decisions that change the SHAPE of the result and ask about those; an outline built on an unasked question is wasted work. Then think step by step and lay out the approach, with the trade-offs you are choosing between made explicit.\n\nAvoid mutating tools. Producing a plan the user can argue with is the deliverable here.\n",
30219
30447
  "crew/memory.md": '---\nid: crew-memory\nname: Crew agent \u2014 memory block\nversion: 1.0.0\ndescription: Told to every crew agent about its own memory folder. Appended after the agent\'s own role and instructions, because it is housekeeping and they are the job.\nparameters:\n - name: dir\n type: string\n description: The agent\'s memory folder, with no trailing slash (e.g. crew/auditor/memory).\n default: "memory"\n - name: index\n type: string\n description: Full path of the agent\'s memory index, derived from dir (e.g. crew/auditor/memory/MEMORY.md).\n default: "memory/MEMORY.md"\n---\n--- Memory ---\nYou keep durable notes in your own memory folder, `{{ dir }}/`, one Markdown file per note, alongside `{{ index }}`, an index you maintain for yourself.\nRead `{{ index }}` at the start of a run to recall what you already know, and call recall_memory to look up one note by name. If you can call save_memory, use it for what is worth knowing on a later run, and keep `{{ index }}` current as you do. If a file tool you have takes a `root` argument, you can pass `{{ dir }}/` as `root` to read and edit those files directly.\n',
30220
30448
  "crew/room.md": `---
30221
30449
  id: crew-room
@@ -30350,7 +30578,7 @@ You are a privacy reviewer. Determine whether the INPUT exposes personally ident
30350
30578
  id: system-base
30351
30579
  name: Operational system prompt
30352
30580
  version: 1.0.0
30353
- description: The agent's system prompt \u2014 persona, mode, tool definitions and editor context composed by @prompd/core.
30581
+ description: The agent's system prompt \u2014 surface-agnostic identity, mode, tool definitions and context composed by @prompd/core. Editor and workflow specifics live in the prompts that inherit this one.
30354
30582
  parameters:
30355
30583
  - name: persona
30356
30584
  type: string
@@ -30383,9 +30611,11 @@ parameters:
30383
30611
  ---
30384
30612
  {% if persona %}{{ persona }}
30385
30613
 
30386
- {% endif %}You are the {{ app }} assistant embedded in the in-browser .prmd editor. You help the user author, compile, and improve .prmd/.md prompts (YAML frontmatter + Nunjucks body with typed parameters). When the user asks about the .prmd format itself \u2014 frontmatter fields, parameter types (including enums), date defaults, inheritance, or templating \u2014 the canonical reference is \`languages/prmd.md\` in the {{ app }} config workspace; consult it rather than guessing (it is already included in context whenever a .prmd/.md file is open).
30614
+ {% endif %}You are the {{ app }} assistant. You help the user do real work in {{ app }}: reading and changing files, composing and running prompts and workflows, and building software. What is in front of you differs by surface \u2014 an open file, a workflow canvas, a workspace, or nothing but this conversation \u2014 so work from the context you were actually given rather than assuming a particular screen.
30615
+
30616
+ To read or change files in the workspace, use the file tools; writes ask the user for permission first. Use compile_prompd to preview rendered output and search_packages to find registry packages. For any multi-step job, maintain a visible task list with todo_write: lay out the steps, keep one task in_progress, and mark each completed as you go. Prefer calling a tool over guessing, and be concise.
30387
30617
 
30388
- To change the file open in the editor, call propose_edit with the COMPLETE new file content \u2014 the user reviews a diff and applies it. To read or change other files in the workspace, use the file tools (writes ask the user for permission first). Use compile_prompd to preview rendered output and search_packages to find registry packages. For any multi-step job, maintain a visible task list with todo_write: lay out the steps, keep one task in_progress, and mark each completed as you go. Prefer calling a tool over guessing, and be concise.
30618
+ Say what you assumed and what you did not do. A request that leaves room for interpretation gets its decisions named, one line each, so the user can correct one without unpicking the rest. How much you decide for yourself versus ask about is the mode's call, below.
30389
30619
 
30390
30620
  ## Tools available this run
30391
30621
  {{ tools }}
@@ -32577,6 +32807,8 @@ function makeCrewTurnRuntime(opts) {
32577
32807
  policy,
32578
32808
  permission,
32579
32809
  ...deps.maxTurns !== void 0 ? { maxTurns: deps.maxTurns } : {},
32810
+ ...deps.compression ? { compression: deps.compression } : {},
32811
+ ...deps.compaction ? { compaction: deps.compaction } : {},
32580
32812
  ...deps.temperature !== void 0 ? { temperature: deps.temperature } : {},
32581
32813
  ...deps.describePermissionDenial ? { describePermissionDenial: deps.describePermissionDenial } : {},
32582
32814
  signal: mergeSignals(deps.toolCtx.signal, turnAbort.signal),
@@ -32592,7 +32824,7 @@ function makeCrewTurnRuntime(opts) {
32592
32824
  deps.onEvent?.(e2);
32593
32825
  }
32594
32826
  });
32595
- const produced = result.messages.slice(input.transcript.length);
32827
+ const produced = result.messages.slice(result.messages.length - result.appended);
32596
32828
  const inputTokens = result.usage.promptTokens ?? 0;
32597
32829
  const outputTokens = result.usage.completionTokens ?? 0;
32598
32830
  const ceilingAborted = turnAbort.signal.aborted;
@@ -33465,7 +33697,7 @@ var LineSplitter = class {
33465
33697
  init_fileService();
33466
33698
 
33467
33699
  // src/session/msb.ts
33468
- import { spawn as spawn3 } from "node:child_process";
33700
+ import { spawn as spawn4 } from "node:child_process";
33469
33701
  var DEFAULT_LIMITS = {
33470
33702
  cpus: 1,
33471
33703
  memory: "512M",
@@ -33512,7 +33744,7 @@ function wslHostPath(p2) {
33512
33744
  function makeMsbStreamer(bin, wrap) {
33513
33745
  return (args, onLine, opts) => {
33514
33746
  const { cmd, argv } = wrap ? wrap(args) : { cmd: bin, argv: args };
33515
- const child = spawn3(cmd, argv, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
33747
+ const child = spawn4(cmd, argv, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
33516
33748
  const out = new LineSplitter();
33517
33749
  const err = new LineSplitter();
33518
33750
  let stderrText = "";
@@ -33563,7 +33795,7 @@ function wslMsb(bin) {
33563
33795
  function makeMsbRunner(bin, wrap) {
33564
33796
  return (args, opts) => new Promise((resolveRun) => {
33565
33797
  const { cmd, argv } = wrap ? wrap(args) : { cmd: bin, argv: args };
33566
- const child = spawn3(cmd, argv, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
33798
+ const child = spawn4(cmd, argv, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
33567
33799
  let stdout = "";
33568
33800
  let stderr = "";
33569
33801
  let settled = false;
@@ -34304,17 +34536,17 @@ function makeGuestRunner(deps) {
34304
34536
  }
34305
34537
 
34306
34538
  // src/session/sessionsPort.ts
34307
- function toInfo(h2, backend) {
34539
+ function toInfo(h2, backend2) {
34308
34540
  return {
34309
34541
  id: h2.id,
34310
34542
  status: h2.status,
34311
34543
  workdir: h2.workdir,
34312
34544
  createdAt: h2.createdAt,
34313
34545
  ...h2.owner ? { owner: h2.owner } : {},
34314
- isolated: backend.isolation !== "none",
34315
- kind: backend.kind,
34546
+ isolated: backend2.isolation !== "none",
34547
+ kind: backend2.kind,
34316
34548
  // Feature-detected once, here, rather than by a client calling and failing.
34317
- canCapture: !!backend.commit,
34549
+ canCapture: !!backend2.commit,
34318
34550
  // Read from the HANDLE, so it survives every later list() — and, on the
34319
34551
  // microVM backend, a daemon restart, since the sandbox carries it in its
34320
34552
  // own labels.
@@ -34325,9 +34557,9 @@ function toInfo(h2, backend) {
34325
34557
  };
34326
34558
  }
34327
34559
  function makeSessionsPort(deps) {
34328
- const { manager, backend } = deps;
34560
+ const { manager, backend: backend2 } = deps;
34329
34561
  return {
34330
- list: () => manager.list().map((h2) => toInfo(h2, backend)),
34562
+ list: () => manager.list().map((h2) => toInfo(h2, backend2)),
34331
34563
  async create(spec) {
34332
34564
  let source;
34333
34565
  let workdir = spec.workdir;
@@ -34361,7 +34593,7 @@ function makeSessionsPort(deps) {
34361
34593
  ...spec.stateDir ? { stateDir: spec.stateDir } : {},
34362
34594
  ...env && Object.keys(env).length ? { env } : {}
34363
34595
  });
34364
- return { ...toInfo(h2, backend), ...source ? { source } : {} };
34596
+ return { ...toInfo(h2, backend2), ...source ? { source } : {} };
34365
34597
  },
34366
34598
  canClone: deps.git?.enabled === true && deps.approveCheckout !== void 0,
34367
34599
  /* Straight through to the manager, which is where admission and the
@@ -34379,9 +34611,9 @@ function makeSessionsPort(deps) {
34379
34611
  * client feature-detects by asking rather than by calling and failing. A
34380
34612
  * process-backed session genuinely cannot capture or stream, and saying so
34381
34613
  * up front is better than an error at the moment someone tries. */
34382
- ...backend.commit ? {
34614
+ ...backend2.commit ? {
34383
34615
  commit: async (id, name) => {
34384
- const snap = await backend.commit(id, name);
34616
+ const snap = await backend2.commit(id, name);
34385
34617
  return {
34386
34618
  name: snap.name,
34387
34619
  createdAt: snap.createdAt,
@@ -34389,26 +34621,26 @@ function makeSessionsPort(deps) {
34389
34621
  };
34390
34622
  }
34391
34623
  } : {},
34392
- ...backend.snapshots ? {
34393
- snapshots: async () => (await backend.snapshots()).map((s2) => ({
34624
+ ...backend2.snapshots ? {
34625
+ snapshots: async () => (await backend2.snapshots()).map((s2) => ({
34394
34626
  name: s2.name,
34395
34627
  createdAt: s2.createdAt,
34396
34628
  ...s2.sessionId ? { sessionId: s2.sessionId } : {},
34397
34629
  ...s2.sizeBytes !== void 0 ? { sizeBytes: s2.sizeBytes } : {}
34398
34630
  }))
34399
34631
  } : {},
34400
- ...backend.snapshotUsage ? {
34401
- snapshotUsage: () => backend.snapshotUsage()
34632
+ ...backend2.snapshotUsage ? {
34633
+ snapshotUsage: () => backend2.snapshotUsage()
34402
34634
  } : {},
34403
- ...backend.metrics ? {
34635
+ ...backend2.metrics ? {
34404
34636
  watchMetrics: (id, onSample) => {
34405
- const st2 = backend.metrics(id, onSample);
34637
+ const st2 = backend2.metrics(id, onSample);
34406
34638
  return () => st2.stop();
34407
34639
  }
34408
34640
  } : {},
34409
- ...backend.logs ? {
34641
+ ...backend2.logs ? {
34410
34642
  watchLogs: (id, onLine) => {
34411
- const st2 = backend.logs(id, onLine);
34643
+ const st2 = backend2.logs(id, onLine);
34412
34644
  return () => st2.stop();
34413
34645
  }
34414
34646
  } : {}
@@ -34431,16 +34663,16 @@ function portExtras(opts) {
34431
34663
  }
34432
34664
  async function wireSessions(opts) {
34433
34665
  if (opts.isolation === "none") {
34434
- const backend2 = makeProcessBackend({ roots: opts.roots, ...opts.log ? { log: opts.log } : {} });
34666
+ const backend3 = makeProcessBackend({ roots: opts.roots, ...opts.log ? { log: opts.log } : {} });
34435
34667
  opts.log?.("sessions: isolation none (runs execute in the daemon process)");
34436
34668
  const manager2 = new SessionManager({
34437
- backend: backend2,
34669
+ backend: backend3,
34438
34670
  runner: opts.runner,
34439
34671
  ...opts.recordRun ? { recordRun: opts.recordRun } : {},
34440
34672
  ...opts.recordTranscript ? { recordTranscript: opts.recordTranscript } : {},
34441
34673
  ...opts.log ? { log: opts.log } : {}
34442
34674
  });
34443
- return { manager: manager2, backend: backend2, port: makeSessionsPort({ manager: manager2, backend: backend2, guestEnv, ...portExtras(opts) }) };
34675
+ return { manager: manager2, backend: backend3, port: makeSessionsPort({ manager: manager2, backend: backend3, guestEnv, ...portExtras(opts) }) };
34444
34676
  }
34445
34677
  const viaWsl = process.platform === "win32";
34446
34678
  const makeRunner = opts.makeRunner ?? ((bin) => makeMsbRunner(bin, viaWsl ? wslMsb(bin) : void 0));
@@ -34453,7 +34685,7 @@ async function wireSessions(opts) {
34453
34685
  On Windows msb runs inside WSL2 \u2014 there is no native build \u2014 so the daemon must run there too.`
34454
34686
  );
34455
34687
  }
34456
- const backend = makeMicrovmBackend({
34688
+ const backend2 = makeMicrovmBackend({
34457
34689
  roots: opts.roots,
34458
34690
  run,
34459
34691
  stream: makeMsbStreamer(opts.msbBin, viaWsl ? wslMsb(opts.msbBin) : void 0),
@@ -34464,14 +34696,14 @@ async function wireSessions(opts) {
34464
34696
  ...opts.log ? { log: opts.log } : {}
34465
34697
  });
34466
34698
  const manager = new SessionManager({
34467
- backend,
34699
+ backend: backend2,
34468
34700
  runner: opts.runner,
34469
34701
  ...opts.recordRun ? { recordRun: opts.recordRun } : {},
34470
34702
  ...opts.recordTranscript ? { recordTranscript: opts.recordTranscript } : {},
34471
34703
  // One runner per session, built on demand: the manager knows which session a
34472
34704
  // run belongs to, so nothing has to infer it.
34473
34705
  guestRunnerFor: (sessionId, guestOpts) => makeGuestRunner({
34474
- backend,
34706
+ backend: backend2,
34475
34707
  sessionId: () => sessionId,
34476
34708
  allowMutations: guestOpts.allowMutations,
34477
34709
  ...opts.log ? { log: opts.log } : {}
@@ -34479,19 +34711,19 @@ async function wireSessions(opts) {
34479
34711
  ...opts.log ? { log: opts.log } : {}
34480
34712
  });
34481
34713
  opts.log?.(`sessions: isolation microvm via ${version} (image ${opts.image})`);
34482
- await backend.adopt?.().catch((e2) => {
34714
+ await backend2.adopt?.().catch((e2) => {
34483
34715
  opts.log?.(`sessions: adoption failed (${e2 instanceof Error ? e2.message : String(e2)}) \u2014 starting with none adopted`);
34484
34716
  });
34485
- return { manager, backend, port: makeSessionsPort({ manager, backend, guestEnv, ...portExtras(opts) }) };
34717
+ return { manager, backend: backend2, port: makeSessionsPort({ manager, backend: backend2, guestEnv, ...portExtras(opts) }) };
34486
34718
  }
34487
34719
 
34488
34720
  // src/runner/crewSessions.ts
34489
34721
  function makeCrewSessions(src2, opts) {
34490
34722
  if (!src2?.manager.isolated) return void 0;
34491
- const { manager, backend } = src2;
34723
+ const { manager, backend: backend2 } = src2;
34492
34724
  return {
34493
- list: () => backend.list(),
34494
- adopt: () => backend.adopt?.() ?? Promise.resolve(void 0),
34725
+ list: () => backend2.list(),
34726
+ adopt: () => backend2.adopt?.() ?? Promise.resolve(void 0),
34495
34727
  create: async (spec) => {
34496
34728
  const env = await opts.guestEnv().catch(() => ({}));
34497
34729
  return manager.create({
@@ -34507,15 +34739,15 @@ function makeCrewSessions(src2, opts) {
34507
34739
 
34508
34740
  // src/runner/owners.ts
34509
34741
  init_tools_node();
34510
- import { readdir, readFile } from "node:fs/promises";
34742
+ import { readdir, readFile as readFile2 } from "node:fs/promises";
34511
34743
  import { createHash as createHash2 } from "node:crypto";
34512
- import { join as join8, resolve as resolve6 } from "node:path";
34744
+ import { join as join9, resolve as resolve6 } from "node:path";
34513
34745
  init_protocol();
34514
34746
 
34515
34747
  // src/hostsDir.ts
34516
34748
  init_tools_node();
34517
34749
  init_protocol();
34518
- import { join as join7 } from "node:path";
34750
+ import { join as join8 } from "node:path";
34519
34751
  var HOSTS_SEG = "hosts";
34520
34752
  var MAX_SYNC_PATH_LEN = 400;
34521
34753
  var MAX_SYNC_SEGMENTS = 24;
@@ -34537,11 +34769,11 @@ function assertSyncPath(path) {
34537
34769
  }
34538
34770
  }
34539
34771
  function hostsRoot(stateDir) {
34540
- return join7(stateDir, "sidecar", HOSTS_SEG);
34772
+ return join8(stateDir, "sidecar", HOSTS_SEG);
34541
34773
  }
34542
34774
  function hostsDirFor(stateDir, dirName2) {
34543
34775
  if (!DIR_NAME_RE.test(dirName2)) throw new Error(`invalid host dir name: ${dirName2}`);
34544
- return join7(hostsRoot(stateDir), dirName2);
34776
+ return join8(hostsRoot(stateDir), dirName2);
34545
34777
  }
34546
34778
  var RAW = /* @__PURE__ */ new WeakMap();
34547
34779
  function hostFilesFor(stateDir, dirName2) {
@@ -34581,7 +34813,7 @@ var WORKSPACE_ID_RE = /^[a-z0-9_-]{1,32}$/;
34581
34813
  async function workspaceRootsFor(dirName2, hostDir) {
34582
34814
  let entries;
34583
34815
  try {
34584
- entries = await readdir(join8(hostDir, WORKSPACES_SEG), { withFileTypes: true });
34816
+ entries = await readdir(join9(hostDir, WORKSPACES_SEG), { withFileTypes: true });
34585
34817
  } catch {
34586
34818
  return [];
34587
34819
  }
@@ -34589,7 +34821,7 @@ async function workspaceRootsFor(dirName2, hostDir) {
34589
34821
  for (const entry of entries) {
34590
34822
  if (!entry.isDirectory()) continue;
34591
34823
  if (!WORKSPACE_ID_RE.test(entry.name)) continue;
34592
- const wsDir = join8(hostDir, WORKSPACES_SEG, entry.name);
34824
+ const wsDir = join9(hostDir, WORKSPACES_SEG, entry.name);
34593
34825
  out.push({
34594
34826
  owner: `${dirName2}/ws/${entry.name}`,
34595
34827
  files: new NodeFileService(wsDir),
@@ -34602,7 +34834,7 @@ async function workspaceRootsFor(dirName2, hostDir) {
34602
34834
  async function readCrewOptIn(root, dotDir = stateDirName()) {
34603
34835
  let raw;
34604
34836
  try {
34605
- raw = await readFile(join8(root, dotDir, CREW_OPT_IN_FILE), "utf8");
34837
+ raw = await readFile2(join9(root, dotDir, CREW_OPT_IN_FILE), "utf8");
34606
34838
  } catch {
34607
34839
  return null;
34608
34840
  }
@@ -34710,8 +34942,8 @@ async function listOwnerRoots(stateDir, workRoots = [], dotDir = stateDirName())
34710
34942
  }
34711
34943
 
34712
34944
  // src/crewDeployment.ts
34713
- import { readFile as readFile2 } from "node:fs/promises";
34714
- import { join as join9 } from "node:path";
34945
+ import { readFile as readFile3 } from "node:fs/promises";
34946
+ import { join as join10 } from "node:path";
34715
34947
  function splitOwner(owner) {
34716
34948
  if (owner === "") return void 0;
34717
34949
  const parts = owner.split("/");
@@ -34724,7 +34956,7 @@ async function deployedRoot(stateDir, owner, agent) {
34724
34956
  if (!parts) return void 0;
34725
34957
  let text;
34726
34958
  try {
34727
- text = await readFile2(join9(hostsDirFor(stateDir, parts.dirName), DEPLOYMENT_FILE), "utf8");
34959
+ text = await readFile3(join10(hostsDirFor(stateDir, parts.dirName), DEPLOYMENT_FILE), "utf8");
34728
34960
  } catch {
34729
34961
  return void 0;
34730
34962
  }
@@ -34737,7 +34969,7 @@ init_tools_node();
34737
34969
  // src/cert.ts
34738
34970
  import { promises as fs9 } from "node:fs";
34739
34971
  import { networkInterfaces as networkInterfaces2 } from "node:os";
34740
- import { join as join10 } from "node:path";
34972
+ import { join as join11 } from "node:path";
34741
34973
  import selfsigned from "selfsigned";
34742
34974
  function lanIPv4s() {
34743
34975
  const out = [];
@@ -34748,9 +34980,9 @@ function lanIPv4s() {
34748
34980
  }
34749
34981
  return [...new Set(out)];
34750
34982
  }
34751
- var certPath = () => join10(sidecarSubdir(), "sidecar.crt");
34752
- var keyPath = () => join10(sidecarSubdir(), "sidecar.key");
34753
- var sansPath = () => join10(sidecarSubdir(), "sidecar.sans.json");
34983
+ var certPath = () => join11(sidecarSubdir(), "sidecar.crt");
34984
+ var keyPath = () => join11(sidecarSubdir(), "sidecar.key");
34985
+ var sansPath = () => join11(sidecarSubdir(), "sidecar.sans.json");
34754
34986
  function selfSignedPaths() {
34755
34987
  return { cert: certPath(), key: keyPath() };
34756
34988
  }
@@ -34822,7 +35054,7 @@ function tunnelIds() {
34822
35054
  }
34823
35055
 
34824
35056
  // src/tunnel/zrok.ts
34825
- import { spawn as spawn4 } from "node:child_process";
35057
+ import { spawn as spawn5 } from "node:child_process";
34826
35058
  function extractShareUrl(s2) {
34827
35059
  const full = s2.match(/https?:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[^\s"'\\]*)?/);
34828
35060
  if (full) return full[0];
@@ -34868,7 +35100,7 @@ function stopChild(child) {
34868
35100
  }
34869
35101
  function reserveName(bin, name) {
34870
35102
  return new Promise((res, rej) => {
34871
- const child = spawn4(bin, ["create", "name", name], { stdio: ["ignore", "ignore", "ignore"] });
35103
+ const child = spawn5(bin, ["create", "name", name], { stdio: ["ignore", "ignore", "ignore"] });
34872
35104
  let settled = false;
34873
35105
  const done = (fn) => {
34874
35106
  if (settled) return;
@@ -34890,7 +35122,7 @@ function reserveName(bin, name) {
34890
35122
  async function startWith(bin, port, name) {
34891
35123
  if (name) await reserveName(bin, name);
34892
35124
  return new Promise((resolveHandle, reject) => {
34893
- const child = spawn4(bin, shareArgs(port, name), { stdio: ["ignore", "pipe", "pipe"] });
35125
+ const child = spawn5(bin, shareArgs(port, name), { stdio: ["ignore", "pipe", "pipe"] });
34894
35126
  let out = "";
34895
35127
  let settled = false;
34896
35128
  const finish = (fn) => {
@@ -34939,7 +35171,7 @@ var zrokProvider = {
34939
35171
  };
34940
35172
 
34941
35173
  // src/tunnel/cloudflared.ts
34942
- import { spawn as spawn5 } from "node:child_process";
35174
+ import { spawn as spawn6 } from "node:child_process";
34943
35175
  function extractTunnelUrl(s2) {
34944
35176
  const m2 = s2.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
34945
35177
  return m2 ? m2[0] : null;
@@ -34993,7 +35225,7 @@ function stopChild2(child) {
34993
35225
  }
34994
35226
  function startWith2(bin, mode, log, onDown) {
34995
35227
  return new Promise((resolveHandle, reject) => {
34996
- const child = spawn5(bin, mode.argv, { stdio: ["ignore", "pipe", "pipe"] });
35228
+ const child = spawn6(bin, mode.argv, { stdio: ["ignore", "pipe", "pipe"] });
34997
35229
  let out = "";
34998
35230
  let settled = false;
34999
35231
  let tailBuf = "";
@@ -35162,7 +35394,7 @@ async function superviseTunnel(opts) {
35162
35394
  // src/runner/runnerService.ts
35163
35395
  init_protocol();
35164
35396
  init_tools_node();
35165
- import { join as join11 } from "node:path";
35397
+ import { join as join12 } from "node:path";
35166
35398
 
35167
35399
  // src/runner/location.ts
35168
35400
  init_tools_node();
@@ -41921,7 +42153,7 @@ function nodeGatewayClient(endpoint, stateDir) {
41921
42153
  }
41922
42154
 
41923
42155
  // src/runner/executor.ts
41924
- import { mkdir } from "node:fs/promises";
42156
+ import { mkdir as mkdir2 } from "node:fs/promises";
41925
42157
 
41926
42158
  // ../harness/core/recovery.ts
41927
42159
  var DEFAULT_RECOVERY = {
@@ -42135,6 +42367,93 @@ function formatOffloadRef(ref) {
42135
42367
  ${ref.preview}${ref.bytes > ref.preview.length ? "\n\u2026(truncated)" : ""}` : head;
42136
42368
  }
42137
42369
 
42370
+ // ../harness/core/compactHistory.ts
42371
+ var CHECKPOINT_MARKER = "[checkpoint] ";
42372
+ var ANCHOR_MARKER = "[anchor] ";
42373
+ var COMPACT_AT_FILL = 0.86;
42374
+ function shouldCompact(fill, messages) {
42375
+ if (fill < COMPACT_AT_FILL) return false;
42376
+ return messages.some((m2) => m2.role !== "system" && m2.role !== "user");
42377
+ }
42378
+ var REARM_BELOW_FILL = 0.6;
42379
+ var REARM_ON_REGROWTH = 0.1;
42380
+ function afterCompaction() {
42381
+ return { armed: false, floor: null };
42382
+ }
42383
+ function messageChars(m2) {
42384
+ let n2 = 0;
42385
+ const content = m2.content;
42386
+ if (typeof content === "string") n2 += content.length;
42387
+ else if (Array.isArray(content)) {
42388
+ for (const part of content) {
42389
+ const text = part.text;
42390
+ if (typeof text === "string") n2 += text.length;
42391
+ }
42392
+ }
42393
+ const calls = m2.tool_calls;
42394
+ if (Array.isArray(calls)) {
42395
+ for (const c2 of calls) n2 += (c2.function?.name?.length ?? 0) + (c2.function?.arguments?.length ?? 0);
42396
+ }
42397
+ return n2;
42398
+ }
42399
+ function estimateFill(messages, contextWindow) {
42400
+ if (!contextWindow || contextWindow <= 0) return 0;
42401
+ let chars = 0;
42402
+ for (const m2 of messages) chars += messageChars(m2);
42403
+ return Math.min(1, chars / 4 / contextWindow);
42404
+ }
42405
+ function compactionDecision(fill, messages, gate) {
42406
+ if (!gate.armed) {
42407
+ const drained = fill < REARM_BELOW_FILL;
42408
+ const regrown = gate.floor !== null && fill >= gate.floor + REARM_ON_REGROWTH;
42409
+ return { compact: false, next: { ...gate, armed: drained || regrown } };
42410
+ }
42411
+ if (!shouldCompact(fill, messages)) return { compact: false, next: gate };
42412
+ return { compact: true, next: afterCompaction() };
42413
+ }
42414
+ async function compactHistory(messages, opts) {
42415
+ const tail3 = messages.length ? messages[messages.length - 1] : void 0;
42416
+ const pending = tail3 && isUnansweredToolCall(tail3) ? tail3 : null;
42417
+ const body2 = pending ? messages.slice(0, -1) : messages;
42418
+ const system = body2.filter((m2) => m2.role === "system");
42419
+ const collapsed = body2.filter((m2) => m2.role !== "system" && m2.role !== "user");
42420
+ const kept = [];
42421
+ let answered = null;
42422
+ for (const m2 of body2) {
42423
+ if (isOwnMarker(m2)) continue;
42424
+ if (m2.role === "assistant" && typeof m2.content === "string") answered = m2;
42425
+ if (m2.role !== "user") continue;
42426
+ if (opts.anchor) {
42427
+ kept.push({ role: "assistant", content: `${ANCHOR_MARKER}${await opts.anchor(m2, answered)}` });
42428
+ }
42429
+ kept.push(await offloadIfOversized(m2, opts));
42430
+ }
42431
+ const summary = await opts.summarize(collapsed);
42432
+ return {
42433
+ messages: [
42434
+ ...system,
42435
+ { role: "assistant", content: `${CHECKPOINT_MARKER}${summary}` },
42436
+ ...kept,
42437
+ ...pending ? [pending] : []
42438
+ ],
42439
+ checkpoint: summary
42440
+ };
42441
+ }
42442
+ async function offloadIfOversized(m2, opts) {
42443
+ const limit2 = opts.offloadOverBytes;
42444
+ if (!opts.offload || limit2 === void 0) return m2;
42445
+ if (typeof m2.content !== "string" || m2.content.length <= limit2) return m2;
42446
+ return { ...m2, content: await opts.offload(m2.content) };
42447
+ }
42448
+ var isCompactionMarker = isOwnMarker;
42449
+ function isOwnMarker(m2) {
42450
+ if (m2.role !== "assistant" || typeof m2.content !== "string") return false;
42451
+ return m2.content.startsWith(ANCHOR_MARKER) || m2.content.startsWith(CHECKPOINT_MARKER);
42452
+ }
42453
+ function isUnansweredToolCall(m2) {
42454
+ return m2.role === "assistant" && Array.isArray(m2.tool_calls) && m2.tool_calls.length > 0;
42455
+ }
42456
+
42138
42457
  // ../harness/prompts/toolsMarker.ts
42139
42458
  var TOOLS_MARKER = "\uE000PROMPD_TOOLS\uE000";
42140
42459
  function formatToolLines(tools) {
@@ -42255,6 +42574,7 @@ async function runAgent(args) {
42255
42574
  const ctxWindow = compression?.contextWindow;
42256
42575
  const compressionStrategy = compression?.strategy ?? "eager";
42257
42576
  let fill = 0;
42577
+ let compactGate = { armed: true, floor: null };
42258
42578
  const deferredOffloads = [];
42259
42579
  const maxTurns = policy.maxTurns ?? args.maxTurns ?? 8;
42260
42580
  const signal = args.signal ?? new AbortController().signal;
@@ -42272,6 +42592,16 @@ async function runAgent(args) {
42272
42592
  turnAc?.abort();
42273
42593
  });
42274
42594
  const messages = [...args.messages];
42595
+ const incoming = new Set(args.messages);
42596
+ const appendedFromTail = () => {
42597
+ let n2 = 0;
42598
+ for (let i2 = messages.length - 1; i2 >= 0; i2--) {
42599
+ const m2 = messages[i2];
42600
+ if (incoming.has(m2) || m2.role === "system" || isCompactionMarker(m2)) break;
42601
+ n2++;
42602
+ }
42603
+ return n2;
42604
+ };
42275
42605
  const deferred = args.deferredTools ?? [];
42276
42606
  const loader = deferred.length ? createToolLoader({ deferred, loaded: args.loadedTools, onChange: (names) => emit({ type: "tools-loaded", names }) }) : null;
42277
42607
  const eager = loader ? [...tools, loader.searchTool] : tools;
@@ -42313,6 +42643,31 @@ ${serverIndex(deferred)}` : serverIndex(deferred) : system;
42313
42643
  emit({ type: "injected", content: typeof m2.content === "string" ? m2.content : "" });
42314
42644
  }
42315
42645
  }
42646
+ const gateFill = fill || estimateFill(messages, ctxWindow);
42647
+ const gate = compactionDecision(gateFill, messages, compactGate);
42648
+ compactGate = gate.next;
42649
+ if (args.compaction && gate.compact) {
42650
+ const before = messages.length;
42651
+ try {
42652
+ const out = await compactHistory(messages, {
42653
+ summarize: args.compaction.summarize,
42654
+ anchor: args.compaction.anchor,
42655
+ offloadOverBytes: compression && compression.mode !== "off" ? effectiveThreshold(compression.mode, gateFill) : void 0,
42656
+ offload: toolCtx.recall ? async (content) => formatOffloadRef(await toolCtx.recall.record("user", content)) : void 0
42657
+ });
42658
+ messages.splice(0, messages.length, ...out.messages);
42659
+ deferredOffloads.length = 0;
42660
+ try {
42661
+ await args.compaction.onCheckpoint?.(out.checkpoint);
42662
+ } catch (e2) {
42663
+ alog("checkpoint persist failed", e2 instanceof Error ? e2.message : String(e2));
42664
+ }
42665
+ emit({ type: "checkpoint", collapsed: before - messages.length, checkpoint: out.checkpoint });
42666
+ } catch (e2) {
42667
+ compactGate = { ...compactGate, armed: true };
42668
+ alog("compaction failed", e2 instanceof Error ? e2.message : String(e2));
42669
+ }
42670
+ }
42316
42671
  gstate.turn = turn - 1;
42317
42672
  const turnGate = evaluateTurnStart(policy, gstate);
42318
42673
  if (!turnGate.ok) {
@@ -42407,6 +42762,7 @@ ${serverIndex(deferred)}` : serverIndex(deferred) : system;
42407
42762
  usage.completionTokens += streamed.usage.completionTokens;
42408
42763
  if (ctxWindow && ctxWindow > 0 && streamed.usage.promptTokens) {
42409
42764
  fill = Math.min(1, streamed.usage.promptTokens / ctxWindow);
42765
+ if (!compactGate.armed && compactGate.floor === null) compactGate = { armed: false, floor: fill };
42410
42766
  }
42411
42767
  emit({ type: "usage", usage: streamed.usage });
42412
42768
  const assistantMsg = streamed.toolCalls.length ? {
@@ -42455,13 +42811,13 @@ ${serverIndex(deferred)}` : serverIndex(deferred) : system;
42455
42811
  }
42456
42812
  loader?.touch(tool.name);
42457
42813
  const address = toolAddress(tool);
42458
- const gate = evaluateToolCall(policy, gstate, { name: call.name, exists: true, address });
42459
- if (!gate.ok) {
42460
- const msg = `Blocked by policy "${policy.name}": ${gate.violation.message}`;
42814
+ const gate2 = evaluateToolCall(policy, gstate, { name: call.name, exists: true, address });
42815
+ if (!gate2.ok) {
42816
+ const msg = `Blocked by policy "${policy.name}": ${gate2.violation.message}`;
42461
42817
  messages.push({ role: "tool", tool_call_id: call.id, content: `Error: ${msg}` });
42462
- emit({ type: "policy-violation", kind: gate.violation.kind, message: gate.violation.message, action: gate.action, tool: call.name });
42818
+ emit({ type: "policy-violation", kind: gate2.violation.kind, message: gate2.violation.message, action: gate2.action, tool: call.name });
42463
42819
  emit({ type: "tool-result", id: call.id, name: call.name, result: msg, isError: true });
42464
- if (gate.action === "stop") {
42820
+ if (gate2.action === "stop") {
42465
42821
  policyStop = true;
42466
42822
  break;
42467
42823
  }
@@ -42544,7 +42900,7 @@ ${serverIndex(deferred)}` : serverIndex(deferred) : system;
42544
42900
  }
42545
42901
  alog("done", stopReason, "tokens", usage);
42546
42902
  emit({ type: "done", reason: stopReason });
42547
- return { messages, text, usage, stopReason };
42903
+ return { messages, text, usage, stopReason, appended: appendedFromTail() };
42548
42904
  }
42549
42905
  async function compactIncomingOffloads(messages, toolByName, compression, recall, emit) {
42550
42906
  const nameForCallId = /* @__PURE__ */ new Map();
@@ -42630,8 +42986,29 @@ var JsonlSessionStore = class _JsonlSessionStore {
42630
42986
  if (!f2) throw new Error("No file workspace is connected \u2014 sessions are unavailable.");
42631
42987
  return f2;
42632
42988
  }
42989
+ /* Sessions that have been MOVED out from under this store's root, id -> the
42990
+ * root they now live in.
42991
+ *
42992
+ * A session can move mid-run: start_project offers to bring the conversation
42993
+ * into the project it just created, and the run that asked is still writing.
42994
+ * Without this, the next save() would resolve the OLD path and recreate the
42995
+ * file there from its cursor -- the same conversation in two places, neither
42996
+ * complete. The cursor itself survives the move untouched: it is keyed by id
42997
+ * and holds the text, not the path.
42998
+ *
42999
+ * In memory only, and deliberately: it describes where a session went during
43000
+ * THIS page's life. A reload lists both roots afresh and needs no memory of
43001
+ * the journey. */
43002
+ movedRoots = /* @__PURE__ */ new Map();
43003
+ /** Resolve this id somewhere other than the store's own root, from now on. */
43004
+ setRootFor(id, root) {
43005
+ this.movedRoots.set(id, root);
43006
+ }
43007
+ rootFor(id) {
43008
+ return this.movedRoots.get(id) ?? this.root();
43009
+ }
42633
43010
  path(id) {
42634
- return `${this.root()}/${id}/SESSION.jsonl`;
43011
+ return `${this.rootFor(id)}/${id}/SESSION.jsonl`;
42635
43012
  }
42636
43013
  async create(title, surface) {
42637
43014
  const createdAt = now();
@@ -42800,11 +43177,109 @@ var JsonlSessionStore = class _JsonlSessionStore {
42800
43177
  }
42801
43178
  async delete(id) {
42802
43179
  this.cursors.delete(id);
42803
- await (await this.files()).remove(`${this.root()}/${id}`).catch(() => {
43180
+ await (await this.files()).remove(`${this.rootFor(id)}/${id}`).catch(() => {
42804
43181
  });
42805
43182
  }
42806
43183
  };
42807
43184
 
43185
+ // ../harness/sessions/checkpointStore.ts
43186
+ var CHECKPOINT_FILE = "CHECKPOINT.md";
43187
+ function makeCheckpointStore(getFiles, root) {
43188
+ return {
43189
+ async append(sessionId, body2) {
43190
+ const files = await getFiles();
43191
+ if (!files) return;
43192
+ const path = `${root}/${sessionId}/${CHECKPOINT_FILE}`;
43193
+ const entry = `## ${(/* @__PURE__ */ new Date()).toISOString()}
43194
+
43195
+ ${body2}
43196
+ `;
43197
+ let existing = null;
43198
+ try {
43199
+ existing = await files.read(path);
43200
+ } catch {
43201
+ existing = null;
43202
+ }
43203
+ if (existing === null) {
43204
+ await files.create(path, `# Checkpoints
43205
+
43206
+ ${entry}`);
43207
+ return;
43208
+ }
43209
+ await files.write(path, `${existing}
43210
+ ${entry}`);
43211
+ }
43212
+ };
43213
+ }
43214
+
43215
+ // ../harness/core/summarize.ts
43216
+ var TRUNCATION_NOTE = " \u2026[truncated]";
43217
+ var MIN_KEEP = 200;
43218
+ var SUMMARY_INPUT_CHARS = 6e4;
43219
+ var SYSTEM = [
43220
+ "You are compacting an agent conversation that is about to lose its middle.",
43221
+ "Write a dense record of what was DECIDED and what the current state is:",
43222
+ "conclusions reached, constraints the user set, files and artifacts touched,",
43223
+ "and anything still open. Omit narration, pleasantries and tool mechanics.",
43224
+ "Write plain prose or short bullets. No preamble."
43225
+ ].join(" ");
43226
+ function makeSummarizer(client, model, opts = {}) {
43227
+ const maxChars = opts.maxChars ?? SUMMARY_INPUT_CHARS;
43228
+ return async (collapsed) => {
43229
+ const rendered = renderTail(collapsed, maxChars);
43230
+ const res = await client.chat.completions.create({
43231
+ model,
43232
+ messages: [
43233
+ { role: "system", content: SYSTEM },
43234
+ { role: "user", content: rendered }
43235
+ ],
43236
+ stream: false
43237
+ });
43238
+ return res.choices[0]?.message?.content ?? "";
43239
+ };
43240
+ }
43241
+ function renderTail(messages, maxChars) {
43242
+ const lines = [];
43243
+ let used = 0;
43244
+ for (let i2 = messages.length - 1; i2 >= 0; i2--) {
43245
+ const m2 = messages[i2];
43246
+ const text = typeof m2.content === "string" ? m2.content : JSON.stringify(m2.content ?? "");
43247
+ const line = `${m2.role}: ${text}`;
43248
+ const room = maxChars - used;
43249
+ if (line.length > room) {
43250
+ if (room > MIN_KEEP) lines.unshift(line.slice(0, room - TRUNCATION_NOTE.length) + TRUNCATION_NOTE);
43251
+ break;
43252
+ }
43253
+ lines.unshift(line);
43254
+ used += line.length;
43255
+ }
43256
+ return lines.join("\n\n");
43257
+ }
43258
+
43259
+ // ../harness/providers/contextWindow.ts
43260
+ var KNOWN_WINDOWS = {
43261
+ "claude-sonnet-4-5": 2e5,
43262
+ "claude-haiku-4-5": 2e5,
43263
+ "claude-opus-4-1": 2e5,
43264
+ "gpt-4o-mini": 128e3,
43265
+ "gpt-4.1-mini": 1e6,
43266
+ "gpt-5": 4e5,
43267
+ "gpt-5-mini": 4e5,
43268
+ "gemini-2-5-pro": 1e6,
43269
+ "gemini-2-5-flash": 1e6
43270
+ };
43271
+ var CONSERVATIVE_FALLBACK_WINDOW = 32e3;
43272
+ function stripDateSuffix(id) {
43273
+ return id.replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/-\d{8}$/, "");
43274
+ }
43275
+ function resolveContextWindow(modelId) {
43276
+ const id = modelId.trim().toLowerCase();
43277
+ if (id in KNOWN_WINDOWS) return { window: KNOWN_WINDOWS[id], source: "known" };
43278
+ const stripped = stripDateSuffix(id);
43279
+ if (stripped in KNOWN_WINDOWS) return { window: KNOWN_WINDOWS[stripped], source: "known" };
43280
+ return { window: CONSERVATIVE_FALLBACK_WINDOW, source: "fallback" };
43281
+ }
43282
+
42808
43283
  // src/runner/runLog.ts
42809
43284
  function runLogPath(crewRoot2, agent) {
42810
43285
  return `${crewRoot2}/${agent}/runs.jsonl`;
@@ -42932,7 +43407,7 @@ async function runCrewAgentNode(def, input, ctx, deps) {
42932
43407
  const signal = ctx.signal ?? new AbortController().signal;
42933
43408
  const resolveDef = crewResolveDef(ctx.stateFiles, ctx.stateRoot);
42934
43409
  const kernelTools = crewToolSet(def, resolveDef)();
42935
- await mkdir(ctx.memoryDir, { recursive: true });
43410
+ await mkdir2(ctx.memoryDir, { recursive: true });
42936
43411
  const extraTools = deps.nodeTools([...ctx.workRoots, ctx.memoryDir]);
42937
43412
  const tools = [...kernelTools, ...extraTools];
42938
43413
  const conditionalOffers = [];
@@ -43034,6 +43509,21 @@ async function runCrewAgentNode(def, input, ctx, deps) {
43034
43509
  // different answer. Supplied only when no prompt is wired; with one, a real
43035
43510
  // person really did decide, and the default wording is correct.
43036
43511
  ...promptImpl ? {} : { describePermissionDenial: unattendedDenialMessage },
43512
+ /* `mode: 'off'` with a real `contextWindow` reads oddly but is exactly what
43513
+ * is wanted here: rung-1 offload stays off (this executor supplies no
43514
+ * `toolCtx.recall`, so there is nowhere to offload TO), while the loop still
43515
+ * computes `fill` — which is the only thing that lets it notice a window
43516
+ * about to overflow. Without a window, `fill` is pinned at 0 and compaction
43517
+ * below is dead code however well it is wired. */
43518
+ compression: { mode: "off", contextWindow: resolveContextWindow(modelId).window },
43519
+ /* Why the daemon in particular: these runs are unattended and long, so
43520
+ * nobody is watching to notice the wall and restart. Before this, hitting
43521
+ * it produced a provider 400 — fatal in recovery.ts, never retried — and
43522
+ * the run simply died mid-task. */
43523
+ compaction: {
43524
+ summarize: makeSummarizer(ctx.client, modelId),
43525
+ onCheckpoint: (body2) => makeCheckpointStore(() => ctx.stateFiles, sessionsRoot2).append(session.id, body2)
43526
+ },
43037
43527
  signal: runSignal,
43038
43528
  onEvent
43039
43529
  });
@@ -43681,7 +44171,7 @@ var NodeRunnerService = class {
43681
44171
  workFiles,
43682
44172
  workRoots: [workDir],
43683
44173
  client: this.client,
43684
- memoryDir: join11(stateOwnerDir, stateRoot, agent, "memory"),
44174
+ memoryDir: join12(stateOwnerDir, stateRoot, agent, "memory"),
43685
44175
  onEvent: trackedOnEvent,
43686
44176
  sessionId,
43687
44177
  signal: controller.signal,
@@ -43718,7 +44208,7 @@ var NodeRunnerService = class {
43718
44208
  // src/runner/core.ts
43719
44209
  import { watch } from "node:fs";
43720
44210
  import { readdir as readdir2, stat } from "node:fs/promises";
43721
- import { join as join13, relative as relative6, sep as sep4 } from "node:path";
44211
+ import { join as join14, relative as relative6, sep as sep4 } from "node:path";
43722
44212
 
43723
44213
  // src/runner/triggerConfig.ts
43724
44214
  function inputFor(trigger, fallback) {
@@ -44671,8 +45161,8 @@ async function resumeOpenRooms(opts) {
44671
45161
  }
44672
45162
 
44673
45163
  // src/runner/roomDeps.ts
44674
- import { mkdir as mkdir2 } from "node:fs/promises";
44675
- import { join as join12 } from "node:path";
45164
+ import { mkdir as mkdir3 } from "node:fs/promises";
45165
+ import { join as join13 } from "node:path";
44676
45166
 
44677
45167
  // src/runner/roomAgentLookup.ts
44678
45168
  init_tools_node();
@@ -44772,11 +45262,11 @@ async function makeDaemonRoomDeps(ctx, room, deps = {}) {
44772
45262
  `cannot locate the memory folder for crew agent "${def.name}": this daemon has no state root for owner "${ctx.owner}"`
44773
45263
  );
44774
45264
  }
44775
- return join12(stateDir, agentMemoryRoot(ctx.stateRoot, def.name));
45265
+ return join13(stateDir, agentMemoryRoot(ctx.stateRoot, def.name));
44776
45266
  }
44777
45267
  async function buildRuntime(def, p2, state, findings) {
44778
45268
  const memoryDir = memoryDirFor(def);
44779
- await mkdir2(memoryDir, { recursive: true });
45269
+ await mkdir3(memoryDir, { recursive: true });
44780
45270
  const toolCtx = {
44781
45271
  signal: new AbortController().signal,
44782
45272
  memory: makeAgentMemoryProvider(() => ctx.stateFiles, agentMemoryRoot(ctx.stateRoot, def.name)),
@@ -44826,7 +45316,19 @@ async function makeDaemonRoomDeps(ctx, room, deps = {}) {
44826
45316
  // person really did decide and the default wording is correct.
44827
45317
  ...ctx.prompt ? {} : { describePermissionDenial: unattendedDenialMessage },
44828
45318
  ...ctx.now ? { now: ctx.now } : {},
44829
- ...ceilingUsd !== void 0 ? { turnCeilingUsd: ceilingUsd } : {}
45319
+ ...ceilingUsd !== void 0 ? { turnCeilingUsd: ceilingUsd } : {},
45320
+ /* A room is the likeliest place in the product to meet the context
45321
+ * wall: every participant's turn is seeded with the WHOLE transcript,
45322
+ * tool calls included, so cost grows with participants x turns. Before
45323
+ * this, a room that filled the window died on a provider 400 -- fatal
45324
+ * in recovery.ts -- and took the turn with it.
45325
+ *
45326
+ * `mode: 'off'` with a real window is the same trade `executor.ts`
45327
+ * makes: rung-1 offload stays off (this toolCtx supplies no `recall`,
45328
+ * so there is nowhere to offload to) while the loop still computes
45329
+ * `fill`, which is the only thing that lets compaction fire. */
45330
+ compression: { mode: "off", contextWindow: resolveContextWindow(modelId).window },
45331
+ compaction: { summarize: makeSummarizer(ctx.client, modelId) }
44830
45332
  }
44831
45333
  });
44832
45334
  }
@@ -45072,9 +45574,9 @@ function startDaemonCore(opts) {
45072
45574
  for (const entry of entries) {
45073
45575
  if (entry.isDirectory()) {
45074
45576
  if (isExcludedSegment(entry.name, dotDir)) continue;
45075
- await walkForChanges(root, join13(dir, entry.name), since);
45577
+ await walkForChanges(root, join14(dir, entry.name), since);
45076
45578
  } else {
45077
- const abs = join13(dir, entry.name);
45579
+ const abs = join14(dir, entry.name);
45078
45580
  let mtimeMs;
45079
45581
  try {
45080
45582
  mtimeMs = (await stat(abs)).mtimeMs;
@@ -45386,9 +45888,9 @@ function makeRunHub(opts = {}) {
45386
45888
  import { promises as fs11 } from "node:fs";
45387
45889
  import { randomBytes as randomBytes2 } from "node:crypto";
45388
45890
  import { hostname } from "node:os";
45389
- import { join as join14 } from "node:path";
45891
+ import { join as join15 } from "node:path";
45390
45892
  function idPath(dir = sidecarSubdir()) {
45391
- return join14(dir, "daemon.id");
45893
+ return join15(dir, "daemon.id");
45392
45894
  }
45393
45895
  async function loadOrCreateSidecarId(dir = sidecarSubdir()) {
45394
45896
  const path = idPath(dir);
@@ -45417,7 +45919,7 @@ function sidecarName(opts) {
45417
45919
  // src/hosts.ts
45418
45920
  import { promises as fs12 } from "node:fs";
45419
45921
  import { randomBytes as randomBytes3 } from "node:crypto";
45420
- import { join as join15, dirname as dirname5 } from "node:path";
45922
+ import { join as join16, dirname as dirname6 } from "node:path";
45421
45923
  var MAX_DIR = 32;
45422
45924
  var MAX_FIELD_LEN = 200;
45423
45925
  var MAX_HOSTS = 200;
@@ -45451,7 +45953,7 @@ function sanitizeDirName(raw) {
45451
45953
  return RESERVED_WINDOWS_NAMES.has(safe) ? `${safe}-host` : safe;
45452
45954
  }
45453
45955
  function hostsPath(stateDir) {
45454
- return join15(stateDir, "sidecar", "hosts.json");
45956
+ return join16(stateDir, "sidecar", "hosts.json");
45455
45957
  }
45456
45958
  function emptyManifest2() {
45457
45959
  return /* @__PURE__ */ Object.create(null);
@@ -45491,10 +45993,10 @@ async function read(dir, fallbackLastSeen) {
45491
45993
  return manifest;
45492
45994
  }
45493
45995
  async function write(stateDir, manifest) {
45494
- const dir = dirname5(hostsPath(stateDir));
45996
+ const dir = dirname6(hostsPath(stateDir));
45495
45997
  await fs12.mkdir(dir, { recursive: true });
45496
45998
  const target = hostsPath(stateDir);
45497
- const tmp = join15(dir, `.hosts.json.tmp-${randomBytes3(8).toString("hex")}`);
45999
+ const tmp = join16(dir, `.hosts.json.tmp-${randomBytes3(8).toString("hex")}`);
45498
46000
  try {
45499
46001
  await fs12.writeFile(tmp, JSON.stringify(manifest, null, 2));
45500
46002
  await fs12.rename(tmp, target);
@@ -45601,7 +46103,7 @@ function makeHostsManifest(dir) {
45601
46103
 
45602
46104
  // src/hostsAdmin.ts
45603
46105
  import { readdir as readdir3 } from "node:fs/promises";
45604
- import { join as join16 } from "node:path";
46106
+ import { join as join17 } from "node:path";
45605
46107
 
45606
46108
  // src/hostsCli.ts
45607
46109
  init_tools_node();
@@ -45717,16 +46219,16 @@ async function countCrew(crewDir) {
45717
46219
  return entries.filter((e2) => e2.isFile() && /\.(prmd|md)$/i.test(e2.name)).length;
45718
46220
  }
45719
46221
  async function countAgents(hostDir) {
45720
- let total = await countCrew(join16(hostDir, "crew"));
46222
+ let total = await countCrew(join17(hostDir, "crew"));
45721
46223
  let workspaces;
45722
46224
  try {
45723
- workspaces = await readdir3(join16(hostDir, WORKSPACES_SEG), { withFileTypes: true });
46225
+ workspaces = await readdir3(join17(hostDir, WORKSPACES_SEG), { withFileTypes: true });
45724
46226
  } catch {
45725
46227
  return total;
45726
46228
  }
45727
46229
  for (const entry of workspaces) {
45728
46230
  if (!entry.isDirectory()) continue;
45729
- total += await countCrew(join16(hostDir, WORKSPACES_SEG, entry.name, "crew"));
46231
+ total += await countCrew(join17(hostDir, WORKSPACES_SEG, entry.name, "crew"));
45730
46232
  }
45731
46233
  return total;
45732
46234
  }
@@ -45852,11 +46354,11 @@ function startAccountWatch(opts) {
45852
46354
  // src/runner/pauseStore.ts
45853
46355
  import { promises as fs14 } from "node:fs";
45854
46356
  import { randomBytes as randomBytes4 } from "node:crypto";
45855
- import { join as join17, dirname as dirname6 } from "node:path";
46357
+ import { join as join18, dirname as dirname7 } from "node:path";
45856
46358
  var MAX_PAUSES = 1e3;
45857
46359
  var MAX_FIELD_LEN2 = 200;
45858
46360
  function pausesPath(stateDir) {
45859
- return join17(stateDir, "sidecar", "pauses.json");
46361
+ return join18(stateDir, "sidecar", "pauses.json");
45860
46362
  }
45861
46363
  function keyOf2(owner, agent) {
45862
46364
  return `${owner}\0${agent}`;
@@ -45899,9 +46401,9 @@ async function read2(stateDir, fallbackPausedAt) {
45899
46401
  }
45900
46402
  async function write2(stateDir, paused) {
45901
46403
  const target = pausesPath(stateDir);
45902
- const dir = dirname6(target);
46404
+ const dir = dirname7(target);
45903
46405
  await fs14.mkdir(dir, { recursive: true });
45904
- const tmp = join17(dir, `.pauses.json.tmp-${randomBytes4(8).toString("hex")}`);
46406
+ const tmp = join18(dir, `.pauses.json.tmp-${randomBytes4(8).toString("hex")}`);
45905
46407
  const body2 = { version: 1, paused: [...paused] };
45906
46408
  try {
45907
46409
  await fs14.writeFile(tmp, JSON.stringify(body2, null, 2));
@@ -46171,10 +46673,10 @@ async function runSessionsCli(argv, deps) {
46171
46673
  }
46172
46674
  if (verb === "create") {
46173
46675
  const root = rootFrom(argv, process.cwd());
46174
- const backend = deps.backend ?? backendFor(deps, [root], run);
46175
- if (backend.adopt) await backend.adopt().catch(() => void 0);
46676
+ const backend2 = deps.backend ?? backendFor(deps, [root], run);
46677
+ if (backend2.adopt) await backend2.adopt().catch(() => void 0);
46176
46678
  try {
46177
- const handle = await backend.create({ workdir: root, owner: process.env.USERNAME || process.env.USER || "cli" });
46679
+ const handle = await backend2.create({ workdir: root, owner: process.env.USERNAME || process.env.USER || "cli" });
46178
46680
  out(`${handle.id}
46179
46681
  `);
46180
46682
  err(`created ${handle.id} on ${handle.workdir}
@@ -46203,19 +46705,19 @@ async function runSessionsCli(argv, deps) {
46203
46705
  `);
46204
46706
  return 1;
46205
46707
  }
46206
- const backend = deps.backend ?? backendFor(deps, [found.workdir], run);
46207
- if (!backend.exec) {
46708
+ const backend2 = deps.backend ?? backendFor(deps, [found.workdir], run);
46709
+ if (!backend2.exec) {
46208
46710
  err("corenel sessions run: this backend cannot execute inside a session\n");
46209
46711
  return 1;
46210
46712
  }
46211
- if (backend.adopt) await backend.adopt().catch(() => void 0);
46713
+ if (backend2.adopt) await backend2.adopt().catch(() => void 0);
46212
46714
  try {
46213
46715
  const token = await readAuthToken() ?? process.env.CORENEL_TOKEN ?? null;
46214
46716
  if (!token) {
46215
46717
  err("corenel sessions run: not signed in on this host.\n Run `corenel login` first \u2014 the session uses the host's credential.\n");
46216
46718
  return 1;
46217
46719
  }
46218
- const r2 = await backend.exec(found.id, ["corenel", "run", prompt], { env: { CORENEL_TOKEN: token } });
46720
+ const r2 = await backend2.exec(found.id, ["corenel", "run", prompt], { env: { CORENEL_TOKEN: token } });
46219
46721
  if (r2.stdout) out(r2.stdout.endsWith("\n") ? r2.stdout : `${r2.stdout}
46220
46722
  `);
46221
46723
  if (r2.stderr.trim()) err(r2.stderr.endsWith("\n") ? r2.stderr : `${r2.stderr}
@@ -46240,9 +46742,9 @@ async function runSessionsCli(argv, deps) {
46240
46742
  `);
46241
46743
  return 1;
46242
46744
  }
46243
- const backend = deps.backend ?? backendFor(deps, [found.workdir], run);
46244
- if (backend.adopt) await backend.adopt().catch(() => void 0);
46245
- const known = !!backend.get?.(found.id);
46745
+ const backend2 = deps.backend ?? backendFor(deps, [found.workdir], run);
46746
+ if (backend2.adopt) await backend2.adopt().catch(() => void 0);
46747
+ const known = !!backend2.get?.(found.id);
46246
46748
  if (!known) {
46247
46749
  const r2 = await sandboxLifecycle(run, verb, found.sandbox);
46248
46750
  if (!r2.ok) {
@@ -46264,7 +46766,7 @@ async function runSessionsCli(argv, deps) {
46264
46766
  }
46265
46767
  try {
46266
46768
  if (verb === "pause") {
46267
- const r2 = await backend.pause(found.id);
46769
+ const r2 = await backend2.pause(found.id);
46268
46770
  if (r2 && !r2.drained) {
46269
46771
  err(`paused ${found.id}, but ${r2.remaining.length} call(s) were STILL RUNNING when the drain gave up
46270
46772
  `);
@@ -46275,12 +46777,12 @@ async function runSessionsCli(argv, deps) {
46275
46777
  return 0;
46276
46778
  }
46277
46779
  if (verb === "resume") {
46278
- await backend.resume(found.id);
46780
+ await backend2.resume(found.id);
46279
46781
  out(`resumed ${found.id}
46280
46782
  `);
46281
46783
  return 0;
46282
46784
  }
46283
- await backend.destroy(found.id);
46785
+ await backend2.destroy(found.id);
46284
46786
  const still = (await listSessions(run)).find((r2) => r2.id === found.id);
46285
46787
  if (still) {
46286
46788
  err(`corenel sessions destroy: ${found.id} is still present (status ${still.status})
@@ -46704,7 +47206,7 @@ var FLAG_TO_KEY = {
46704
47206
  "--default-model": "defaultModel",
46705
47207
  "--name": "name"
46706
47208
  };
46707
- var DEFAULT_GUEST_IMAGE2 = "prompd/corenel-sandbox:0.8.6";
47209
+ var DEFAULT_GUEST_IMAGE2 = "prompd/corenel-sandbox:0.8.8";
46708
47210
  function parseArgsWithProvenance(argv) {
46709
47211
  const explicit = /* @__PURE__ */ new Set();
46710
47212
  for (const a2 of argv) {
@@ -46846,78 +47348,6 @@ async function resolveEndpoint(args, env = process.env) {
46846
47348
  });
46847
47349
  }
46848
47350
 
46849
- // src/config/daemonConfig.ts
46850
- import { readFile as readFile3, writeFile, mkdir as mkdir3 } from "node:fs/promises";
46851
- import { dirname as dirname7, join as join18 } from "node:path";
46852
- var DAEMON_CONFIG_VERSION = 1;
46853
- var SECRET_KEYS = ["acpAuth", "tunnelToken", "token"];
46854
- var KNOWN_KEYS = [
46855
- "version",
46856
- "port",
46857
- "host",
46858
- "roots",
46859
- "addUnder",
46860
- "cloneUnder",
46861
- "allowLan",
46862
- "allowOrigins",
46863
- "tlsCert",
46864
- "tlsKey",
46865
- "selfSigned",
46866
- "tunnel",
46867
- "tunnelName",
46868
- "tunnelHostname",
46869
- "endpoint",
46870
- "allowShell",
46871
- "keepTranscripts",
46872
- "isolation",
46873
- "msbBin",
46874
- "guestImage",
46875
- "maxChainDepth",
46876
- "name",
46877
- "appUrl",
46878
- "defaultModel",
46879
- "mcpServers",
46880
- "mcpAllow"
46881
- ];
46882
- function daemonConfigPath(stateDir) {
46883
- return join18(stateDir, "daemon.json");
46884
- }
46885
- async function readDaemonConfig(stateDir) {
46886
- const empty = (corrupt) => ({ config: { version: DAEMON_CONFIG_VERSION }, unknown: {}, corrupt });
46887
- let raw;
46888
- try {
46889
- raw = await readFile3(daemonConfigPath(stateDir), "utf8");
46890
- } catch {
46891
- return empty(false);
46892
- }
46893
- let parsed;
46894
- try {
46895
- parsed = JSON.parse(raw);
46896
- } catch {
46897
- return empty(true);
46898
- }
46899
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return empty(true);
46900
- const src2 = parsed;
46901
- const config = { version: DAEMON_CONFIG_VERSION };
46902
- const unknown = {};
46903
- for (const [k2, v2] of Object.entries(src2)) {
46904
- if (k2 === "version") continue;
46905
- if (KNOWN_KEYS.includes(k2)) {
46906
- config[k2] = v2;
46907
- } else {
46908
- unknown[k2] = v2;
46909
- }
46910
- }
46911
- return { config, unknown, corrupt: false };
46912
- }
46913
- async function writeDaemonConfig(stateDir, next, unknown = {}) {
46914
- const body2 = { ...unknown, ...next, version: DAEMON_CONFIG_VERSION };
46915
- const path = daemonConfigPath(stateDir);
46916
- await mkdir3(dirname7(path), { recursive: true });
46917
- await writeFile(path, `${JSON.stringify(body2, null, 2)}
46918
- `, "utf8");
46919
- }
46920
-
46921
47351
  // src/config/layer.ts
46922
47352
  var STRICT_BOOLEAN_KEYS = /* @__PURE__ */ new Set(["allowShell", "allowLan", "selfSigned", "keepTranscripts"]);
46923
47353
  var KEY_MAP = {
@@ -47344,7 +47774,162 @@ async function runConfig(raw, io) {
47344
47774
  return 2;
47345
47775
  }
47346
47776
 
47347
- // src/mcp/config.ts
47777
+ // ../mcp/src/httpClient.ts
47778
+ var PROTOCOL_VERSION = "2025-06-18";
47779
+ var CLIENT_INFO = { name: "corenel", version: "0.2.0" };
47780
+ var SESSION_HEADER = "Mcp-Session-Id";
47781
+ var McpHttpClient = class {
47782
+ constructor(server, auth, fetchImpl = fetch) {
47783
+ this.server = server;
47784
+ this.auth = auth;
47785
+ this.fetchImpl = fetchImpl;
47786
+ }
47787
+ nextRequestId = 1;
47788
+ sessionId;
47789
+ async initialize() {
47790
+ await this.send("initialize", {
47791
+ protocolVersion: PROTOCOL_VERSION,
47792
+ capabilities: {},
47793
+ clientInfo: CLIENT_INFO
47794
+ });
47795
+ }
47796
+ async listTools() {
47797
+ const result = await this.send("tools/list", {});
47798
+ const tools = isRecord2(result) ? result.tools : void 0;
47799
+ if (!Array.isArray(tools)) return [];
47800
+ return tools.filter(isMcpTool);
47801
+ }
47802
+ async callTool(name, args) {
47803
+ const result = await this.send("tools/call", { name, arguments: args });
47804
+ const record = isRecord2(result) ? result : {};
47805
+ return { content: record.content, isError: record.isError === true };
47806
+ }
47807
+ /** Send one JSON-RPC request, retrying exactly once on a 401 that the
47808
+ * AuthProvider says it recovered from. `isRetry` prevents a second retry
47809
+ * loop -- a credential that fails twice is refused, not flaky. */
47810
+ async send(method, params, isRetry = false) {
47811
+ const headers = await this.buildHeaders();
47812
+ const id = this.nextRequestId++;
47813
+ const res = await this.request(headers, id, method, params);
47814
+ if (res.status === 401) {
47815
+ if (!isRetry && await this.auth.onUnauthorized()) {
47816
+ return this.send(method, params, true);
47817
+ }
47818
+ throw new Error(
47819
+ `MCP server "${this.server.name}" refused the credential (401) -- run: corenel mcp login ${this.server.name}`
47820
+ );
47821
+ }
47822
+ const sessionId = res.headers.get(SESSION_HEADER);
47823
+ if (sessionId) this.sessionId = sessionId;
47824
+ if (!res.ok) {
47825
+ throw new Error(`MCP server "${this.server.name}" returned HTTP ${res.status}`);
47826
+ }
47827
+ const contentType = res.headers.get("content-type") ?? "";
47828
+ let body2;
47829
+ if (contentType.includes("text/event-stream")) {
47830
+ body2 = await this.readSseResponse(res, id);
47831
+ } else if (contentType.includes("application/json")) {
47832
+ body2 = await res.json();
47833
+ } else {
47834
+ throw new Error(
47835
+ `MCP server "${this.server.name}" answered with an unexpected content-type: "${contentType || "(none)"}"`
47836
+ );
47837
+ }
47838
+ if (body2.error) {
47839
+ throw new Error(`MCP server "${this.server.name}" returned error ${body2.error.code}: ${body2.error.message}`);
47840
+ }
47841
+ return body2.result;
47842
+ }
47843
+ /** Read an SSE body until the JSON-RPC response for `id` arrives.
47844
+ *
47845
+ * Events are separated by a blank line; an event's `data:` lines are joined
47846
+ * with newlines, per the SSE spec, and parsed as one JSON-RPC message.
47847
+ * Notifications (no `id`) and responses to other ids are skipped. The reader
47848
+ * is cancelled once the answer is in, so a server that keeps the stream open
47849
+ * after replying cannot hold this call open with it. */
47850
+ async readSseResponse(res, id) {
47851
+ if (!res.body) {
47852
+ throw new Error(`MCP server "${this.server.name}" closed the stream without a response`);
47853
+ }
47854
+ const reader = res.body.getReader();
47855
+ const decoder = new TextDecoder();
47856
+ let buffer = "";
47857
+ try {
47858
+ for (; ; ) {
47859
+ const { done, value } = await reader.read();
47860
+ if (value) buffer += decoder.decode(value, { stream: true });
47861
+ buffer = buffer.replace(/\r\n/g, "\n");
47862
+ let boundary = buffer.indexOf("\n\n");
47863
+ while (boundary !== -1) {
47864
+ const event = buffer.slice(0, boundary);
47865
+ buffer = buffer.slice(boundary + 2);
47866
+ const match = this.matchSseEvent(event, id);
47867
+ if (match) return match;
47868
+ boundary = buffer.indexOf("\n\n");
47869
+ }
47870
+ if (done) {
47871
+ const tail3 = buffer.trim() ? this.matchSseEvent(buffer, id) : null;
47872
+ if (tail3) return tail3;
47873
+ throw new Error(`MCP server "${this.server.name}" closed the stream without a response`);
47874
+ }
47875
+ }
47876
+ } finally {
47877
+ reader.cancel().catch(() => {
47878
+ });
47879
+ }
47880
+ }
47881
+ /** The JSON-RPC response in one SSE event, if it answers `id`; else null. */
47882
+ matchSseEvent(event, id) {
47883
+ const data = event.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
47884
+ if (!data) return null;
47885
+ let parsed;
47886
+ try {
47887
+ parsed = JSON.parse(data);
47888
+ } catch {
47889
+ return null;
47890
+ }
47891
+ if (!isRecord2(parsed) || parsed.id !== id) return null;
47892
+ if (!("result" in parsed) && !("error" in parsed)) return null;
47893
+ return parsed;
47894
+ }
47895
+ /** The bare `fetch()` call, wrapped -- every OTHER throw in `send` already
47896
+ * names the server; this is the one call whose own failures (DNS miss,
47897
+ * connection refused, TLS failure) never reach `send`'s response-handling
47898
+ * at all. Left unguarded, node's bare `fetch failed` -- naming neither the
47899
+ * URL nor the actual cause -- propagates straight out of `send`, into
47900
+ * `registry.ts`'s `status().error`, and out of `doctor`, the one command
47901
+ * whose entire job is diagnosis. `err.cause` carries the real reason
47902
+ * (ECONNREFUSED, ENOTFOUND, a TLS alert); undici puts it there rather than
47903
+ * in `err.message`. */
47904
+ async request(headers, id, method, params) {
47905
+ try {
47906
+ return await this.fetchImpl(this.server.url, {
47907
+ method: "POST",
47908
+ headers,
47909
+ body: JSON.stringify({ jsonrpc: "2.0", id, method, params })
47910
+ });
47911
+ } catch (err) {
47912
+ const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : err instanceof Error ? err.message : String(err);
47913
+ throw new Error(`MCP server "${this.server.name}" (${this.server.url}) is unreachable: ${cause}`);
47914
+ }
47915
+ }
47916
+ async buildHeaders() {
47917
+ const headers = new Headers();
47918
+ headers.set("Content-Type", "application/json");
47919
+ headers.set("Accept", "application/json, text/event-stream");
47920
+ for (const h2 of await this.auth.headers()) headers.set(h2.name, h2.value);
47921
+ if (this.sessionId) headers.set(SESSION_HEADER, this.sessionId);
47922
+ return headers;
47923
+ }
47924
+ };
47925
+ function isRecord2(value) {
47926
+ return typeof value === "object" && value !== null;
47927
+ }
47928
+ function isMcpTool(value) {
47929
+ return isRecord2(value) && typeof value.name === "string" && "inputSchema" in value;
47930
+ }
47931
+
47932
+ // ../mcp/src/config.ts
47348
47933
  init_tools_node();
47349
47934
  import { readFile as readFile4, writeFile as writeFile2, mkdir as mkdir4, chmod } from "node:fs/promises";
47350
47935
  function isUsableServer(v2) {
@@ -47463,7 +48048,7 @@ async function writeMcpCredential(stateDir, server, cred) {
47463
48048
  }
47464
48049
  }
47465
48050
 
47466
- // src/mcp/oauth/discovery.ts
48051
+ // ../mcp/src/oauth/discovery.ts
47467
48052
  var PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
47468
48053
  var AUTH_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
47469
48054
  async function tryFetchJson(url, fetchImpl) {
@@ -47561,7 +48146,423 @@ async function discoverAuthMetadata(authServer, fetchImpl = fetch) {
47561
48146
  return { authorizationEndpoint, tokenEndpoint, registrationEndpoint, scopesSupported };
47562
48147
  }
47563
48148
 
47564
- // src/mcp/oauth/register.ts
48149
+ // ../mcp/src/oauth/tokens.ts
48150
+ function parseTokenResponse(body2) {
48151
+ if (typeof body2 !== "object" || body2 === null || Array.isArray(body2)) {
48152
+ throw new Error("token endpoint returned a malformed response");
48153
+ }
48154
+ const r2 = body2;
48155
+ if (typeof r2.access_token !== "string" || r2.access_token.length === 0) {
48156
+ throw new Error("token endpoint response did not include an access_token");
48157
+ }
48158
+ return {
48159
+ access_token: r2.access_token,
48160
+ ...typeof r2.refresh_token === "string" ? { refresh_token: r2.refresh_token } : {},
48161
+ ...typeof r2.expires_in === "number" ? { expires_in: r2.expires_in } : {},
48162
+ ...typeof r2.scope === "string" ? { scope: r2.scope } : {}
48163
+ };
48164
+ }
48165
+ async function postToken(endpoint, body2, fetchImpl) {
48166
+ const res = await fetchImpl(endpoint, {
48167
+ method: "POST",
48168
+ headers: { "content-type": "application/x-www-form-urlencoded" },
48169
+ body: body2.toString()
48170
+ });
48171
+ if (!res.ok) {
48172
+ throw new Error(`token request failed at ${endpoint} (HTTP ${res.status})`);
48173
+ }
48174
+ return parseTokenResponse(await res.json());
48175
+ }
48176
+ async function exchangeCode(meta, opts, fetchImpl = fetch, now2 = Date.now) {
48177
+ const body2 = new URLSearchParams({
48178
+ grant_type: "authorization_code",
48179
+ client_id: opts.clientId,
48180
+ code: opts.code,
48181
+ code_verifier: opts.verifier,
48182
+ redirect_uri: opts.redirectUri,
48183
+ resource: opts.resource
48184
+ });
48185
+ if (opts.clientSecret) body2.set("client_secret", opts.clientSecret);
48186
+ const tokens = await postToken(meta.tokenEndpoint, body2, fetchImpl);
48187
+ return {
48188
+ kind: "oauth",
48189
+ accessToken: tokens.access_token,
48190
+ ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {},
48191
+ ...typeof tokens.expires_in === "number" ? { expiresAt: now2() + tokens.expires_in * 1e3 } : {},
48192
+ clientId: opts.clientId,
48193
+ ...opts.clientSecret ? { clientSecret: opts.clientSecret } : {},
48194
+ authServer: opts.authServer,
48195
+ ...tokens.scope ? { scope: tokens.scope } : {}
48196
+ };
48197
+ }
48198
+ async function refresh(meta, cred, fetchImpl = fetch, now2 = Date.now) {
48199
+ if (!cred.refreshToken) {
48200
+ throw new Error("credential has no refresh token to refresh with -- sign in again");
48201
+ }
48202
+ const body2 = new URLSearchParams({
48203
+ grant_type: "refresh_token",
48204
+ refresh_token: cred.refreshToken,
48205
+ client_id: cred.clientId
48206
+ });
48207
+ if (cred.clientSecret) body2.set("client_secret", cred.clientSecret);
48208
+ const tokens = await postToken(meta.tokenEndpoint, body2, fetchImpl);
48209
+ return {
48210
+ kind: "oauth",
48211
+ accessToken: tokens.access_token,
48212
+ // Keep the old refresh token when the server did not send a new one.
48213
+ refreshToken: tokens.refresh_token ?? cred.refreshToken,
48214
+ ...typeof tokens.expires_in === "number" ? { expiresAt: now2() + tokens.expires_in * 1e3 } : {},
48215
+ clientId: cred.clientId,
48216
+ ...cred.clientSecret ? { clientSecret: cred.clientSecret } : {},
48217
+ authServer: cred.authServer,
48218
+ scope: tokens.scope ?? cred.scope
48219
+ };
48220
+ }
48221
+ var EXPIRY_SKEW_MS = 6e4;
48222
+ function isExpired(cred, nowMs) {
48223
+ if (cred.expiresAt === void 0) return false;
48224
+ return cred.expiresAt - nowMs <= EXPIRY_SKEW_MS;
48225
+ }
48226
+
48227
+ // ../mcp/src/auth.ts
48228
+ function authProviderFor(stateDir, server, fetchImpl = fetch, accountToken = async () => null) {
48229
+ async function doRefresh(cred) {
48230
+ if (!cred.refreshToken) {
48231
+ throw new Error(`"${server.name}" has no refresh token on file -- run: corenel mcp login ${server.name}`);
48232
+ }
48233
+ const meta = await discoverAuthMetadata(cred.authServer, fetchImpl);
48234
+ const refreshed = await refresh(meta, cred, fetchImpl);
48235
+ await writeMcpCredential(stateDir, server.name, refreshed);
48236
+ return refreshed;
48237
+ }
48238
+ return {
48239
+ async headers() {
48240
+ const cred = await readMcpCredential(stateDir, server.name);
48241
+ if (!cred && !server.hosted) {
48242
+ throw new Error(`No credential stored for MCP server "${server.name}" -- run: corenel mcp login ${server.name} (or set-header, for a static token).`);
48243
+ }
48244
+ const mcpHeaders = !cred ? [] : cred.kind === "headers" ? cred.headers : [{ name: "Authorization", value: `Bearer ${(isExpired(cred, Date.now()) ? await doRefresh(cred) : cred).accessToken}` }];
48245
+ if (!server.hosted) return mcpHeaders;
48246
+ const moved = mcpHeaders.map((h2) => h2.name.toLowerCase() === "authorization" ? { name: "X-MCP-Authorization", value: h2.value } : h2);
48247
+ const account = await accountToken();
48248
+ return account ? [...moved, { name: "Authorization", value: `Bearer ${account}` }] : moved;
48249
+ },
48250
+ async onUnauthorized() {
48251
+ const cred = await readMcpCredential(stateDir, server.name);
48252
+ if (!cred || cred.kind !== "oauth") return false;
48253
+ try {
48254
+ await doRefresh(cred);
48255
+ return true;
48256
+ } catch {
48257
+ return false;
48258
+ }
48259
+ }
48260
+ };
48261
+ }
48262
+
48263
+ // ../mcp/src/timedFetch.ts
48264
+ var AUTH_REQUEST_TIMEOUT_MS = 15e3;
48265
+ function timedFetch(ms, inner = fetch) {
48266
+ return async (input, init2 = {}) => {
48267
+ const deadline = AbortSignal.timeout(ms);
48268
+ const signal = init2.signal ? AbortSignal.any([init2.signal, deadline]) : deadline;
48269
+ try {
48270
+ return await inner(input, { ...init2, signal });
48271
+ } catch (err) {
48272
+ if (deadline.aborted && !init2.signal?.aborted) {
48273
+ throw new Error(`no answer within ${ms / 1e3}s`, { cause: err });
48274
+ }
48275
+ throw err;
48276
+ }
48277
+ };
48278
+ }
48279
+
48280
+ // ../mcp/src/registry.ts
48281
+ async function accountTokenFor(stateDir) {
48282
+ try {
48283
+ const cred = await resolveCredential(process.env, stateDir);
48284
+ return cred.token ?? null;
48285
+ } catch {
48286
+ return null;
48287
+ }
48288
+ }
48289
+ function defaultClientFactory(stateDir, server) {
48290
+ return new McpHttpClient(
48291
+ server,
48292
+ authProviderFor(stateDir, server, timedFetch(AUTH_REQUEST_TIMEOUT_MS), () => accountTokenFor(stateDir))
48293
+ );
48294
+ }
48295
+ var SERVER_CONNECT_TIMEOUT_MS = 3e4;
48296
+ var McpRegistry = class {
48297
+ constructor(stateDir, clientFactory = defaultClientFactory) {
48298
+ this.stateDir = stateDir;
48299
+ this.clientFactory = clientFactory;
48300
+ }
48301
+ connected = /* @__PURE__ */ new Map();
48302
+ statuses = /* @__PURE__ */ new Map();
48303
+ // The order the operator wrote servers in daemon.json, captured once per
48304
+ // connectAll() -- `tools()` walks THIS, never `connected`'s Map iteration
48305
+ // order. `connectOne` runs concurrently for every server (Promise.all), so
48306
+ // Map insertion order is race order: whichever server's initialize+list
48307
+ // happened to resolve first. That is fine for `connected` (a lookup table,
48308
+ // order never read) but `tools()` is a list a model/doctor/policy consumes
48309
+ // in order, and race order means a different tool ordering on every daemon
48310
+ // boot -- exactly the kind of bug that surfaces far from here and is never
48311
+ // traced back. Configured order is also information the operator supplied
48312
+ // deliberately; sorting alphabetically would discard it.
48313
+ serverOrder = [];
48314
+ // Tool lists seeded from the on-disk cache, with no socket ever opened.
48315
+ // Deliberately a THIRD piece of state, not folded into `connected`: a
48316
+ // primed server can answer `tools()` but must still go through
48317
+ // `ensureConnected` before `call()` will dispatch to it. `connectAll`
48318
+ // does not touch this map -- see the comment at its first line for why.
48319
+ primed = /* @__PURE__ */ new Map();
48320
+ // One in-flight ensureConnected() attempt per server, so three tool calls
48321
+ // that land on the same cold server in the same turn share one connection
48322
+ // instead of racing three.
48323
+ connecting = /* @__PURE__ */ new Map();
48324
+ /**
48325
+ * Connect to every configured server and cache its tools. Never throws --
48326
+ * a server that fails to initialize, or fails listing tools, is recorded
48327
+ * in `status()` with zero tools and the daemon keeps starting.
48328
+ */
48329
+ async connectAll(opts = {}) {
48330
+ this.connected.clear();
48331
+ this.statuses.clear();
48332
+ this.primed.clear();
48333
+ const servers = await readMcpServers(this.stateDir);
48334
+ this.serverOrder = servers.map((s2) => s2.name);
48335
+ const timeoutMs = opts.timeoutMs ?? SERVER_CONNECT_TIMEOUT_MS;
48336
+ await Promise.all(servers.map((server) => this.connectOne(server, timeoutMs, opts.onServer)));
48337
+ }
48338
+ /* BOUNDED, AND SETTLED EXACTLY ONCE.
48339
+ *
48340
+ * Every server connects under one Promise.all, and daemon startup awaits it.
48341
+ * With no bound, one stalled server -- measured: an expired Microsoft 365
48342
+ * token refreshing against a cold service, still waiting at 60 seconds --
48343
+ * left the terminal silent after `mirrors sessions`, indistinguishable from a
48344
+ * crash.
48345
+ *
48346
+ * The deadline covers the whole connect (any credential refresh, initialize,
48347
+ * tools/list), not each request, because several individually bounded steps
48348
+ * in a row can still add up to a hang. The attempt that loses the race keeps
48349
+ * running; its late result is deliberately ignored, so a server reported as
48350
+ * timed out cannot quietly become connected afterwards -- status would then
48351
+ * tell doctor one thing and the registered tools another. */
48352
+ async connectOne(server, timeoutMs, onServer) {
48353
+ let timer;
48354
+ const deadline = new Promise((resolve14) => {
48355
+ timer = setTimeout(() => resolve14("timeout"), timeoutMs);
48356
+ });
48357
+ const attempt = (async () => {
48358
+ const client = this.clientFactory(this.stateDir, server);
48359
+ await client.initialize();
48360
+ const tools = await client.listTools();
48361
+ return { client, tools };
48362
+ })();
48363
+ attempt.catch(() => {
48364
+ });
48365
+ try {
48366
+ const outcome = await Promise.race([attempt, deadline]);
48367
+ if (outcome === "timeout") {
48368
+ this.statuses.set(server.name, { connected: false, toolCount: 0, error: `timed out after ${timeoutMs / 1e3}s` });
48369
+ } else {
48370
+ this.connected.set(server.name, outcome);
48371
+ this.statuses.set(server.name, { connected: true, toolCount: outcome.tools.length });
48372
+ }
48373
+ } catch (err) {
48374
+ this.statuses.set(server.name, {
48375
+ connected: false,
48376
+ toolCount: 0,
48377
+ error: err instanceof Error ? err.message : String(err)
48378
+ });
48379
+ } finally {
48380
+ clearTimeout(timer);
48381
+ }
48382
+ const settled = this.statuses.get(server.name);
48383
+ if (settled) onServer?.({ server: server.name, ...settled });
48384
+ }
48385
+ /** Every tool from every connected OR primed server, paired with the server
48386
+ * it came from, in CONFIGURED order (`serverOrder`) -- not the order
48387
+ * servers happened to finish connecting in, and not insertion order into
48388
+ * `primed` either. Within a server, tools stay in the order that server
48389
+ * returned them. A live connection wins over a primed (cached) entry for
48390
+ * the same server, since it is the fresher truth. A server with neither
48391
+ * either failed to connect or reported zero tools -- `status()` tells the
48392
+ * two apart. */
48393
+ tools() {
48394
+ const out = [];
48395
+ for (const server of this.serverOrder) {
48396
+ const entry = this.connected.get(server);
48397
+ const list = entry ? entry.tools : this.primed.get(server);
48398
+ if (!list) continue;
48399
+ for (const tool of list) out.push({ server, tool });
48400
+ }
48401
+ return out;
48402
+ }
48403
+ /** Seed tool lists from the on-disk cache. NO I/O, no sockets -- this is
48404
+ * what a terminal calls at startup so that `tools()` can answer
48405
+ * immediately, before any server has actually been reached. A primed
48406
+ * server is deliberately NOT `connected`: it can answer what tools exist,
48407
+ * and cannot call one until `ensureConnected()` has run. */
48408
+ primeFromCache(cache) {
48409
+ for (const [name, entry] of Object.entries(cache)) {
48410
+ this.statuses.set(name, { connected: false, toolCount: entry.tools.length });
48411
+ this.primed.set(name, entry.tools);
48412
+ if (!this.serverOrder.includes(name)) this.serverOrder.push(name);
48413
+ }
48414
+ }
48415
+ isConnected(server) {
48416
+ return this.connected.has(server);
48417
+ }
48418
+ connectedServers() {
48419
+ return this.serverOrder.filter((s2) => this.connected.has(s2));
48420
+ }
48421
+ /** Connect ONE server, on demand. Never throws, for the same reason
48422
+ * connectAll never throws: a server that is down must not take the
48423
+ * process with it. Concurrent callers for the same server share one
48424
+ * attempt rather than opening a second socket. */
48425
+ async ensureConnected(server) {
48426
+ if (this.connected.has(server)) return;
48427
+ const inflight = this.connecting.get(server);
48428
+ if (inflight) return inflight;
48429
+ const attempt = (async () => {
48430
+ const servers = await readMcpServers(this.stateDir);
48431
+ const def = servers.find((s2) => s2.name === server);
48432
+ if (!def) return;
48433
+ if (!this.serverOrder.includes(server)) this.serverOrder.push(server);
48434
+ await this.connectOne(def, SERVER_CONNECT_TIMEOUT_MS);
48435
+ })().finally(() => this.connecting.delete(server));
48436
+ this.connecting.set(server, attempt);
48437
+ return attempt;
48438
+ }
48439
+ /**
48440
+ * Call one tool on one server. Fails immediately, naming the server, when
48441
+ * that server never connected -- there is nothing to route the call
48442
+ * through, and pretending otherwise would either stall waiting on a
48443
+ * connection that will never appear or return an empty result the caller
48444
+ * could mistake for a genuine (if empty) tool response.
48445
+ */
48446
+ async call(server, tool, args) {
48447
+ await this.ensureConnected(server);
48448
+ const entry = this.connected.get(server);
48449
+ if (!entry) {
48450
+ throw new Error(`MCP server "${server}" is not connected -- run: corenel mcp doctor`);
48451
+ }
48452
+ return entry.client.callTool(tool, args);
48453
+ }
48454
+ /** Per-server connection state, for `doctor` (Task 10) to print. A server
48455
+ * with `connected: false` always carries `error` -- there is no code path
48456
+ * that records a failure without a reason, because "no tools" alone tells
48457
+ * the reader nothing about what to fix. */
48458
+ status() {
48459
+ const out = [];
48460
+ for (const server of this.serverOrder) {
48461
+ const s2 = this.statuses.get(server);
48462
+ if (s2) out.push({ server, ...s2 });
48463
+ }
48464
+ return out;
48465
+ }
48466
+ };
48467
+
48468
+ // ../mcp/src/policy.ts
48469
+ function mcpCallAllowed(address, policy) {
48470
+ const allow = policy.tools?.allow ?? [];
48471
+ if (!matchesAnyGlob(address, allow)) {
48472
+ return { ok: false, reason: `${address} is not allowed - add it with: corenel config add mcpAllow ${address}` };
48473
+ }
48474
+ const service = new PermissionService({
48475
+ policy,
48476
+ defaultForReadOnly: "allow",
48477
+ defaultForMutating: "allow"
48478
+ });
48479
+ const level = service.resolveLevel({ name: address, address, mutates: true });
48480
+ if (level !== "allow") {
48481
+ return {
48482
+ ok: false,
48483
+ reason: `${address} resolves to permission "${level}" - MCP tools never prompt; set it to "allow" or remove the rule`
48484
+ };
48485
+ }
48486
+ return { ok: true };
48487
+ }
48488
+
48489
+ // ../mcp/src/toolNames.ts
48490
+ init_protocol();
48491
+
48492
+ // ../mcp/src/asAgentTools.ts
48493
+ function isPlainObject3(v2) {
48494
+ return typeof v2 === "object" && v2 !== null && !Array.isArray(v2);
48495
+ }
48496
+ function toParameters(inputSchema) {
48497
+ if (isPlainObject3(inputSchema)) return inputSchema;
48498
+ return { type: "object", properties: {} };
48499
+ }
48500
+ function formatContent(content) {
48501
+ if (typeof content === "string") return content;
48502
+ if (Array.isArray(content)) {
48503
+ const parts = content.map((block) => {
48504
+ if (isPlainObject3(block) && typeof block.text === "string") return block.text;
48505
+ return JSON.stringify(block);
48506
+ });
48507
+ return parts.join("\n");
48508
+ }
48509
+ if (content == null) return "";
48510
+ return JSON.stringify(content);
48511
+ }
48512
+ function asAgentTools(registry2) {
48513
+ const out = [];
48514
+ for (const { server, tool } of registry2.tools()) {
48515
+ if (!canEncodeMcpWireName(server, tool.name)) {
48516
+ console.error(
48517
+ `MCP tool refused: server "${server}" tool "${tool.name}" cannot be encoded as a wire name (unsupported characters or too long) -- this tool is not available to the model`
48518
+ );
48519
+ continue;
48520
+ }
48521
+ out.push(toAgentTool(registry2, server, tool));
48522
+ }
48523
+ return out;
48524
+ }
48525
+ function toAgentTool(registry2, server, tool) {
48526
+ const name = mcpWireName(server, tool.name);
48527
+ const address = mcpToolAddress(server, tool.name);
48528
+ return {
48529
+ name,
48530
+ namespace: "mcp",
48531
+ description: tool.description?.trim() || `MCP tool "${tool.name}" on server "${server}" (${address}).`,
48532
+ parameters: toParameters(tool.inputSchema),
48533
+ mutates: true,
48534
+ async run(args) {
48535
+ const result = await registry2.call(server, tool.name, args);
48536
+ const text = formatContent(result.content);
48537
+ return result.isError ? `Error: ${text}` : text;
48538
+ }
48539
+ };
48540
+ }
48541
+
48542
+ // ../mcp/src/gatedAgentTools.ts
48543
+ function gatedAgentTools(registry2, policy) {
48544
+ const base = asAgentTools(registry2);
48545
+ const byWireName = new Map(base.map((t2) => [t2.name, t2]));
48546
+ const wrapped = [];
48547
+ for (const { server, tool } of registry2.tools()) {
48548
+ if (!canEncodeMcpWireName(server, tool.name)) continue;
48549
+ const name = mcpWireName(server, tool.name);
48550
+ const original = byWireName.get(name);
48551
+ if (!original) continue;
48552
+ const address = mcpToolAddress(server, tool.name);
48553
+ wrapped.push({
48554
+ ...original,
48555
+ async run(args, ctx) {
48556
+ const decision = mcpCallAllowed(address, policy);
48557
+ if (!decision.ok) return `Error: ${decision.reason}`;
48558
+ return original.run(args, ctx);
48559
+ }
48560
+ });
48561
+ }
48562
+ return wrapped;
48563
+ }
48564
+
48565
+ // ../mcp/src/oauth/register.ts
47565
48566
  async function registerClient(meta, redirectUri, fetchImpl) {
47566
48567
  if (!meta.registrationEndpoint) return null;
47567
48568
  const res = await fetchImpl(meta.registrationEndpoint, {
@@ -47591,7 +48592,7 @@ async function registerClient(meta, redirectUri, fetchImpl) {
47591
48592
  return { clientId, clientSecret };
47592
48593
  }
47593
48594
 
47594
- // src/mcp/oauth/pkce.ts
48595
+ // ../mcp/src/oauth/pkce.ts
47595
48596
  import { randomBytes as randomBytes5, createHash as createHash3 } from "node:crypto";
47596
48597
  function base64url(bytes) {
47597
48598
  return bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -47619,7 +48620,7 @@ function authorizationUrl(meta, opts) {
47619
48620
  return url.toString();
47620
48621
  }
47621
48622
 
47622
- // src/mcp/oauth/loopback.ts
48623
+ // ../mcp/src/oauth/loopback.ts
47623
48624
  import { createServer } from "node:http";
47624
48625
  var PAGE_OK = `<!doctype html><meta charset="utf-8"><title>Signed in</title>
47625
48626
  <body style="font:16px system-ui;padding:3rem;text-align:center">
@@ -47672,6 +48673,17 @@ function awaitRedirect(port, expectedState, opts = {}) {
47672
48673
  server.once("error", (e2) => {
47673
48674
  finish(() => reject(e2 instanceof Error ? e2 : new Error(String(e2))));
47674
48675
  });
48676
+ if (opts.signal) {
48677
+ if (opts.signal.aborted) {
48678
+ finish(() => reject(new Error("sign-in cancelled")));
48679
+ } else {
48680
+ opts.signal.addEventListener(
48681
+ "abort",
48682
+ () => finish(() => reject(new Error("sign-in cancelled"))),
48683
+ { once: true }
48684
+ );
48685
+ }
48686
+ }
47675
48687
  v6.on("error", () => {
47676
48688
  });
47677
48689
  try {
@@ -47684,6 +48696,15 @@ function awaitRedirect(port, expectedState, opts = {}) {
47684
48696
  });
47685
48697
  });
47686
48698
  }
48699
+ function portFree(port) {
48700
+ return new Promise((resolve14) => {
48701
+ const probe = createServer();
48702
+ probe.once("error", () => resolve14(false));
48703
+ probe.listen(port, "127.0.0.1", () => {
48704
+ probe.close(() => resolve14(true));
48705
+ });
48706
+ });
48707
+ }
47687
48708
  function getFreePort() {
47688
48709
  return new Promise((resolve14, reject) => {
47689
48710
  const probe = createServer();
@@ -47695,93 +48716,16 @@ function getFreePort() {
47695
48716
  });
47696
48717
  }
47697
48718
 
47698
- // src/mcp/oauth/tokens.ts
47699
- function parseTokenResponse(body2) {
47700
- if (typeof body2 !== "object" || body2 === null || Array.isArray(body2)) {
47701
- throw new Error("token endpoint returned a malformed response");
47702
- }
47703
- const r2 = body2;
47704
- if (typeof r2.access_token !== "string" || r2.access_token.length === 0) {
47705
- throw new Error("token endpoint response did not include an access_token");
47706
- }
47707
- return {
47708
- access_token: r2.access_token,
47709
- ...typeof r2.refresh_token === "string" ? { refresh_token: r2.refresh_token } : {},
47710
- ...typeof r2.expires_in === "number" ? { expires_in: r2.expires_in } : {},
47711
- ...typeof r2.scope === "string" ? { scope: r2.scope } : {}
47712
- };
47713
- }
47714
- async function postToken(endpoint, body2, fetchImpl) {
47715
- const res = await fetchImpl(endpoint, {
47716
- method: "POST",
47717
- headers: { "content-type": "application/x-www-form-urlencoded" },
47718
- body: body2.toString()
47719
- });
47720
- if (!res.ok) {
47721
- throw new Error(`token request failed at ${endpoint} (HTTP ${res.status})`);
47722
- }
47723
- return parseTokenResponse(await res.json());
47724
- }
47725
- async function exchangeCode(meta, opts, fetchImpl = fetch, now2 = Date.now) {
47726
- const body2 = new URLSearchParams({
47727
- grant_type: "authorization_code",
47728
- client_id: opts.clientId,
47729
- code: opts.code,
47730
- code_verifier: opts.verifier,
47731
- redirect_uri: opts.redirectUri,
47732
- resource: opts.resource
47733
- });
47734
- if (opts.clientSecret) body2.set("client_secret", opts.clientSecret);
47735
- const tokens = await postToken(meta.tokenEndpoint, body2, fetchImpl);
47736
- return {
47737
- kind: "oauth",
47738
- accessToken: tokens.access_token,
47739
- ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {},
47740
- ...typeof tokens.expires_in === "number" ? { expiresAt: now2() + tokens.expires_in * 1e3 } : {},
47741
- clientId: opts.clientId,
47742
- ...opts.clientSecret ? { clientSecret: opts.clientSecret } : {},
47743
- authServer: opts.authServer,
47744
- ...tokens.scope ? { scope: tokens.scope } : {}
47745
- };
47746
- }
47747
- async function refresh(meta, cred, fetchImpl = fetch, now2 = Date.now) {
47748
- if (!cred.refreshToken) {
47749
- throw new Error("credential has no refresh token to refresh with -- sign in again");
47750
- }
47751
- const body2 = new URLSearchParams({
47752
- grant_type: "refresh_token",
47753
- refresh_token: cred.refreshToken,
47754
- client_id: cred.clientId
47755
- });
47756
- if (cred.clientSecret) body2.set("client_secret", cred.clientSecret);
47757
- const tokens = await postToken(meta.tokenEndpoint, body2, fetchImpl);
47758
- return {
47759
- kind: "oauth",
47760
- accessToken: tokens.access_token,
47761
- // Keep the old refresh token when the server did not send a new one.
47762
- refreshToken: tokens.refresh_token ?? cred.refreshToken,
47763
- ...typeof tokens.expires_in === "number" ? { expiresAt: now2() + tokens.expires_in * 1e3 } : {},
47764
- clientId: cred.clientId,
47765
- ...cred.clientSecret ? { clientSecret: cred.clientSecret } : {},
47766
- authServer: cred.authServer,
47767
- scope: tokens.scope ?? cred.scope
47768
- };
47769
- }
47770
- var EXPIRY_SKEW_MS = 6e4;
47771
- function isExpired(cred, nowMs) {
47772
- if (cred.expiresAt === void 0) return false;
47773
- return cred.expiresAt - nowMs <= EXPIRY_SKEW_MS;
47774
- }
47775
-
47776
- // src/mcp/oauth/login.ts
48719
+ // ../mcp/src/oauth/login.ts
47777
48720
  var defaultOpenBrowser = async (url) => {
47778
- const { openBrowser: openBrowser3 } = await Promise.resolve().then(() => (init_cli(), cli_exports));
48721
+ const { openBrowser: openBrowser3 } = await Promise.resolve().then(() => (init_tools_node(), tools_node_exports));
47779
48722
  await openBrowser3(url);
47780
48723
  };
47781
48724
  async function loginToMcpServer(stateDir, server, deps = {}) {
47782
48725
  const fetchImpl = deps.fetchImpl ?? fetch;
47783
48726
  const openBrowserFn = deps.openBrowser ?? defaultOpenBrowser;
47784
48727
  const now2 = deps.now ?? Date.now;
48728
+ const redirectAbort = new AbortController();
47785
48729
  const signInUrl = server.authUrl ?? server.url;
47786
48730
  try {
47787
48731
  const discovered = await discoverAuthServer(
@@ -47802,6 +48746,13 @@ async function loginToMcpServer(stateDir, server, deps = {}) {
47802
48746
  const meta = discovered.scopes ? { ...asMeta, scopesSupported: discovered.scopes } : asMeta;
47803
48747
  const cached = await readMcpClient(stateDir, server.name);
47804
48748
  const port = cached?.redirectPort ?? await getFreePort();
48749
+ if (cached?.redirectPort !== void 0 && !await portFree(cached.redirectPort)) {
48750
+ return {
48751
+ ok: false,
48752
+ reason: "failed",
48753
+ message: `the redirect port ${cached.redirectPort} is in use, and "${server.name}" is registered against exactly that port -- free it and sign in again`
48754
+ };
48755
+ }
47805
48756
  const redirectUri = `http://localhost:${port}/callback`;
47806
48757
  const registered2 = cached && cached.authServer === discovered.authServer ? { clientId: cached.clientId, ...cached.clientSecret ? { clientSecret: cached.clientSecret } : {} } : await registerClient(
47807
48758
  meta,
@@ -47812,7 +48763,25 @@ async function loginToMcpServer(stateDir, server, deps = {}) {
47812
48763
  await writeMcpClient(stateDir, server.name, {
47813
48764
  clientId: registered2.clientId,
47814
48765
  ...registered2.clientSecret ? { clientSecret: registered2.clientSecret } : {},
47815
- authServer: discovered.authServer
48766
+ authServer: discovered.authServer,
48767
+ /* PIN THE PORT WE JUST REGISTERED WITH.
48768
+ *
48769
+ * RFC 8252 s7.3 obliges an authorization server to accept any port on a
48770
+ * loopback redirect, and most do -- but not all, and the ones that do
48771
+ * not fail in the worst possible way. Atlassian's `auth.atlassian.com`
48772
+ * (Trello's authorization server, distinct from `mcp.atlassian.com`)
48773
+ * matches the redirect URI exactly. Registering with one ephemeral port
48774
+ * and then authorizing with a fresh one on the next sign-in produced
48775
+ * "The app's callback URL is invalid", from the vendor's own page,
48776
+ * BEFORE any token exchange -- so nothing came back for the
48777
+ * invalid_client recovery below to catch, and the flow simply timed out
48778
+ * waiting for a redirect that was never going to arrive.
48779
+ *
48780
+ * Persisting it costs nothing where the port is ignored, and is the
48781
+ * whole difference where it is not. The field already existed for
48782
+ * operator-set ports (`corenel mcp set-client --redirect-port`); it was
48783
+ * read here and never written by this path. */
48784
+ redirectPort: port
47816
48785
  });
47817
48786
  }
47818
48787
  if (!registered2) {
@@ -47833,7 +48802,7 @@ async function loginToMcpServer(stateDir, server, deps = {}) {
47833
48802
  resource: discovered.resource
47834
48803
  });
47835
48804
  deps.onAuthUrl?.(authUrl);
47836
- const redirectP = awaitRedirect(port, state);
48805
+ const redirectP = awaitRedirect(port, state, { signal: redirectAbort.signal });
47837
48806
  redirectP.catch(() => {
47838
48807
  });
47839
48808
  await openBrowserFn(authUrl);
@@ -47855,6 +48824,7 @@ async function loginToMcpServer(stateDir, server, deps = {}) {
47855
48824
  await writeMcpCredential(stateDir, server.name, cred);
47856
48825
  return { ok: true };
47857
48826
  } catch (e2) {
48827
+ redirectAbort.abort();
47858
48828
  const message = e2 instanceof Error ? e2.message : String(e2);
47859
48829
  if (/invalid_client|unauthorized_client|client.{0,20}not.{0,10}found/i.test(message)) {
47860
48830
  await clearMcpClient(stateDir, server.name).catch(() => {
@@ -48351,7 +49321,7 @@ function persistAddedRoots(controller, stateDir, log) {
48351
49321
  import { spawn as spawn7 } from "node:child_process";
48352
49322
  import { promises as fs16 } from "node:fs";
48353
49323
  import { join as join20, resolve as resolve13 } from "node:path";
48354
- var DEFAULT_TIMEOUT_MS5 = 10 * 6e4;
49324
+ var DEFAULT_TIMEOUT_MS4 = 10 * 6e4;
48355
49325
  var ALLOWED_SCHEMES = ["https://", "ssh://", "git+ssh://"];
48356
49326
  var SCP_LIKE = /^[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+:[A-Za-z0-9_./~-]+$/;
48357
49327
  function assertCloneUrl(url) {
@@ -48415,8 +49385,8 @@ function makeGitRunner(bin = "git") {
48415
49385
  const timer = setTimeout(() => {
48416
49386
  child.kill("SIGKILL");
48417
49387
  settle({ code: -1, stdout, stderr: `${stderr}
48418
- git timed out after ${opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS5}ms` });
48419
- }, opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS5);
49388
+ git timed out after ${opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS4}ms` });
49389
+ }, opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS4);
48420
49390
  timer.unref?.();
48421
49391
  child.on("error", (e2) => {
48422
49392
  clearTimeout(timer);
@@ -48525,7 +49495,7 @@ var RpcError = class extends Error {
48525
49495
  if (shape.data !== void 0) this.data = shape.data;
48526
49496
  }
48527
49497
  };
48528
- function isRecord2(v2) {
49498
+ function isRecord3(v2) {
48529
49499
  return typeof v2 === "object" && v2 !== null && !Array.isArray(v2);
48530
49500
  }
48531
49501
  var JsonRpcPeer = class {
@@ -48563,7 +49533,7 @@ var JsonRpcPeer = class {
48563
49533
  this.log(`acp: dropped unparseable frame (${line.length} bytes)`);
48564
49534
  return;
48565
49535
  }
48566
- if (!isRecord2(msg)) {
49536
+ if (!isRecord3(msg)) {
48567
49537
  this.log("acp: dropped non-object frame");
48568
49538
  return;
48569
49539
  }
@@ -48585,7 +49555,7 @@ var JsonRpcPeer = class {
48585
49555
  return;
48586
49556
  }
48587
49557
  this.pending.delete(id);
48588
- if ("error" in msg && isRecord2(msg.error)) {
49558
+ if ("error" in msg && isRecord3(msg.error)) {
48589
49559
  const e2 = msg.error;
48590
49560
  p2.reject(new RpcError({
48591
49561
  code: typeof e2.code === "number" ? e2.code : RPC_INTERNAL_ERROR,
@@ -49477,7 +50447,6 @@ function textOf(prompt) {
49477
50447
  }
49478
50448
 
49479
50449
  // src/acp/agentCli.ts
49480
- init_store();
49481
50450
  function elog(line) {
49482
50451
  process.stderr.write(`${line}
49483
50452
  `);
@@ -49525,434 +50494,239 @@ async function runAcpAgent(argv) {
49525
50494
  });
49526
50495
  }
49527
50496
 
49528
- // src/cli.ts
49529
- init_cli();
49530
- init_store();
49531
- init_fileService();
49532
- import { relative as relative7, resolve as resolvePath } from "node:path";
50497
+ // src/auth/login.ts
50498
+ import { createServer as createServer2 } from "node:http";
49533
50499
 
49534
- // src/mcp/httpClient.ts
49535
- var PROTOCOL_VERSION = "2025-06-18";
49536
- var CLIENT_INFO = { name: "corenel", version: "0.2.0" };
49537
- var SESSION_HEADER = "Mcp-Session-Id";
49538
- var McpHttpClient = class {
49539
- constructor(server, auth, fetchImpl = fetch) {
49540
- this.server = server;
49541
- this.auth = auth;
49542
- this.fetchImpl = fetchImpl;
49543
- }
49544
- nextRequestId = 1;
49545
- sessionId;
49546
- async initialize() {
49547
- await this.send("initialize", {
49548
- protocolVersion: PROTOCOL_VERSION,
49549
- capabilities: {},
49550
- clientInfo: CLIENT_INFO
49551
- });
49552
- }
49553
- async listTools() {
49554
- const result = await this.send("tools/list", {});
49555
- const tools = isRecord3(result) ? result.tools : void 0;
49556
- if (!Array.isArray(tools)) return [];
49557
- return tools.filter(isMcpTool);
49558
- }
49559
- async callTool(name, args) {
49560
- const result = await this.send("tools/call", { name, arguments: args });
49561
- const record = isRecord3(result) ? result : {};
49562
- return { content: record.content, isError: record.isError === true };
49563
- }
49564
- /** Send one JSON-RPC request, retrying exactly once on a 401 that the
49565
- * AuthProvider says it recovered from. `isRetry` prevents a second retry
49566
- * loop -- a credential that fails twice is refused, not flaky. */
49567
- async send(method, params, isRetry = false) {
49568
- const headers = await this.buildHeaders();
49569
- const id = this.nextRequestId++;
49570
- const res = await this.request(headers, id, method, params);
49571
- if (res.status === 401) {
49572
- if (!isRetry && await this.auth.onUnauthorized()) {
49573
- return this.send(method, params, true);
49574
- }
49575
- throw new Error(
49576
- `MCP server "${this.server.name}" refused the credential (401) -- run: corenel mcp login ${this.server.name}`
49577
- );
49578
- }
49579
- const sessionId = res.headers.get(SESSION_HEADER);
49580
- if (sessionId) this.sessionId = sessionId;
49581
- if (!res.ok) {
49582
- throw new Error(`MCP server "${this.server.name}" returned HTTP ${res.status}`);
49583
- }
49584
- const contentType = res.headers.get("content-type") ?? "";
49585
- let body2;
49586
- if (contentType.includes("text/event-stream")) {
49587
- body2 = await this.readSseResponse(res, id);
49588
- } else if (contentType.includes("application/json")) {
49589
- body2 = await res.json();
49590
- } else {
49591
- throw new Error(
49592
- `MCP server "${this.server.name}" answered with an unexpected content-type: "${contentType || "(none)"}"`
49593
- );
49594
- }
49595
- if (body2.error) {
49596
- throw new Error(`MCP server "${this.server.name}" returned error ${body2.error.code}: ${body2.error.message}`);
49597
- }
49598
- return body2.result;
49599
- }
49600
- /** Read an SSE body until the JSON-RPC response for `id` arrives.
49601
- *
49602
- * Events are separated by a blank line; an event's `data:` lines are joined
49603
- * with newlines, per the SSE spec, and parsed as one JSON-RPC message.
49604
- * Notifications (no `id`) and responses to other ids are skipped. The reader
49605
- * is cancelled once the answer is in, so a server that keeps the stream open
49606
- * after replying cannot hold this call open with it. */
49607
- async readSseResponse(res, id) {
49608
- if (!res.body) {
49609
- throw new Error(`MCP server "${this.server.name}" closed the stream without a response`);
49610
- }
49611
- const reader = res.body.getReader();
49612
- const decoder = new TextDecoder();
49613
- let buffer = "";
49614
- try {
49615
- for (; ; ) {
49616
- const { done, value } = await reader.read();
49617
- if (value) buffer += decoder.decode(value, { stream: true });
49618
- buffer = buffer.replace(/\r\n/g, "\n");
49619
- let boundary = buffer.indexOf("\n\n");
49620
- while (boundary !== -1) {
49621
- const event = buffer.slice(0, boundary);
49622
- buffer = buffer.slice(boundary + 2);
49623
- const match = this.matchSseEvent(event, id);
49624
- if (match) return match;
49625
- boundary = buffer.indexOf("\n\n");
49626
- }
49627
- if (done) {
49628
- const tail3 = buffer.trim() ? this.matchSseEvent(buffer, id) : null;
49629
- if (tail3) return tail3;
49630
- throw new Error(`MCP server "${this.server.name}" closed the stream without a response`);
49631
- }
49632
- }
49633
- } finally {
49634
- reader.cancel().catch(() => {
49635
- });
49636
- }
49637
- }
49638
- /** The JSON-RPC response in one SSE event, if it answers `id`; else null. */
49639
- matchSseEvent(event, id) {
49640
- const data = event.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
49641
- if (!data) return null;
49642
- let parsed;
49643
- try {
49644
- parsed = JSON.parse(data);
49645
- } catch {
49646
- return null;
49647
- }
49648
- if (!isRecord3(parsed) || parsed.id !== id) return null;
49649
- if (!("result" in parsed) && !("error" in parsed)) return null;
49650
- return parsed;
49651
- }
49652
- /** The bare `fetch()` call, wrapped -- every OTHER throw in `send` already
49653
- * names the server; this is the one call whose own failures (DNS miss,
49654
- * connection refused, TLS failure) never reach `send`'s response-handling
49655
- * at all. Left unguarded, node's bare `fetch failed` -- naming neither the
49656
- * URL nor the actual cause -- propagates straight out of `send`, into
49657
- * `registry.ts`'s `status().error`, and out of `doctor`, the one command
49658
- * whose entire job is diagnosis. `err.cause` carries the real reason
49659
- * (ECONNREFUSED, ENOTFOUND, a TLS alert); undici puts it there rather than
49660
- * in `err.message`. */
49661
- async request(headers, id, method, params) {
49662
- try {
49663
- return await this.fetchImpl(this.server.url, {
49664
- method: "POST",
49665
- headers,
49666
- body: JSON.stringify({ jsonrpc: "2.0", id, method, params })
49667
- });
49668
- } catch (err) {
49669
- const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : err instanceof Error ? err.message : String(err);
49670
- throw new Error(`MCP server "${this.server.name}" (${this.server.url}) is unreachable: ${cause}`);
49671
- }
49672
- }
49673
- async buildHeaders() {
49674
- const headers = new Headers();
49675
- headers.set("Content-Type", "application/json");
49676
- headers.set("Accept", "application/json, text/event-stream");
49677
- for (const h2 of await this.auth.headers()) headers.set(h2.name, h2.value);
49678
- if (this.sessionId) headers.set(SESSION_HEADER, this.sessionId);
49679
- return headers;
49680
- }
49681
- };
49682
- function isRecord3(value) {
49683
- return typeof value === "object" && value !== null;
50500
+ // src/auth/pkce.ts
50501
+ import { createHash as createHash4, randomBytes as randomBytes6 } from "node:crypto";
50502
+ function base64url2(buf) {
50503
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
49684
50504
  }
49685
- function isMcpTool(value) {
49686
- return isRecord3(value) && typeof value.name === "string" && "inputSchema" in value;
50505
+ function createVerifier(bytes = 32) {
50506
+ return base64url2(randomBytes6(bytes));
50507
+ }
50508
+ function challengeFor(verifier) {
50509
+ return base64url2(createHash4("sha256").update(verifier).digest());
50510
+ }
50511
+ function createState() {
50512
+ return base64url2(randomBytes6(16));
50513
+ }
50514
+ function createPkce() {
50515
+ const verifier = createVerifier();
50516
+ return { verifier, challenge: challengeFor(verifier), method: "S256", state: createState() };
50517
+ }
50518
+ function authorizeUrl(p2) {
50519
+ const u2 = new URL("/oauth/authorize", p2.issuer);
50520
+ u2.searchParams.set("response_type", "code");
50521
+ u2.searchParams.set("client_id", p2.clientId);
50522
+ u2.searchParams.set("redirect_uri", p2.redirectUri);
50523
+ u2.searchParams.set("code_challenge", p2.pkce.challenge);
50524
+ u2.searchParams.set("code_challenge_method", p2.pkce.method);
50525
+ u2.searchParams.set("state", p2.pkce.state);
50526
+ u2.searchParams.set("scope", p2.scope ?? "profile email");
50527
+ return u2.toString();
50528
+ }
50529
+ function stateMatches(expected, got) {
50530
+ if (!got || got.length !== expected.length) return false;
50531
+ let diff = 0;
50532
+ for (let i2 = 0; i2 < expected.length; i2++) diff |= expected.charCodeAt(i2) ^ got.charCodeAt(i2);
50533
+ return diff === 0;
49687
50534
  }
49688
50535
 
49689
- // src/mcp/auth.ts
49690
- function authProviderFor(stateDir, server, fetchImpl = fetch, accountToken = async () => null) {
49691
- async function doRefresh(cred) {
49692
- if (!cred.refreshToken) {
49693
- throw new Error(`"${server.name}" has no refresh token on file -- run: corenel mcp login ${server.name}`);
50536
+ // src/auth/login.ts
50537
+ var DEFAULT_TIMEOUT_MS5 = 5 * 60 * 1e3;
50538
+ var PAGE_OK2 = `<!doctype html><meta charset="utf-8"><title>Signed in</title>
50539
+ <body style="font:16px system-ui;padding:3rem;text-align:center">
50540
+ <h1>Signed in to Corenel</h1><p>You can close this tab and return to your terminal.</p></body>`;
50541
+ var PAGE_FAIL2 = `<!doctype html><meta charset="utf-8"><title>Sign-in failed</title>
50542
+ <body style="font:16px system-ui;padding:3rem;text-align:center">
50543
+ <h1>Sign-in failed</h1><p>Return to your terminal for details.</p></body>`;
50544
+ function awaitCallback(pkce, port, timeoutMs) {
50545
+ let resolveUri;
50546
+ let resolveResult;
50547
+ let rejectResult;
50548
+ const redirectUri = new Promise((res) => {
50549
+ resolveUri = res;
50550
+ });
50551
+ const result = new Promise((res, rej) => {
50552
+ resolveResult = res;
50553
+ rejectResult = rej;
50554
+ });
50555
+ const server = createServer2((req, res) => {
50556
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
50557
+ if (url.pathname !== "/callback") {
50558
+ res.writeHead(404).end();
50559
+ return;
49694
50560
  }
49695
- const meta = await discoverAuthMetadata(cred.authServer, fetchImpl);
49696
- const refreshed = await refresh(meta, cred, fetchImpl);
49697
- await writeMcpCredential(stateDir, server.name, refreshed);
49698
- return refreshed;
49699
- }
49700
- return {
49701
- async headers() {
49702
- const cred = await readMcpCredential(stateDir, server.name);
49703
- if (!cred && !server.hosted) {
49704
- throw new Error(`No credential stored for MCP server "${server.name}" -- run: corenel mcp login ${server.name} (or set-header, for a static token).`);
49705
- }
49706
- const mcpHeaders = !cred ? [] : cred.kind === "headers" ? cred.headers : [{ name: "Authorization", value: `Bearer ${(isExpired(cred, Date.now()) ? await doRefresh(cred) : cred).accessToken}` }];
49707
- if (!server.hosted) return mcpHeaders;
49708
- const moved = mcpHeaders.map((h2) => h2.name.toLowerCase() === "authorization" ? { name: "X-MCP-Authorization", value: h2.value } : h2);
49709
- const account = await accountToken();
49710
- return account ? [...moved, { name: "Authorization", value: `Bearer ${account}` }] : moved;
49711
- },
49712
- async onUnauthorized() {
49713
- const cred = await readMcpCredential(stateDir, server.name);
49714
- if (!cred || cred.kind !== "oauth") return false;
49715
- try {
49716
- await doRefresh(cred);
49717
- return true;
49718
- } catch {
49719
- return false;
49720
- }
50561
+ const err = url.searchParams.get("error");
50562
+ const code2 = url.searchParams.get("code");
50563
+ const state = url.searchParams.get("state");
50564
+ if (!stateMatches(pkce.state, state)) {
50565
+ res.writeHead(400, { "content-type": "text/html" }).end(PAGE_FAIL2);
50566
+ rejectResult(new Error("state mismatch \u2014 the callback did not match this sign-in request"));
50567
+ return;
49721
50568
  }
49722
- };
49723
- }
49724
-
49725
- // src/mcp/registry.ts
49726
- init_store();
49727
-
49728
- // src/mcp/timedFetch.ts
49729
- var AUTH_REQUEST_TIMEOUT_MS = 15e3;
49730
- function timedFetch(ms, inner = fetch) {
49731
- return async (input, init2 = {}) => {
49732
- const deadline = AbortSignal.timeout(ms);
49733
- const signal = init2.signal ? AbortSignal.any([init2.signal, deadline]) : deadline;
49734
- try {
49735
- return await inner(input, { ...init2, signal });
49736
- } catch (err) {
49737
- if (deadline.aborted && !init2.signal?.aborted) {
49738
- throw new Error(`no answer within ${ms / 1e3}s`, { cause: err });
49739
- }
49740
- throw err;
50569
+ if (err) {
50570
+ res.writeHead(400, { "content-type": "text/html" }).end(PAGE_FAIL2);
50571
+ rejectResult(new Error(`authorization failed: ${err}`));
50572
+ return;
49741
50573
  }
50574
+ if (!code2) {
50575
+ res.writeHead(400, { "content-type": "text/html" }).end(PAGE_FAIL2);
50576
+ rejectResult(new Error("callback carried no authorization code"));
50577
+ return;
50578
+ }
50579
+ res.writeHead(200, { "content-type": "text/html" }).end(PAGE_OK2);
50580
+ resolveResult({ code: code2 });
50581
+ });
50582
+ const timer = setTimeout(() => {
50583
+ rejectResult(new Error("timed out waiting for the browser callback"));
50584
+ }, timeoutMs);
50585
+ timer.unref?.();
50586
+ const close2 = () => {
50587
+ clearTimeout(timer);
50588
+ server.close();
49742
50589
  };
50590
+ void result.finally(close2).catch(() => {
50591
+ });
50592
+ server.listen(port, "127.0.0.1", () => {
50593
+ const addr = server.address();
50594
+ const bound = typeof addr === "object" && addr ? addr.port : port;
50595
+ resolveUri(`http://127.0.0.1:${bound}/callback`);
50596
+ });
50597
+ return { redirectUri, result, close: close2 };
49743
50598
  }
49744
-
49745
- // src/mcp/registry.ts
49746
- async function accountTokenFor(stateDir) {
49747
- try {
49748
- const cred = await resolveCredential(process.env, stateDir);
49749
- return cred.token ?? null;
49750
- } catch {
49751
- return null;
49752
- }
49753
- }
49754
- function defaultClientFactory(stateDir, server) {
49755
- return new McpHttpClient(
49756
- server,
49757
- authProviderFor(stateDir, server, timedFetch(AUTH_REQUEST_TIMEOUT_MS), () => accountTokenFor(stateDir))
50599
+ async function login(deps) {
50600
+ const log = deps.log ?? (() => void 0);
50601
+ const now2 = deps.now ?? Date.now;
50602
+ const pkce = createPkce();
50603
+ const { redirectUri: uriP, result, close: close2 } = awaitCallback(
50604
+ pkce,
50605
+ deps.config.port ?? 0,
50606
+ deps.timeoutMs ?? DEFAULT_TIMEOUT_MS5
49758
50607
  );
49759
- }
49760
- var SERVER_CONNECT_TIMEOUT_MS = 3e4;
49761
- var McpRegistry = class {
49762
- constructor(stateDir, clientFactory = defaultClientFactory) {
49763
- this.stateDir = stateDir;
49764
- this.clientFactory = clientFactory;
49765
- }
49766
- connected = /* @__PURE__ */ new Map();
49767
- statuses = /* @__PURE__ */ new Map();
49768
- // The order the operator wrote servers in daemon.json, captured once per
49769
- // connectAll() -- `tools()` walks THIS, never `connected`'s Map iteration
49770
- // order. `connectOne` runs concurrently for every server (Promise.all), so
49771
- // Map insertion order is race order: whichever server's initialize+list
49772
- // happened to resolve first. That is fine for `connected` (a lookup table,
49773
- // order never read) but `tools()` is a list a model/doctor/policy consumes
49774
- // in order, and race order means a different tool ordering on every daemon
49775
- // boot -- exactly the kind of bug that surfaces far from here and is never
49776
- // traced back. Configured order is also information the operator supplied
49777
- // deliberately; sorting alphabetically would discard it.
49778
- serverOrder = [];
49779
- /**
49780
- * Connect to every configured server and cache its tools. Never throws --
49781
- * a server that fails to initialize, or fails listing tools, is recorded
49782
- * in `status()` with zero tools and the daemon keeps starting.
49783
- */
49784
- async connectAll(opts = {}) {
49785
- this.connected.clear();
49786
- this.statuses.clear();
49787
- const servers = await readMcpServers(this.stateDir);
49788
- this.serverOrder = servers.map((s2) => s2.name);
49789
- const timeoutMs = opts.timeoutMs ?? SERVER_CONNECT_TIMEOUT_MS;
49790
- await Promise.all(servers.map((server) => this.connectOne(server, timeoutMs, opts.onServer)));
49791
- }
49792
- /* BOUNDED, AND SETTLED EXACTLY ONCE.
49793
- *
49794
- * Every server connects under one Promise.all, and daemon startup awaits it.
49795
- * With no bound, one stalled server -- measured: an expired Microsoft 365
49796
- * token refreshing against a cold service, still waiting at 60 seconds --
49797
- * left the terminal silent after `mirrors sessions`, indistinguishable from a
49798
- * crash.
49799
- *
49800
- * The deadline covers the whole connect (any credential refresh, initialize,
49801
- * tools/list), not each request, because several individually bounded steps
49802
- * in a row can still add up to a hang. The attempt that loses the race keeps
49803
- * running; its late result is deliberately ignored, so a server reported as
49804
- * timed out cannot quietly become connected afterwards -- status would then
49805
- * tell doctor one thing and the registered tools another. */
49806
- async connectOne(server, timeoutMs, onServer) {
49807
- let timer;
49808
- const deadline = new Promise((resolve14) => {
49809
- timer = setTimeout(() => resolve14("timeout"), timeoutMs);
50608
+ try {
50609
+ const redirectUri = await uriP;
50610
+ const url = authorizeUrl({
50611
+ issuer: deps.config.issuer,
50612
+ clientId: deps.config.clientId,
50613
+ redirectUri,
50614
+ pkce,
50615
+ ...deps.config.scope ? { scope: deps.config.scope } : {}
49810
50616
  });
49811
- const attempt = (async () => {
49812
- const client = this.clientFactory(this.stateDir, server);
49813
- await client.initialize();
49814
- const tools = await client.listTools();
49815
- return { client, tools };
49816
- })();
49817
- attempt.catch(() => {
50617
+ log("Opening your browser to sign in...");
50618
+ log(url);
50619
+ await deps.openBrowser(url);
50620
+ const { code: code2 } = await result;
50621
+ const tokens = await deps.exchange({
50622
+ issuer: deps.config.issuer,
50623
+ clientId: deps.config.clientId,
50624
+ code: code2,
50625
+ verifier: pkce.verifier,
50626
+ redirectUri
49818
50627
  });
49819
- try {
49820
- const outcome = await Promise.race([attempt, deadline]);
49821
- if (outcome === "timeout") {
49822
- this.statuses.set(server.name, { connected: false, toolCount: 0, error: `timed out after ${timeoutMs / 1e3}s` });
49823
- } else {
49824
- this.connected.set(server.name, outcome);
49825
- this.statuses.set(server.name, { connected: true, toolCount: outcome.tools.length });
49826
- }
49827
- } catch (err) {
49828
- this.statuses.set(server.name, {
49829
- connected: false,
49830
- toolCount: 0,
49831
- error: err instanceof Error ? err.message : String(err)
49832
- });
49833
- } finally {
49834
- clearTimeout(timer);
49835
- }
49836
- const settled = this.statuses.get(server.name);
49837
- if (settled) onServer?.({ server: server.name, ...settled });
49838
- }
49839
- /** Every tool from every connected server, paired with the server it came
49840
- * from, in CONFIGURED order (`serverOrder`) -- not the order servers
49841
- * happened to finish connecting in. Within a server, tools stay in the
49842
- * order that server returned them. A server with no entry in `connected`
49843
- * either failed to connect or reported zero tools -- `status()` tells the
49844
- * two apart. */
49845
- tools() {
49846
- const out = [];
49847
- for (const server of this.serverOrder) {
49848
- const entry = this.connected.get(server);
49849
- if (!entry) continue;
49850
- for (const tool of entry.tools) out.push({ server, tool });
49851
- }
49852
- return out;
49853
- }
49854
- /**
49855
- * Call one tool on one server. Fails immediately, naming the server, when
49856
- * that server never connected -- there is nothing to route the call
49857
- * through, and pretending otherwise would either stall waiting on a
49858
- * connection that will never appear or return an empty result the caller
49859
- * could mistake for a genuine (if empty) tool response.
49860
- */
49861
- async call(server, tool, args) {
49862
- const entry = this.connected.get(server);
49863
- if (!entry) {
49864
- throw new Error(`MCP server "${server}" is not connected -- run: corenel mcp doctor`);
49865
- }
49866
- return entry.client.callTool(tool, args);
49867
- }
49868
- /** Per-server connection state, for `doctor` (Task 10) to print. A server
49869
- * with `connected: false` always carries `error` -- there is no code path
49870
- * that records a failure without a reason, because "no tools" alone tells
49871
- * the reader nothing about what to fix. */
49872
- status() {
49873
- const out = [];
49874
- for (const server of this.serverOrder) {
49875
- const s2 = this.statuses.get(server);
49876
- if (s2) out.push({ server, ...s2 });
49877
- }
49878
- return out;
50628
+ if (!tokens.access_token) throw new Error("the token exchange returned no access token");
50629
+ const cred = {
50630
+ accessToken: tokens.access_token,
50631
+ ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {},
50632
+ ...typeof tokens.expires_in === "number" ? { expiresAt: now2() + tokens.expires_in * 1e3 } : {},
50633
+ ...tokens.account ? { account: tokens.account } : {},
50634
+ ...deps.apiBase ? { apiBase: deps.apiBase } : {}
50635
+ };
50636
+ await saveCredential(cred, deps.stateDir);
50637
+ return {
50638
+ ...cred.account ? { account: cred.account } : {},
50639
+ ...cred.expiresAt ? { expiresAt: cred.expiresAt } : {}
50640
+ };
50641
+ } finally {
50642
+ close2();
49879
50643
  }
49880
- };
49881
-
49882
- // src/mcp/toolNames.ts
49883
- init_protocol();
50644
+ }
49884
50645
 
49885
- // src/mcp/asAgentTools.ts
49886
- function isPlainObject3(v2) {
49887
- return typeof v2 === "object" && v2 !== null && !Array.isArray(v2);
50646
+ // src/auth/cli.ts
50647
+ init_tools_node();
50648
+ function oauthConfigFromEnv(env = process.env) {
50649
+ const issuer = env.CORENEL_OAUTH_ISSUER?.trim();
50650
+ const clientId = env.CORENEL_OAUTH_CLIENT_ID?.trim();
50651
+ if (!issuer || !clientId) return null;
50652
+ return { issuer, clientId };
49888
50653
  }
49889
- function toParameters(inputSchema) {
49890
- if (isPlainObject3(inputSchema)) return inputSchema;
49891
- return { type: "object", properties: {} };
50654
+ async function exchangeCode2(p2) {
50655
+ const res = await fetch(new URL("/oauth/token", p2.issuer), {
50656
+ method: "POST",
50657
+ headers: { "content-type": "application/x-www-form-urlencoded" },
50658
+ body: new URLSearchParams({
50659
+ grant_type: "authorization_code",
50660
+ client_id: p2.clientId,
50661
+ code: p2.code,
50662
+ code_verifier: p2.verifier,
50663
+ redirect_uri: p2.redirectUri
50664
+ }).toString()
50665
+ });
50666
+ if (!res.ok) {
50667
+ throw new Error(`token exchange failed (HTTP ${res.status})`);
50668
+ }
50669
+ return await res.json();
49892
50670
  }
49893
- function formatContent(content) {
49894
- if (typeof content === "string") return content;
49895
- if (Array.isArray(content)) {
49896
- const parts = content.map((block) => {
49897
- if (isPlainObject3(block) && typeof block.text === "string") return block.text;
49898
- return JSON.stringify(block);
50671
+ async function runLogin(stateDir, apiBase2) {
50672
+ const config = oauthConfigFromEnv();
50673
+ if (!config) {
50674
+ console.error("Sign-in is not configured on this build.");
50675
+ console.error("Set CORENEL_OAUTH_ISSUER and CORENEL_OAUTH_CLIENT_ID, or use " + TOKEN_ENV2 + " for headless use.");
50676
+ return 1;
50677
+ }
50678
+ try {
50679
+ const res = await login({
50680
+ config,
50681
+ openBrowser,
50682
+ exchange: exchangeCode2,
50683
+ log: (l2) => console.error(l2),
50684
+ ...stateDir ? { stateDir } : {},
50685
+ // Recorded so the daemon does not have to guess later which API this
50686
+ // credential belongs to -- see LoginDeps.apiBase.
50687
+ ...apiBase2 ? { apiBase: apiBase2 } : {}
49899
50688
  });
49900
- return parts.join("\n");
50689
+ console.error(`Signed in${res.account ? ` as ${res.account}` : ""}.`);
50690
+ return 0;
50691
+ } catch (err) {
50692
+ console.error(`Sign-in failed: ${err instanceof Error ? err.message : String(err)}`);
50693
+ return 1;
49901
50694
  }
49902
- if (content == null) return "";
49903
- return JSON.stringify(content);
49904
50695
  }
49905
- function asAgentTools(registry2) {
49906
- const out = [];
49907
- for (const { server, tool } of registry2.tools()) {
49908
- if (!canEncodeMcpWireName(server, tool.name)) {
49909
- console.error(
49910
- `MCP tool refused: server "${server}" tool "${tool.name}" cannot be encoded as a wire name (unsupported characters or too long) -- this tool is not available to the model`
49911
- );
49912
- continue;
49913
- }
49914
- out.push(toAgentTool(registry2, server, tool));
50696
+ async function runLogout(stateDir, env = process.env) {
50697
+ const before = await resolveCredential(env, stateDir);
50698
+ await clearCredential(stateDir);
50699
+ console.error("Signed out. (Paired devices are unaffected - run `hosts forget` to unpair.)");
50700
+ console.error(` Cleared ${tokenFilePath(stateDir)} and ${credentialFilePath(stateDir)}`);
50701
+ if (before.source === "none") console.error(" Nothing was stored - this machine was already signed out.");
50702
+ if (env[TOKEN_ENV2]?.trim()) {
50703
+ console.error(` NOTE: ${TOKEN_ENV2} is still set in this environment, so this machine is STILL signed in.`);
49915
50704
  }
49916
- return out;
50705
+ return 0;
49917
50706
  }
49918
- function toAgentTool(registry2, server, tool) {
49919
- const name = mcpWireName(server, tool.name);
49920
- const address = mcpToolAddress(server, tool.name);
49921
- return {
49922
- name,
49923
- namespace: "mcp",
49924
- description: tool.description?.trim() || `MCP tool "${tool.name}" on server "${server}" (${address}).`,
49925
- parameters: toParameters(tool.inputSchema),
49926
- mutates: true,
49927
- async run(args) {
49928
- const result = await registry2.call(server, tool.name, args);
49929
- const text = formatContent(result.content);
49930
- return result.isError ? `Error: ${text}` : text;
49931
- }
49932
- };
50707
+ function sourceLabel(source) {
50708
+ return source === "env" ? `${TOKEN_ENV2} environment variable` : source === "token-file" ? "token file" : source === "credential-file" ? "credential file (auth.json)" : "none";
49933
50709
  }
49934
-
49935
- // src/mcp/policy.ts
49936
- function mcpCallAllowed(address, policy) {
49937
- const allow = policy.tools?.allow ?? [];
49938
- if (!matchesAnyGlob(address, allow)) {
49939
- return { ok: false, reason: `${address} is not allowed - add it with: corenel config add mcpAllow ${address}` };
49940
- }
49941
- const service = new PermissionService({
49942
- policy,
49943
- defaultForReadOnly: "allow",
49944
- defaultForMutating: "allow"
49945
- });
49946
- const level = service.resolveLevel({ name: address, address, mutates: true });
49947
- if (level !== "allow") {
49948
- return {
49949
- ok: false,
49950
- reason: `${address} resolves to permission "${level}" - MCP tools never prompt; set it to "allow" or remove the rule`
49951
- };
50710
+ async function runAuthStatus(stateDir, env = process.env) {
50711
+ const r2 = await resolveCredential(env, stateDir);
50712
+ if (!r2.token) {
50713
+ console.error("Not signed in. Run `corenel login`, or set " + TOKEN_ENV2 + ".");
50714
+ console.error(` Looked in ${tokenFilePath(stateDir)}`);
50715
+ console.error(` Looked in ${credentialFilePath(stateDir)}`);
50716
+ return 1;
49952
50717
  }
49953
- return { ok: true };
50718
+ console.error(`Signed in (${r2.source === "env" ? sourceLabel(r2.source) : r2.path}).`);
50719
+ console.error(` account ${r2.account ?? "not recorded in this store - run `corenel login` to record it"}`);
50720
+ console.error(` token ${maskToken(r2.token)}`);
50721
+ console.error(` source ${sourceLabel(r2.source)}`);
50722
+ console.error(` expiry ${r2.expired ? `EXPIRED at ${new Date(r2.expiresAt ?? 0).toISOString()} - run login again` : r2.expiresAt ? new Date(r2.expiresAt).toISOString() : "not recorded in this store"}`);
50723
+ return r2.expired ? 1 : 0;
49954
50724
  }
49955
50725
 
50726
+ // src/cli.ts
50727
+ init_fileService();
50728
+ import { relative as relative7, resolve as resolvePath } from "node:path";
50729
+
49956
50730
  // src/mcp/daemonTools.ts
49957
50731
  function buildMcpPolicy(fileConfig) {
49958
50732
  return {
@@ -49964,24 +50738,7 @@ function buildMcpPolicy(fileConfig) {
49964
50738
  };
49965
50739
  }
49966
50740
  function registerMcpTools(registry2, policy) {
49967
- const base = asAgentTools(registry2);
49968
- const byWireName = new Map(base.map((t2) => [t2.name, t2]));
49969
- const wrapped = [];
49970
- for (const { server, tool } of registry2.tools()) {
49971
- if (!canEncodeMcpWireName(server, tool.name)) continue;
49972
- const name = mcpWireName(server, tool.name);
49973
- const original = byWireName.get(name);
49974
- if (!original) continue;
49975
- const address = mcpToolAddress(server, tool.name);
49976
- wrapped.push({
49977
- ...original,
49978
- async run(args, ctx) {
49979
- const decision = mcpCallAllowed(address, policy);
49980
- if (!decision.ok) return `Error: ${decision.reason}`;
49981
- return original.run(args, ctx);
49982
- }
49983
- });
49984
- }
50741
+ const wrapped = gatedAgentTools(registry2, policy);
49985
50742
  for (const tool of wrapped) registerTool(tool);
49986
50743
  return () => {
49987
50744
  for (const tool of wrapped) unregisterTool(tool.name);