@cleocode/caamp 2026.4.6 → 2026.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ import {
13
13
  getCanonicalSkillsDir,
14
14
  getLockFilePath,
15
15
  getPlatformLocations,
16
+ resolveProviderConfigPath,
16
17
  resolveProviderProjectPath,
17
18
  resolveProviderSkillsDirs
18
19
  } from "./chunk-364OHA2T.js";
@@ -220,8 +221,8 @@ async function linkToAgent(canonicalPath, provider, skillName, isGlobal, project
220
221
  await mkdir(targetSkillsDir, { recursive: true });
221
222
  const linkPath = join2(targetSkillsDir, skillName);
222
223
  if (existsSync2(linkPath)) {
223
- const stat2 = lstatSync(linkPath);
224
- if (stat2.isSymbolicLink()) {
224
+ const stat4 = lstatSync(linkPath);
225
+ if (stat4.isSymbolicLink()) {
225
226
  await rm(linkPath);
226
227
  } else {
227
228
  await rm(linkPath, { recursive: true });
@@ -352,8 +353,8 @@ async function snapshotSkillState(providerTargets, operation, projectDir, backup
352
353
  pathSnapshots.push({ linkPath, state: "missing" });
353
354
  continue;
354
355
  }
355
- const stat2 = lstatSync2(linkPath);
356
- if (stat2.isSymbolicLink()) {
356
+ const stat4 = lstatSync2(linkPath);
357
+ if (stat4.isSymbolicLink()) {
357
358
  pathSnapshots.push({
358
359
  linkPath,
359
360
  state: "symlink",
@@ -363,7 +364,7 @@ async function snapshotSkillState(providerTargets, operation, projectDir, backup
363
364
  }
364
365
  const backupPath = join3(backupRoot, "links", provider.id, `${skillName}-${basename(linkPath)}`);
365
366
  await mkdir2(dirname(backupPath), { recursive: true });
366
- if (stat2.isDirectory()) {
367
+ if (stat4.isDirectory()) {
367
368
  await cp2(linkPath, backupPath, { recursive: true });
368
369
  pathSnapshots.push({ linkPath, state: "directory", backupPath });
369
370
  continue;
@@ -510,15 +511,83 @@ async function updateInstructionsSingleOperation(providers, content, scope = "pr
510
511
  return summary;
511
512
  }
512
513
 
514
+ // src/core/config/caamp-config.ts
515
+ var DEFAULT_EXCLUSIVITY_MODE = "auto";
516
+ var EXCLUSIVITY_MODE_ENV_VAR = "CAAMP_EXCLUSIVITY_MODE";
517
+ var programmaticOverride = null;
518
+ var exclusivityWarningState = {
519
+ piAbsentAutoWarned: false,
520
+ explicitNonPiAutoWarned: false
521
+ };
522
+ function hasPiAbsentAutoWarned() {
523
+ return exclusivityWarningState.piAbsentAutoWarned;
524
+ }
525
+ function hasExplicitNonPiAutoWarned() {
526
+ return exclusivityWarningState.explicitNonPiAutoWarned;
527
+ }
528
+ function markPiAbsentAutoWarned() {
529
+ exclusivityWarningState.piAbsentAutoWarned = true;
530
+ }
531
+ function markExplicitNonPiAutoWarned() {
532
+ exclusivityWarningState.explicitNonPiAutoWarned = true;
533
+ }
534
+ var PiRequiredError = class extends Error {
535
+ /** LAFS-stable error code identifying this failure mode. */
536
+ code = "E_NOT_FOUND_RESOURCE";
537
+ /**
538
+ * Construct a new {@link PiRequiredError}.
539
+ *
540
+ * @param message - Human-readable failure description; defaults to a
541
+ * stable string suitable for direct CLI display.
542
+ */
543
+ constructor(message = 'caamp.exclusivityMode is set to "force-pi" but Pi is not installed. Install Pi (https://github.com/mariozechner/pi-coding-agent) or change the mode with CAAMP_EXCLUSIVITY_MODE=auto.') {
544
+ super(message);
545
+ this.name = "PiRequiredError";
546
+ }
547
+ };
548
+ function isExclusivityMode(value) {
549
+ return value === "auto" || value === "force-pi" || value === "legacy";
550
+ }
551
+ function getExclusivityMode() {
552
+ if (programmaticOverride !== null) {
553
+ return programmaticOverride;
554
+ }
555
+ const envValue = process.env[EXCLUSIVITY_MODE_ENV_VAR];
556
+ if (envValue !== void 0 && isExclusivityMode(envValue)) {
557
+ return envValue;
558
+ }
559
+ return DEFAULT_EXCLUSIVITY_MODE;
560
+ }
561
+ function setExclusivityMode(mode) {
562
+ programmaticOverride = mode;
563
+ }
564
+ function resetExclusivityModeOverride() {
565
+ programmaticOverride = null;
566
+ }
567
+
513
568
  // src/core/harness/pi.ts
514
569
  import { spawn } from "child_process";
515
570
  import { existsSync as existsSync4 } from "fs";
516
- import { cp as cp3, mkdir as mkdir3, readdir, readFile, rename, rm as rm3, writeFile } from "fs/promises";
571
+ import {
572
+ appendFile,
573
+ cp as cp3,
574
+ mkdir as mkdir3,
575
+ open,
576
+ readdir,
577
+ readFile,
578
+ rename,
579
+ rm as rm3,
580
+ stat,
581
+ writeFile
582
+ } from "fs/promises";
583
+ import { homedir as homedir2 } from "os";
584
+ import { basename as basename2, dirname as dirname2, extname, join as join5 } from "path";
585
+ import { parseDocument, validateDocument } from "@cleocode/cant";
586
+
587
+ // src/core/harness/scope.ts
517
588
  import { homedir } from "os";
518
- import { dirname as dirname2, join as join4 } from "path";
519
- var MARKER_START = "<!-- CAAMP:START -->";
520
- var MARKER_END = "<!-- CAAMP:END -->";
521
- var MARKER_PATTERN = /<!-- CAAMP:START -->[\s\S]*?<!-- CAAMP:END -->/;
589
+ import { join as join4 } from "path";
590
+ var TIER_PRECEDENCE = ["project", "user", "global"];
522
591
  function getPiAgentDir() {
523
592
  const env = process.env["PI_CODING_AGENT_DIR"];
524
593
  if (env !== void 0 && env.length > 0) {
@@ -528,6 +597,79 @@ function getPiAgentDir() {
528
597
  }
529
598
  return join4(homedir(), ".pi", "agent");
530
599
  }
600
+ function getCleoHomeDir() {
601
+ const env = process.env["CLEO_HOME"];
602
+ if (env !== void 0 && env.trim().length > 0) {
603
+ return env.trim();
604
+ }
605
+ if (process.platform === "win32") {
606
+ const localAppData = process.env["LOCALAPPDATA"];
607
+ if (localAppData !== void 0 && localAppData.length > 0) {
608
+ return join4(localAppData, "cleo", "Data");
609
+ }
610
+ return join4(homedir(), "AppData", "Local", "cleo", "Data");
611
+ }
612
+ if (process.platform === "darwin") {
613
+ return join4(homedir(), "Library", "Application Support", "cleo");
614
+ }
615
+ const xdgData = process.env["XDG_DATA_HOME"];
616
+ if (xdgData !== void 0 && xdgData.length > 0) {
617
+ return join4(xdgData, "cleo");
618
+ }
619
+ return join4(homedir(), ".local", "share", "cleo");
620
+ }
621
+ function assetDirName(kind) {
622
+ switch (kind) {
623
+ case "extensions":
624
+ return { native: "extensions", hubSuffix: "pi-extensions" };
625
+ case "prompts":
626
+ return { native: "prompts", hubSuffix: "pi-prompts" };
627
+ case "themes":
628
+ return { native: "themes", hubSuffix: "pi-themes" };
629
+ case "sessions":
630
+ return { native: "sessions", hubSuffix: "pi-sessions" };
631
+ case "cant":
632
+ return { native: "cant", hubSuffix: "pi-cant" };
633
+ }
634
+ }
635
+ function resolveTierDir(opts) {
636
+ const { tier, kind } = opts;
637
+ const names = assetDirName(kind);
638
+ if (tier === "project") {
639
+ if (opts.projectDir === void 0 || opts.projectDir.length === 0) {
640
+ throw new Error("resolveTierDir: 'project' tier requires a projectDir argument");
641
+ }
642
+ return join4(opts.projectDir, ".pi", names.native);
643
+ }
644
+ if (tier === "user") {
645
+ return join4(getPiAgentDir(), names.native);
646
+ }
647
+ return join4(getCleoHomeDir(), names.hubSuffix);
648
+ }
649
+ function resolveAllTiers(kind, projectDir) {
650
+ const out = [];
651
+ for (const tier of TIER_PRECEDENCE) {
652
+ if (tier === "project" && (projectDir === void 0 || projectDir.length === 0)) {
653
+ continue;
654
+ }
655
+ out.push({ tier, dir: resolveTierDir({ tier, kind, projectDir }) });
656
+ }
657
+ return out;
658
+ }
659
+
660
+ // src/core/harness/pi.ts
661
+ var MARKER_START = "<!-- CAAMP:START -->";
662
+ var MARKER_END = "<!-- CAAMP:END -->";
663
+ var MARKER_PATTERN = /<!-- CAAMP:START -->[\s\S]*?<!-- CAAMP:END -->/;
664
+ function getPiAgentDir2() {
665
+ const env = process.env["PI_CODING_AGENT_DIR"];
666
+ if (env !== void 0 && env.length > 0) {
667
+ if (env === "~") return homedir2();
668
+ if (env.startsWith("~/")) return join5(homedir2(), env.slice(2));
669
+ return env;
670
+ }
671
+ return join5(homedir2(), ".pi", "agent");
672
+ }
531
673
  function isPlainObject(v) {
532
674
  return typeof v === "object" && v !== null && !Array.isArray(v);
533
675
  }
@@ -550,6 +692,38 @@ async function atomicWriteJson(filePath, data) {
550
692
  `, "utf8");
551
693
  await rename(tmp, filePath);
552
694
  }
695
+ var DEFAULT_TERMINATE_GRACE_MS = 5e3;
696
+ var STDERR_RING_BUFFER_SIZE = 100;
697
+ var activeSubagents = /* @__PURE__ */ new Set();
698
+ var orphanSweeperRegistered = false;
699
+ function ensureOrphanSweeperRegistered() {
700
+ if (orphanSweeperRegistered) return;
701
+ orphanSweeperRegistered = true;
702
+ const sweeper = () => {
703
+ for (const entry of activeSubagents) {
704
+ try {
705
+ entry.terminate();
706
+ } catch {
707
+ }
708
+ }
709
+ };
710
+ process.on("exit", sweeper);
711
+ }
712
+ function generateShortId() {
713
+ const ts = Date.now().toString(36);
714
+ const rand = Math.random().toString(36).slice(2, 8);
715
+ return `${ts}${rand}`;
716
+ }
717
+ function readTerminateGraceFromSettings(settings, fallback) {
718
+ if (!isPlainObject(settings)) return fallback;
719
+ const piBlock = settings["pi"];
720
+ if (!isPlainObject(piBlock)) return fallback;
721
+ const subBlock = piBlock["subagent"];
722
+ if (!isPlainObject(subBlock)) return fallback;
723
+ const value = subBlock["terminateGraceMs"];
724
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return fallback;
725
+ return value;
726
+ }
553
727
  var PiHarness = class {
554
728
  /**
555
729
  * Construct a harness bound to a resolved Pi provider.
@@ -566,19 +740,13 @@ var PiHarness = class {
566
740
  * Resolve the skills directory for a given scope.
567
741
  */
568
742
  skillsDir(scope) {
569
- return scope.kind === "global" ? join4(getPiAgentDir(), "skills") : join4(scope.projectDir, ".pi", "skills");
570
- }
571
- /**
572
- * Resolve the extensions directory for a given scope.
573
- */
574
- extensionsDir(scope) {
575
- return scope.kind === "global" ? join4(getPiAgentDir(), "extensions") : join4(scope.projectDir, ".pi", "extensions");
743
+ return scope.kind === "global" ? join5(getPiAgentDir2(), "skills") : join5(scope.projectDir, ".pi", "skills");
576
744
  }
577
745
  /**
578
746
  * Resolve the settings.json path for a given scope.
579
747
  */
580
748
  settingsPath(scope) {
581
- return scope.kind === "global" ? join4(getPiAgentDir(), "settings.json") : join4(scope.projectDir, ".pi", "settings.json");
749
+ return scope.kind === "global" ? join5(getPiAgentDir2(), "settings.json") : join5(scope.projectDir, ".pi", "settings.json");
582
750
  }
583
751
  /**
584
752
  * Resolve the AGENTS.md instruction file path for a given scope.
@@ -589,19 +757,19 @@ var PiHarness = class {
589
757
  * auto-discovering `AGENTS.md` from the working directory upwards.
590
758
  */
591
759
  agentsMdPath(scope) {
592
- return scope.kind === "global" ? join4(getPiAgentDir(), "AGENTS.md") : join4(scope.projectDir, "AGENTS.md");
760
+ return scope.kind === "global" ? join5(getPiAgentDir2(), "AGENTS.md") : join5(scope.projectDir, "AGENTS.md");
593
761
  }
594
762
  // ── Skills ──────────────────────────────────────────────────────────
595
763
  /** {@inheritDoc Harness.installSkill} */
596
764
  async installSkill(sourcePath, skillName, scope) {
597
- const targetDir = join4(this.skillsDir(scope), skillName);
765
+ const targetDir = join5(this.skillsDir(scope), skillName);
598
766
  await rm3(targetDir, { recursive: true, force: true });
599
767
  await mkdir3(dirname2(targetDir), { recursive: true });
600
768
  await cp3(sourcePath, targetDir, { recursive: true });
601
769
  }
602
770
  /** {@inheritDoc Harness.removeSkill} */
603
771
  async removeSkill(skillName, scope) {
604
- const targetDir = join4(this.skillsDir(scope), skillName);
772
+ const targetDir = join5(this.skillsDir(scope), skillName);
605
773
  await rm3(targetDir, { recursive: true, force: true });
606
774
  }
607
775
  /** {@inheritDoc Harness.listSkills} */
@@ -646,86 +814,79 @@ ${MARKER_END}`;
646
814
  await writeFile(filePath, stripped.length === 0 ? "" : `${stripped}
647
815
  `, "utf8");
648
816
  }
649
- // ── MCP-as-extension scaffold ───────────────────────────────────────
817
+ // ── Subagent spawn (ADR-035 §D6) ────────────────────────────────────
650
818
  /**
651
- * {@inheritDoc Harness.installMcpAsExtension}
819
+ * Spawn a subagent through Pi's configured `spawnCommand` and return a
820
+ * live handle bound to the canonical streaming, attribution, and
821
+ * cleanup contract.
652
822
  *
653
823
  * @remarks
654
- * Emits a SCAFFOLD Pi extension file under `extensions/mcp-<name>.ts`.
655
- * The scaffold registers a Pi tool whose `execute` function currently
656
- * returns an "isError" payload explaining that the MCP bridge runtime
657
- * is not yet implemented. This preserves the public lifecycle surface
658
- * (install/list/remove) so orchestration code can treat the bridge as
659
- * a first-class asset while the concrete JSON-RPC runtime is built out
660
- * in a later wave.
661
- */
662
- async installMcpAsExtension(server, scope) {
663
- const dir = this.extensionsDir(scope);
664
- await mkdir3(dir, { recursive: true });
665
- const filePath = join4(dir, `mcp-${server.name}.ts`);
666
- const launchConfig = JSON.stringify(
667
- {
668
- command: server.command,
669
- args: server.args ?? [],
670
- url: server.url,
671
- env: server.env ?? {},
672
- headers: server.headers ?? {}
673
- },
674
- null,
675
- 2
676
- );
677
- const src = `// AUTO-GENERATED by @cleocode/caamp \u2014 do not edit.
678
- // MCP-as-Pi-extension bridge scaffold for "${server.name}".
679
- // TODO: implement the MCP JSON-RPC bridge. Current behavior is a stub
680
- // that logs every tool invocation. The scaffold exists so that CAAMP
681
- // can manage the extension lifecycle (install/remove/list) without
682
- // blocking on a full MCP runtime bridge.
683
-
684
- const CONFIG = ${launchConfig};
685
-
686
- export default (pi: unknown) => {
687
- const api = pi as {
688
- registerTool: (def: {
689
- name: string;
690
- label: string;
691
- description: string;
692
- parameters: unknown;
693
- execute: (...args: unknown[]) => Promise<{ type: 'text'; text: string; isError?: boolean }>;
694
- }) => void;
695
- };
696
-
697
- api.registerTool({
698
- name: ${JSON.stringify(`mcp_${server.name}`)},
699
- label: ${JSON.stringify(`MCP: ${server.name}`)},
700
- description: ${JSON.stringify(
701
- `MCP server "${server.name}" \u2014 bridge scaffold, not yet implemented.`
702
- )},
703
- parameters: { type: 'object', properties: {} },
704
- execute: async () => ({
705
- type: 'text',
706
- text: \`MCP bridge for "${server.name}" is a scaffold. Config: \${JSON.stringify(CONFIG)}\`,
707
- isError: true,
708
- }),
709
- });
710
- };
711
- `;
712
- await writeFile(filePath, src, "utf8");
713
- }
714
- // ── Subagent spawn ──────────────────────────────────────────────────
715
- /**
716
- * {@inheritDoc Harness.spawnSubagent}
824
+ * Per ADR-035 §D6 this is the **only** sanctioned subagent spawn path
825
+ * in CLEO. All historical direct `child_process.spawn` callers in
826
+ * subagent contexts (including the `cant-bridge.ts` Pi extension and
827
+ * the legacy CLEO orchestrator paths) MUST migrate to this method so
828
+ * the contract below holds uniformly. A custom biome rule banning
829
+ * raw `spawn()` from subagent code is planned for v3 cleanup but is
830
+ * intentionally NOT enforced in v2 to keep the migration incremental.
717
831
  *
718
- * @remarks
719
- * Invokes Pi's configured `spawnCommand` (e.g.
720
- * `["pi", "--mode", "json", "-p", "--no-session"]`) with the task prompt
721
- * appended as the trailing positional argument. The {@link SubagentTask.targetProviderId}
722
- * is a routing hint carried in the prompt stream; Pi's own extension
723
- * layer dispatches to the correct inner agent.
832
+ * **Streaming semantics** — Pi's `--mode json` produces line-delimited
833
+ * JSON on stdout. The harness:
834
+ *
835
+ * - Line-buffers stdout, parses each line as JSON, and forwards a
836
+ * `{ kind: 'message', subagentId, lineNumber, payload }`
837
+ * {@link SubagentStreamEvent} via {@link SubagentSpawnOptions.onStream}.
838
+ * Non-parseable lines increment a warning counter (recorded in the
839
+ * child session as `{ type: 'raw' }`) but never crash the loop.
840
+ * - Line-buffers stderr separately, forwards each line as
841
+ * `{ kind: 'stderr', subagentId, payload: { line } }`, and stores
842
+ * it in a 100-line ring buffer accessible via
843
+ * {@link SubagentHandle.recentStderr}. Stderr is **never** injected
844
+ * into the parent LLM context per ADR-035 §D6.
845
+ * - Emits a final `{ kind: 'exit', subagentId, payload: SubagentExitResult }`
846
+ * when the child terminates.
847
+ *
848
+ * **Session attribution** — Every spawn produces a child session JSONL
849
+ * file at
850
+ * `~/.pi/agent/sessions/subagents/subagent-{parentSessionId}-{taskId}.jsonl`.
851
+ * The header line records the subagentId, taskId, and parent linkage.
852
+ * When {@link SubagentTask.parentSessionPath} is supplied, a
853
+ * {@link SubagentLinkEntry} is appended to the parent session file as
854
+ * a JSONL line so listing the parent surfaces its children.
855
+ *
856
+ * **Exit propagation** — {@link SubagentHandle.exitPromise} resolves
857
+ * with `{ code, signal, childSessionPath, durationMs }` exactly once
858
+ * when the child exits. The promise NEVER rejects: failure is
859
+ * encoded by a non-zero `code`, a non-null `signal`, or partial
860
+ * output preserved in the child session file.
861
+ *
862
+ * **Cleanup** — {@link SubagentHandle.terminate} sends SIGTERM, waits
863
+ * the configured grace window, then sends SIGKILL if the child is
864
+ * still alive. The grace window is sourced from
865
+ * {@link SubagentSpawnOptions.terminateGraceMs} when supplied,
866
+ * otherwise from `settings.json:pi.subagent.terminateGraceMs`,
867
+ * otherwise from {@link DEFAULT_TERMINATE_GRACE_MS}. A
868
+ * `subagent_exit` entry with reason `terminated` is appended to the
869
+ * child session file when cleanup runs.
724
870
  *
725
- * Throws immediately when the provider entry is missing a `spawnCommand`
726
- * so callers see configuration errors early rather than at child-exit time.
871
+ * **Concurrency** Use the static helpers
872
+ * {@link PiHarness.raceSubagents} and
873
+ * {@link PiHarness.settleAllSubagents} to compose `parallel: race`
874
+ * and `parallel: settle` constructs from CANT workflows over multiple
875
+ * handles.
876
+ *
877
+ * **Orphan handling** — On the first spawn the harness registers a
878
+ * process-wide `'exit'` handler that terminates every still-active
879
+ * subagent so a parent crash never strands children.
880
+ *
881
+ * Throws immediately when the provider entry is missing a
882
+ * `spawnCommand` so callers see configuration errors early rather
883
+ * than at child-exit time.
884
+ *
885
+ * @param task - Subagent task specification.
886
+ * @param opts - Per-call streaming and cleanup overrides.
887
+ * @returns A live subagent handle.
727
888
  */
728
- async spawnSubagent(task) {
889
+ async spawnSubagent(task, opts = {}) {
729
890
  const cmd = this.provider.capabilities.spawn.spawnCommand;
730
891
  if (cmd === null || cmd.length === 0) {
731
892
  throw new Error(
@@ -736,44 +897,312 @@ export default (pi: unknown) => {
736
897
  if (typeof program !== "string" || program.length === 0) {
737
898
  throw new Error("PiHarness.spawnSubagent: invalid spawnCommand (missing program)");
738
899
  }
900
+ const taskId = task.taskId ?? generateShortId();
901
+ const parentSessionId = task.parentSessionId ?? "orphan";
902
+ const subagentId = `sub-${taskId}-${generateShortId().slice(0, 6)}`;
903
+ const childSessionPath = join5(
904
+ getPiAgentDir2(),
905
+ "sessions",
906
+ "subagents",
907
+ `subagent-${parentSessionId}-${taskId}.jsonl`
908
+ );
909
+ await mkdir3(dirname2(childSessionPath), { recursive: true });
910
+ let grace = opts.terminateGraceMs;
911
+ if (grace === void 0) {
912
+ try {
913
+ const settings = await this.readSettings({ kind: "global" });
914
+ grace = readTerminateGraceFromSettings(settings, DEFAULT_TERMINATE_GRACE_MS);
915
+ } catch {
916
+ grace = DEFAULT_TERMINATE_GRACE_MS;
917
+ }
918
+ }
919
+ if (!Number.isFinite(grace) || grace < 0) {
920
+ grace = DEFAULT_TERMINATE_GRACE_MS;
921
+ }
922
+ const startedAt = /* @__PURE__ */ new Date();
923
+ const startedAtIso = startedAt.toISOString();
924
+ const sessionHeader = {
925
+ type: "session",
926
+ version: 3,
927
+ id: subagentId,
928
+ timestamp: startedAtIso,
929
+ cwd: opts.cwd ?? task.cwd ?? process.cwd(),
930
+ parentSession: task.parentSessionId ?? null,
931
+ taskId,
932
+ childSessionPath
933
+ };
934
+ await writeFile(childSessionPath, `${JSON.stringify(sessionHeader)}
935
+ `, "utf8");
739
936
  const baseArgs = cmd.slice(1);
740
937
  const args = [...baseArgs, task.prompt];
741
938
  const child = spawn(program, args, {
742
- cwd: task.cwd,
743
- env: { ...process.env, ...task.env },
939
+ cwd: opts.cwd ?? task.cwd,
940
+ env: { ...process.env, ...task.env, ...opts.env },
744
941
  stdio: ["ignore", "pipe", "pipe"]
745
942
  });
746
- let stdout = "";
747
- let stderr = "";
943
+ let stdoutAccum = "";
944
+ let stderrAccum = "";
945
+ let stdoutBuffer = "";
946
+ let stderrBuffer = "";
947
+ let stdoutLineNumber = 0;
948
+ let nonJsonLineCount = 0;
949
+ const stderrRing = [];
950
+ const safeOnStream = (event) => {
951
+ if (opts.onStream === void 0) return;
952
+ try {
953
+ opts.onStream(event);
954
+ } catch (err) {
955
+ const message = err instanceof Error ? err.message : String(err);
956
+ stderrRing.push(`[onStream] ${message}`);
957
+ if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
958
+ }
959
+ };
960
+ const writeChildSession = (entry) => {
961
+ void appendFile(childSessionPath, `${JSON.stringify(entry)}
962
+ `, "utf8").catch(() => {
963
+ const synthetic = `[childSession] failed to append entry`;
964
+ stderrRing.push(synthetic);
965
+ if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
966
+ });
967
+ };
968
+ const flushStdoutBuffer = (final) => {
969
+ let nlIdx = stdoutBuffer.indexOf("\n");
970
+ while (nlIdx !== -1) {
971
+ const line = stdoutBuffer.slice(0, nlIdx);
972
+ stdoutBuffer = stdoutBuffer.slice(nlIdx + 1);
973
+ this.handleStdoutLine(line, {
974
+ subagentId,
975
+ increment: () => ++stdoutLineNumber,
976
+ incrementNonJson: () => ++nonJsonLineCount,
977
+ writeChildSession,
978
+ safeOnStream
979
+ });
980
+ nlIdx = stdoutBuffer.indexOf("\n");
981
+ }
982
+ if (final && stdoutBuffer.length > 0) {
983
+ const remainder = stdoutBuffer;
984
+ stdoutBuffer = "";
985
+ this.handleStdoutLine(remainder, {
986
+ subagentId,
987
+ increment: () => ++stdoutLineNumber,
988
+ incrementNonJson: () => ++nonJsonLineCount,
989
+ writeChildSession,
990
+ safeOnStream
991
+ });
992
+ }
993
+ };
994
+ const flushStderrBuffer = (final) => {
995
+ let nlIdx = stderrBuffer.indexOf("\n");
996
+ while (nlIdx !== -1) {
997
+ const line = stderrBuffer.slice(0, nlIdx);
998
+ stderrBuffer = stderrBuffer.slice(nlIdx + 1);
999
+ stderrRing.push(line);
1000
+ if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
1001
+ writeChildSession({ type: "subagent_stderr", line });
1002
+ safeOnStream({ kind: "stderr", subagentId, payload: { line } });
1003
+ nlIdx = stderrBuffer.indexOf("\n");
1004
+ }
1005
+ if (final && stderrBuffer.length > 0) {
1006
+ const line = stderrBuffer;
1007
+ stderrBuffer = "";
1008
+ stderrRing.push(line);
1009
+ if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
1010
+ writeChildSession({ type: "subagent_stderr", line });
1011
+ safeOnStream({ kind: "stderr", subagentId, payload: { line } });
1012
+ }
1013
+ };
748
1014
  child.stdout?.on("data", (chunk) => {
749
- stdout += chunk.toString("utf8");
1015
+ const text = chunk.toString("utf8");
1016
+ stdoutAccum += text;
1017
+ stdoutBuffer += text;
1018
+ flushStdoutBuffer(false);
750
1019
  });
751
1020
  child.stderr?.on("data", (chunk) => {
752
- stderr += chunk.toString("utf8");
753
- });
754
- const result = new Promise((resolve) => {
755
- child.on("close", (exitCode) => {
756
- let parsed;
757
- try {
758
- parsed = JSON.parse(stdout);
759
- } catch {
760
- }
761
- resolve({ exitCode, stdout, stderr, parsed });
762
- });
1021
+ const text = chunk.toString("utf8");
1022
+ stderrAccum += text;
1023
+ stderrBuffer += text;
1024
+ flushStderrBuffer(false);
763
1025
  });
1026
+ let terminating = false;
1027
+ let terminationReason = "natural";
1028
+ let terminatePromise = null;
1029
+ const terminateImpl = () => {
1030
+ if (terminatePromise !== null) return terminatePromise;
1031
+ terminating = true;
1032
+ terminationReason = "terminated";
1033
+ terminatePromise = terminateSubagent(child, grace ?? DEFAULT_TERMINATE_GRACE_MS);
1034
+ return terminatePromise;
1035
+ };
1036
+ const terminateSync = () => {
1037
+ if (terminating) return;
1038
+ terminating = true;
1039
+ terminationReason = "terminated";
1040
+ try {
1041
+ child.kill("SIGTERM");
1042
+ } catch {
1043
+ }
1044
+ };
1045
+ const activeRecord = {
1046
+ child,
1047
+ subagentId,
1048
+ terminate: terminateSync
1049
+ };
1050
+ activeSubagents.add(activeRecord);
1051
+ ensureOrphanSweeperRegistered();
764
1052
  if (task.signal !== void 0) {
765
- task.signal.addEventListener("abort", () => {
766
- child.kill();
1053
+ const onAbort = () => {
1054
+ void terminateImpl();
1055
+ };
1056
+ if (task.signal.aborted) {
1057
+ onAbort();
1058
+ } else {
1059
+ task.signal.addEventListener("abort", onAbort, { once: true });
1060
+ }
1061
+ }
1062
+ const exitPromise = new Promise((resolve) => {
1063
+ child.on("close", (exitCode, signal) => {
1064
+ flushStdoutBuffer(true);
1065
+ flushStderrBuffer(true);
1066
+ const durationMs = Date.now() - startedAt.getTime();
1067
+ writeChildSession({
1068
+ type: "subagent_exit",
1069
+ code: exitCode,
1070
+ signal,
1071
+ reason: terminationReason,
1072
+ durationMs,
1073
+ nonJsonLineCount
1074
+ });
1075
+ activeSubagents.delete(activeRecord);
1076
+ const result2 = {
1077
+ code: exitCode,
1078
+ signal,
1079
+ childSessionPath,
1080
+ durationMs
1081
+ };
1082
+ safeOnStream({ kind: "exit", subagentId, payload: result2 });
1083
+ resolve(result2);
1084
+ });
1085
+ child.on("error", () => {
767
1086
  });
1087
+ });
1088
+ const result = exitPromise.then(({ code }) => {
1089
+ let parsed;
1090
+ try {
1091
+ parsed = JSON.parse(stdoutAccum);
1092
+ } catch {
1093
+ }
1094
+ return { exitCode: code, stdout: stdoutAccum, stderr: stderrAccum, parsed };
1095
+ });
1096
+ const linkEntry = {
1097
+ type: "subagent_link",
1098
+ subagentId,
1099
+ taskId,
1100
+ childSessionPath,
1101
+ startedAt: startedAtIso
1102
+ };
1103
+ if (task.parentSessionPath !== void 0 && task.parentSessionPath.length > 0) {
1104
+ try {
1105
+ await writeSubagentLink(task.parentSessionPath, linkEntry);
1106
+ safeOnStream({ kind: "link", subagentId, payload: linkEntry });
1107
+ } catch {
1108
+ stderrRing.push(`[link] failed to write subagent_link to parent`);
1109
+ if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
1110
+ }
768
1111
  }
769
1112
  return {
1113
+ subagentId,
1114
+ taskId,
1115
+ childSessionPath,
770
1116
  pid: child.pid ?? null,
1117
+ startedAt,
1118
+ exitPromise,
771
1119
  result,
1120
+ terminate: terminateImpl,
772
1121
  abort: () => {
773
- child.kill();
774
- }
1122
+ void terminateImpl();
1123
+ },
1124
+ recentStderr: () => stderrRing.slice()
775
1125
  };
776
1126
  }
1127
+ /**
1128
+ * Race a set of subagent handles, returning the first one that exits.
1129
+ *
1130
+ * @remarks
1131
+ * Maps CANT's `parallel: race` construct (per ADR-035 §D6) onto the
1132
+ * canonical {@link spawnSubagent} contract. The losing handles are
1133
+ * gracefully terminated via {@link SubagentHandle.terminate} once the
1134
+ * first settles so no straggler children outlive the race.
1135
+ *
1136
+ * @param handles - Subagent handles to race.
1137
+ * @returns The {@link SubagentExitResult} of the first child to exit.
1138
+ * @throws When `handles` is empty (caller bug — a race over zero
1139
+ * children has no winner).
1140
+ */
1141
+ static async raceSubagents(handles) {
1142
+ if (handles.length === 0) {
1143
+ throw new Error("PiHarness.raceSubagents: cannot race an empty handle list");
1144
+ }
1145
+ const tagged = handles.map(
1146
+ (handle, index) => handle.exitPromise.then((value) => ({ index, value }))
1147
+ );
1148
+ const winner = await Promise.race(tagged);
1149
+ const losers = [];
1150
+ for (let i = 0; i < handles.length; i += 1) {
1151
+ if (i === winner.index) continue;
1152
+ const loser = handles[i];
1153
+ if (loser === void 0) continue;
1154
+ losers.push(loser.terminate().catch(() => void 0));
1155
+ }
1156
+ await Promise.all(losers);
1157
+ return winner.value;
1158
+ }
1159
+ /**
1160
+ * Settle a set of subagent handles, returning a parallel array of
1161
+ * results.
1162
+ *
1163
+ * @remarks
1164
+ * Maps CANT's `parallel: settle` construct (per ADR-035 §D6) onto the
1165
+ * canonical {@link spawnSubagent} contract. Because
1166
+ * {@link SubagentHandle.exitPromise} never rejects, every entry in
1167
+ * the returned array is `{ status: 'fulfilled', value: ... }` under
1168
+ * normal operation; the `PromiseSettledResult` shape is preserved
1169
+ * for forward compatibility with future failure modes.
1170
+ *
1171
+ * @param handles - Subagent handles to settle.
1172
+ * @returns Parallel array of settled exit results, one per input.
1173
+ */
1174
+ static async settleAllSubagents(handles) {
1175
+ return Promise.allSettled(handles.map((h) => h.exitPromise));
1176
+ }
1177
+ /**
1178
+ * Per-line stdout dispatcher used by the streaming buffer flusher.
1179
+ *
1180
+ * @remarks
1181
+ * Extracted as a private method so the line-handling logic stays
1182
+ * close to {@link spawnSubagent} but does not bloat the parent
1183
+ * function. Skips empty lines (a leading newline produces a zero-
1184
+ * length entry that has no semantic meaning).
1185
+ */
1186
+ handleStdoutLine(rawLine, ctx) {
1187
+ const trimmed = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
1188
+ if (trimmed.length === 0) return;
1189
+ const lineNumber = ctx.increment();
1190
+ let parsed;
1191
+ try {
1192
+ parsed = JSON.parse(trimmed);
1193
+ } catch {
1194
+ ctx.incrementNonJson();
1195
+ ctx.writeChildSession({ type: "raw", lineNumber, line: trimmed });
1196
+ return;
1197
+ }
1198
+ ctx.writeChildSession({ type: "custom_message", lineNumber, payload: parsed });
1199
+ ctx.safeOnStream({
1200
+ kind: "message",
1201
+ subagentId: ctx.subagentId,
1202
+ lineNumber,
1203
+ payload: parsed
1204
+ });
1205
+ }
777
1206
  // ── Settings ────────────────────────────────────────────────────────
778
1207
  /** {@inheritDoc Harness.readSettings} */
779
1208
  async readSettings(scope) {
@@ -798,7 +1227,722 @@ export default (pi: unknown) => {
798
1227
  async configureModels(modelPatterns, scope) {
799
1228
  await this.writeSettings({ enabledModels: modelPatterns }, scope);
800
1229
  }
801
- };
1230
+ // ── Wave-1 three-tier helpers ───────────────────────────────────────
1231
+ /**
1232
+ * Resolve the `models.json` path for a given legacy two-tier scope.
1233
+ *
1234
+ * @remarks
1235
+ * Lives next to `settings.json`. Global scope uses the Pi state root,
1236
+ * project scope uses the project's `.pi/` directory, matching the
1237
+ * dual-file authority model documented in ADR-035 §D3.
1238
+ */
1239
+ modelsConfigPath(scope) {
1240
+ return scope.kind === "global" ? join5(getPiAgentDir2(), "models.json") : join5(scope.projectDir, ".pi", "models.json");
1241
+ }
1242
+ /**
1243
+ * Resolve the sessions directory — always user-tier because Pi owns
1244
+ * session storage and the three-tier model folds session listings to
1245
+ * the single authoritative location per ADR-035 §D2.
1246
+ */
1247
+ sessionsDir() {
1248
+ return join5(getPiAgentDir2(), "sessions");
1249
+ }
1250
+ // ── Extensions (Wave-1, T263) ───────────────────────────────────────
1251
+ /** {@inheritDoc Harness.installExtension} */
1252
+ async installExtension(sourcePath, name, tier, projectDir, opts) {
1253
+ if (!existsSync4(sourcePath)) {
1254
+ throw new Error(`installExtension: source file does not exist: ${sourcePath}`);
1255
+ }
1256
+ const stats = await stat(sourcePath);
1257
+ if (!stats.isFile()) {
1258
+ throw new Error(`installExtension: source path is not a regular file: ${sourcePath}`);
1259
+ }
1260
+ const ext = extname(sourcePath);
1261
+ if (ext !== ".ts" && ext !== ".tsx" && ext !== ".mts") {
1262
+ throw new Error(
1263
+ `installExtension: expected a TypeScript source file (.ts/.tsx/.mts), got: ${ext || "(no extension)"}`
1264
+ );
1265
+ }
1266
+ const contents = await readFile(sourcePath, "utf8");
1267
+ if (!/\bexport\s+default\b/.test(contents)) {
1268
+ throw new Error(
1269
+ `installExtension: source file is missing an 'export default' \u2014 Pi extensions must export a default function`
1270
+ );
1271
+ }
1272
+ const dir = resolveTierDir({ tier, kind: "extensions", projectDir });
1273
+ const targetPath = join5(dir, `${name}.ts`);
1274
+ if (existsSync4(targetPath) && opts?.force !== true) {
1275
+ throw new Error(
1276
+ `installExtension: target already exists at ${targetPath} (pass --force to overwrite)`
1277
+ );
1278
+ }
1279
+ await mkdir3(dir, { recursive: true });
1280
+ await writeFile(targetPath, contents, "utf8");
1281
+ return { targetPath, tier };
1282
+ }
1283
+ /** {@inheritDoc Harness.removeExtension} */
1284
+ async removeExtension(name, tier, projectDir) {
1285
+ const dir = resolveTierDir({ tier, kind: "extensions", projectDir });
1286
+ const targetPath = join5(dir, `${name}.ts`);
1287
+ if (!existsSync4(targetPath)) return false;
1288
+ await rm3(targetPath, { force: true });
1289
+ return true;
1290
+ }
1291
+ /** {@inheritDoc Harness.listExtensions} */
1292
+ async listExtensions(projectDir) {
1293
+ const tiers = resolveAllTiers("extensions", projectDir);
1294
+ const out = [];
1295
+ const seenNames = /* @__PURE__ */ new Set();
1296
+ for (const { tier, dir } of tiers) {
1297
+ if (!existsSync4(dir)) continue;
1298
+ let entries;
1299
+ try {
1300
+ entries = await readdir(dir, { withFileTypes: true });
1301
+ } catch {
1302
+ continue;
1303
+ }
1304
+ for (const entry of entries) {
1305
+ if (!entry.isFile()) continue;
1306
+ const fileName = entry.name;
1307
+ if (!fileName.endsWith(".ts")) continue;
1308
+ const name = fileName.slice(0, -".ts".length);
1309
+ const shadowed = seenNames.has(name);
1310
+ out.push({
1311
+ name,
1312
+ tier,
1313
+ path: join5(dir, fileName),
1314
+ shadowed
1315
+ });
1316
+ seenNames.add(name);
1317
+ }
1318
+ }
1319
+ return out;
1320
+ }
1321
+ // ── Sessions (Wave-1, T264) ─────────────────────────────────────────
1322
+ /** {@inheritDoc Harness.listSessions} */
1323
+ async listSessions(opts) {
1324
+ const rootDir = this.sessionsDir();
1325
+ if (!existsSync4(rootDir)) return [];
1326
+ const files = [];
1327
+ let rootEntries;
1328
+ try {
1329
+ rootEntries = await readdir(rootDir, { withFileTypes: true });
1330
+ } catch {
1331
+ return [];
1332
+ }
1333
+ for (const entry of rootEntries) {
1334
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
1335
+ files.push(join5(rootDir, entry.name));
1336
+ }
1337
+ }
1338
+ if (opts?.includeSubagents !== false) {
1339
+ const subDir = join5(rootDir, "subagents");
1340
+ if (existsSync4(subDir)) {
1341
+ try {
1342
+ const subEntries = await readdir(subDir, { withFileTypes: true });
1343
+ for (const entry of subEntries) {
1344
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
1345
+ files.push(join5(subDir, entry.name));
1346
+ }
1347
+ }
1348
+ } catch {
1349
+ }
1350
+ }
1351
+ }
1352
+ const summaries = [];
1353
+ for (const filePath of files) {
1354
+ const summary = await readSessionHeader(filePath);
1355
+ if (summary !== null) {
1356
+ summaries.push(summary);
1357
+ }
1358
+ }
1359
+ summaries.sort((a, b) => b.mtimeMs - a.mtimeMs);
1360
+ return summaries;
1361
+ }
1362
+ /** {@inheritDoc Harness.showSession} */
1363
+ async showSession(id) {
1364
+ const summaries = await this.listSessions({ includeSubagents: true });
1365
+ const match = summaries.find((s) => s.id === id);
1366
+ if (match === void 0) {
1367
+ throw new Error(`showSession: no session found with id ${id}`);
1368
+ }
1369
+ const raw = await readFile(match.filePath, "utf8");
1370
+ const allLines = raw.split("\n");
1371
+ while (allLines.length > 0 && allLines[allLines.length - 1] === "") {
1372
+ allLines.pop();
1373
+ }
1374
+ const entries = allLines.slice(1);
1375
+ return { summary: match, entries };
1376
+ }
1377
+ // ── Models (Wave-1, T265) ───────────────────────────────────────────
1378
+ /** {@inheritDoc Harness.readModelsConfig} */
1379
+ async readModelsConfig(scope) {
1380
+ const filePath = this.modelsConfigPath(scope);
1381
+ if (!existsSync4(filePath)) return { providers: {} };
1382
+ let raw;
1383
+ try {
1384
+ raw = await readFile(filePath, "utf8");
1385
+ } catch {
1386
+ return { providers: {} };
1387
+ }
1388
+ try {
1389
+ const parsed = JSON.parse(raw);
1390
+ if (!isPlainObject(parsed)) return { providers: {} };
1391
+ const providersField = parsed["providers"];
1392
+ if (!isPlainObject(providersField)) return { providers: {} };
1393
+ const providers = {};
1394
+ for (const [id, block] of Object.entries(providersField)) {
1395
+ if (isPlainObject(block)) {
1396
+ providers[id] = block;
1397
+ }
1398
+ }
1399
+ return { providers };
1400
+ } catch {
1401
+ return { providers: {} };
1402
+ }
1403
+ }
1404
+ /** {@inheritDoc Harness.writeModelsConfig} */
1405
+ async writeModelsConfig(config, scope) {
1406
+ const filePath = this.modelsConfigPath(scope);
1407
+ await atomicWriteJson(filePath, config);
1408
+ }
1409
+ /** {@inheritDoc Harness.listModels} */
1410
+ async listModels(scope) {
1411
+ const models = await this.readModelsConfig(scope);
1412
+ const settings = await this.readSettings(scope);
1413
+ const settingsObj = isPlainObject(settings) ? settings : {};
1414
+ const enabledRaw = settingsObj["enabledModels"];
1415
+ const enabled = Array.isArray(enabledRaw) ? enabledRaw.filter((v) => typeof v === "string") : [];
1416
+ const defaultModel = typeof settingsObj["defaultModel"] === "string" ? settingsObj["defaultModel"] : null;
1417
+ const defaultProvider = typeof settingsObj["defaultProvider"] === "string" ? settingsObj["defaultProvider"] : null;
1418
+ const out = [];
1419
+ const seen = /* @__PURE__ */ new Set();
1420
+ for (const [providerId, providerBlock] of Object.entries(models.providers)) {
1421
+ const modelDefs = providerBlock.models ?? [];
1422
+ for (const def of modelDefs) {
1423
+ const key = `${providerId}:${def.id}`;
1424
+ seen.add(key);
1425
+ const isEnabled = enabled.includes(key) || enabled.includes(`${providerId}/*`);
1426
+ const isDefault = defaultProvider === providerId && defaultModel === def.id;
1427
+ out.push({
1428
+ provider: providerId,
1429
+ id: def.id,
1430
+ name: def.name ?? null,
1431
+ enabled: isEnabled,
1432
+ isDefault,
1433
+ custom: true
1434
+ });
1435
+ }
1436
+ }
1437
+ for (const selection of enabled) {
1438
+ if (!selection.includes(":") && !selection.includes("/")) continue;
1439
+ const match = selection.match(/^([^:/]+)[:/]([^:/].*)$/);
1440
+ if (match === null) continue;
1441
+ const provider = match[1];
1442
+ const id = match[2];
1443
+ if (provider === void 0 || id === void 0) continue;
1444
+ if (id.endsWith("*")) continue;
1445
+ const key = `${provider}:${id}`;
1446
+ if (seen.has(key)) continue;
1447
+ seen.add(key);
1448
+ const isDefault = defaultProvider === provider && defaultModel === id;
1449
+ out.push({
1450
+ provider,
1451
+ id,
1452
+ name: null,
1453
+ enabled: true,
1454
+ isDefault,
1455
+ custom: false
1456
+ });
1457
+ }
1458
+ if (defaultProvider !== null && defaultModel !== null && !seen.has(`${defaultProvider}:${defaultModel}`)) {
1459
+ out.push({
1460
+ provider: defaultProvider,
1461
+ id: defaultModel,
1462
+ name: null,
1463
+ enabled: false,
1464
+ isDefault: true,
1465
+ custom: false
1466
+ });
1467
+ }
1468
+ return out;
1469
+ }
1470
+ // ── Prompts (Wave-1, T266) ──────────────────────────────────────────
1471
+ /** {@inheritDoc Harness.installPrompt} */
1472
+ async installPrompt(sourceDir, name, tier, projectDir, opts) {
1473
+ if (!existsSync4(sourceDir)) {
1474
+ throw new Error(`installPrompt: source directory does not exist: ${sourceDir}`);
1475
+ }
1476
+ const stats = await stat(sourceDir);
1477
+ if (!stats.isDirectory()) {
1478
+ throw new Error(`installPrompt: source path is not a directory: ${sourceDir}`);
1479
+ }
1480
+ if (!existsSync4(join5(sourceDir, "prompt.md"))) {
1481
+ throw new Error(`installPrompt: source directory is missing a prompt.md file: ${sourceDir}`);
1482
+ }
1483
+ const baseDir = resolveTierDir({ tier, kind: "prompts", projectDir });
1484
+ const targetPath = join5(baseDir, name);
1485
+ if (existsSync4(targetPath)) {
1486
+ if (opts?.force !== true) {
1487
+ throw new Error(
1488
+ `installPrompt: target already exists at ${targetPath} (pass --force to overwrite)`
1489
+ );
1490
+ }
1491
+ await rm3(targetPath, { recursive: true, force: true });
1492
+ }
1493
+ await mkdir3(baseDir, { recursive: true });
1494
+ await cp3(sourceDir, targetPath, { recursive: true });
1495
+ return { targetPath, tier };
1496
+ }
1497
+ /** {@inheritDoc Harness.listPrompts} */
1498
+ async listPrompts(projectDir) {
1499
+ const tiers = resolveAllTiers("prompts", projectDir);
1500
+ const out = [];
1501
+ const seenNames = /* @__PURE__ */ new Set();
1502
+ for (const { tier, dir } of tiers) {
1503
+ if (!existsSync4(dir)) continue;
1504
+ let entries;
1505
+ try {
1506
+ entries = await readdir(dir, { withFileTypes: true });
1507
+ } catch {
1508
+ continue;
1509
+ }
1510
+ for (const entry of entries) {
1511
+ if (!entry.isDirectory()) continue;
1512
+ const name = entry.name;
1513
+ const shadowed = seenNames.has(name);
1514
+ out.push({
1515
+ name,
1516
+ tier,
1517
+ path: join5(dir, name),
1518
+ shadowed
1519
+ });
1520
+ seenNames.add(name);
1521
+ }
1522
+ }
1523
+ return out;
1524
+ }
1525
+ /** {@inheritDoc Harness.removePrompt} */
1526
+ async removePrompt(name, tier, projectDir) {
1527
+ const dir = resolveTierDir({ tier, kind: "prompts", projectDir });
1528
+ const targetPath = join5(dir, name);
1529
+ if (!existsSync4(targetPath)) return false;
1530
+ await rm3(targetPath, { recursive: true, force: true });
1531
+ return true;
1532
+ }
1533
+ // ── Themes (Wave-1, T267) ───────────────────────────────────────────
1534
+ /** {@inheritDoc Harness.installTheme} */
1535
+ async installTheme(sourceFile, name, tier, projectDir, opts) {
1536
+ if (!existsSync4(sourceFile)) {
1537
+ throw new Error(`installTheme: source file does not exist: ${sourceFile}`);
1538
+ }
1539
+ const stats = await stat(sourceFile);
1540
+ if (!stats.isFile()) {
1541
+ throw new Error(`installTheme: source path is not a regular file: ${sourceFile}`);
1542
+ }
1543
+ const ext = extname(sourceFile);
1544
+ if (ext !== ".ts" && ext !== ".tsx" && ext !== ".mts" && ext !== ".json") {
1545
+ throw new Error(
1546
+ `installTheme: expected a theme file (.ts/.tsx/.mts/.json), got: ${ext || "(no extension)"}`
1547
+ );
1548
+ }
1549
+ const dir = resolveTierDir({ tier, kind: "themes", projectDir });
1550
+ const targetPath = join5(dir, `${name}${ext}`);
1551
+ if (existsSync4(targetPath) && opts?.force !== true) {
1552
+ throw new Error(
1553
+ `installTheme: target already exists at ${targetPath} (pass --force to overwrite)`
1554
+ );
1555
+ }
1556
+ const otherExts = [".ts", ".tsx", ".mts", ".json"].filter((e) => e !== ext);
1557
+ for (const otherExt of otherExts) {
1558
+ const otherPath = join5(dir, `${name}${otherExt}`);
1559
+ if (existsSync4(otherPath) && opts?.force !== true) {
1560
+ throw new Error(
1561
+ `installTheme: conflicting theme exists at ${otherPath} (pass --force to overwrite both)`
1562
+ );
1563
+ }
1564
+ if (existsSync4(otherPath) && opts?.force === true) {
1565
+ await rm3(otherPath, { force: true });
1566
+ }
1567
+ }
1568
+ await mkdir3(dir, { recursive: true });
1569
+ const contents = await readFile(sourceFile);
1570
+ await writeFile(targetPath, contents);
1571
+ return { targetPath, tier };
1572
+ }
1573
+ /** {@inheritDoc Harness.listThemes} */
1574
+ async listThemes(projectDir) {
1575
+ const tiers = resolveAllTiers("themes", projectDir);
1576
+ const out = [];
1577
+ const seenNames = /* @__PURE__ */ new Set();
1578
+ const validExts = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".json"]);
1579
+ for (const { tier, dir } of tiers) {
1580
+ if (!existsSync4(dir)) continue;
1581
+ let entries;
1582
+ try {
1583
+ entries = await readdir(dir, { withFileTypes: true });
1584
+ } catch {
1585
+ continue;
1586
+ }
1587
+ for (const entry of entries) {
1588
+ if (!entry.isFile()) continue;
1589
+ const fileExt = extname(entry.name);
1590
+ if (!validExts.has(fileExt)) continue;
1591
+ const name = entry.name.slice(0, -fileExt.length);
1592
+ const shadowed = seenNames.has(name);
1593
+ out.push({
1594
+ name,
1595
+ tier,
1596
+ path: join5(dir, entry.name),
1597
+ fileExt,
1598
+ shadowed
1599
+ });
1600
+ seenNames.add(name);
1601
+ }
1602
+ }
1603
+ return out;
1604
+ }
1605
+ /** {@inheritDoc Harness.removeTheme} */
1606
+ async removeTheme(name, tier, projectDir) {
1607
+ const dir = resolveTierDir({ tier, kind: "themes", projectDir });
1608
+ let removed = false;
1609
+ for (const ext of [".ts", ".tsx", ".mts", ".json"]) {
1610
+ const targetPath = join5(dir, `${name}${ext}`);
1611
+ if (existsSync4(targetPath)) {
1612
+ await rm3(targetPath, { force: true });
1613
+ removed = true;
1614
+ }
1615
+ }
1616
+ return removed;
1617
+ }
1618
+ // ── CANT profiles (Wave-1, T276) ────────────────────────────────────
1619
+ /**
1620
+ * {@inheritDoc Harness.installCantProfile}
1621
+ *
1622
+ * @remarks
1623
+ * Validates the source via {@link validateCantProfile} before copying so
1624
+ * we never persist a `.cant` file the runtime bridge cannot load. The
1625
+ * target layout is `<tier-root>/cant/<name>.cant`, resolved through
1626
+ * {@link resolveTierDir} so the project/user/global hierarchy stays
1627
+ * consistent with the other Wave-1 verbs.
1628
+ */
1629
+ async installCantProfile(sourcePath, name, tier, projectDir, opts) {
1630
+ if (!existsSync4(sourcePath)) {
1631
+ throw new Error(`installCantProfile: source file does not exist: ${sourcePath}`);
1632
+ }
1633
+ const stats = await stat(sourcePath);
1634
+ if (!stats.isFile()) {
1635
+ throw new Error(`installCantProfile: source path is not a regular file: ${sourcePath}`);
1636
+ }
1637
+ const ext = extname(sourcePath);
1638
+ if (ext !== ".cant") {
1639
+ throw new Error(
1640
+ `installCantProfile: expected a CANT source file (.cant), got: ${ext || "(no extension)"}`
1641
+ );
1642
+ }
1643
+ const validation = await this.validateCantProfile(sourcePath);
1644
+ if (!validation.valid) {
1645
+ const firstError = validation.errors.find((e) => e.severity === "error") ?? validation.errors[0];
1646
+ const detail = firstError !== void 0 ? ` (${firstError.ruleId} at ${firstError.line}:${firstError.col}: ${firstError.message})` : "";
1647
+ throw new Error(`installCantProfile: source file failed cant-core validation${detail}`);
1648
+ }
1649
+ const dir = resolveTierDir({ tier, kind: "cant", projectDir });
1650
+ const targetPath = join5(dir, `${name}.cant`);
1651
+ if (existsSync4(targetPath) && opts?.force !== true) {
1652
+ throw new Error(
1653
+ `installCantProfile: target already exists at ${targetPath} (pass --force to overwrite)`
1654
+ );
1655
+ }
1656
+ const contents = await readFile(sourcePath);
1657
+ await mkdir3(dir, { recursive: true });
1658
+ await writeFile(targetPath, contents);
1659
+ return { targetPath, tier, counts: validation.counts };
1660
+ }
1661
+ /** {@inheritDoc Harness.removeCantProfile} */
1662
+ async removeCantProfile(name, tier, projectDir) {
1663
+ const dir = resolveTierDir({ tier, kind: "cant", projectDir });
1664
+ const targetPath = join5(dir, `${name}.cant`);
1665
+ if (!existsSync4(targetPath)) return false;
1666
+ await rm3(targetPath, { force: true });
1667
+ return true;
1668
+ }
1669
+ /**
1670
+ * {@inheritDoc Harness.listCantProfiles}
1671
+ *
1672
+ * @remarks
1673
+ * Walks every tier in {@link TIER_PRECEDENCE} order, parsing each
1674
+ * discovered `.cant` file via cant-core to extract a
1675
+ * {@link CantProfileCounts} bag. Higher-precedence tiers shadow
1676
+ * lower-precedence entries with the same name; shadowed entries
1677
+ * still appear in the result but carry the
1678
+ * `shadowedByHigherTier` flag so callers can render the precedence
1679
+ * story without losing visibility of the duplicate.
1680
+ */
1681
+ async listCantProfiles(projectDir) {
1682
+ const tiers = resolveAllTiers("cant", projectDir);
1683
+ const out = [];
1684
+ const seenNames = /* @__PURE__ */ new Set();
1685
+ for (const { tier, dir } of tiers) {
1686
+ if (!existsSync4(dir)) continue;
1687
+ let entries;
1688
+ try {
1689
+ entries = await readdir(dir, { withFileTypes: true });
1690
+ } catch {
1691
+ continue;
1692
+ }
1693
+ for (const entry of entries) {
1694
+ if (!entry.isFile()) continue;
1695
+ const fileName = entry.name;
1696
+ if (!fileName.endsWith(".cant")) continue;
1697
+ const name = fileName.slice(0, -".cant".length);
1698
+ const sourcePath = join5(dir, fileName);
1699
+ const counts = await extractCantCounts(sourcePath);
1700
+ const shadowed = seenNames.has(name);
1701
+ const profile = {
1702
+ name,
1703
+ tier,
1704
+ sourcePath,
1705
+ counts
1706
+ };
1707
+ if (shadowed) {
1708
+ profile.shadowedByHigherTier = true;
1709
+ }
1710
+ out.push(profile);
1711
+ seenNames.add(name);
1712
+ }
1713
+ }
1714
+ return out;
1715
+ }
1716
+ /**
1717
+ * {@inheritDoc Harness.validateCantProfile}
1718
+ *
1719
+ * @remarks
1720
+ * Pure validator. Reads the file, runs `parseDocument` to derive
1721
+ * counts (when parsing succeeds) and `validateDocument` to collect
1722
+ * the 42-rule diagnostic feed. The two calls are kept independent so
1723
+ * we can still report counts for files that pass parsing but fail a
1724
+ * lint rule.
1725
+ */
1726
+ async validateCantProfile(sourcePath) {
1727
+ if (!existsSync4(sourcePath)) {
1728
+ throw new Error(`validateCantProfile: source file does not exist: ${sourcePath}`);
1729
+ }
1730
+ const stats = await stat(sourcePath);
1731
+ if (!stats.isFile()) {
1732
+ throw new Error(`validateCantProfile: source path is not a regular file: ${sourcePath}`);
1733
+ }
1734
+ const counts = await extractCantCounts(sourcePath);
1735
+ const validation = await validateDocument(sourcePath);
1736
+ const errors = validation.diagnostics.map((d) => ({
1737
+ ruleId: d.ruleId,
1738
+ message: d.message,
1739
+ line: d.line,
1740
+ col: d.col,
1741
+ severity: normaliseSeverity(d.severity)
1742
+ }));
1743
+ return {
1744
+ valid: validation.valid,
1745
+ errors,
1746
+ counts
1747
+ };
1748
+ }
1749
+ };
1750
+ async function terminateSubagent(child, graceMs) {
1751
+ if (child.exitCode !== null || child.signalCode !== null) {
1752
+ return;
1753
+ }
1754
+ try {
1755
+ child.kill("SIGTERM");
1756
+ } catch {
1757
+ return;
1758
+ }
1759
+ const pollInterval = Math.min(25, Math.max(1, graceMs));
1760
+ const deadline = Date.now() + graceMs;
1761
+ await new Promise((resolve) => {
1762
+ const timer = setInterval(() => {
1763
+ if (child.exitCode !== null || child.signalCode !== null) {
1764
+ clearInterval(timer);
1765
+ resolve();
1766
+ return;
1767
+ }
1768
+ if (Date.now() >= deadline) {
1769
+ clearInterval(timer);
1770
+ try {
1771
+ child.kill("SIGKILL");
1772
+ } catch {
1773
+ }
1774
+ resolve();
1775
+ }
1776
+ }, pollInterval);
1777
+ child.once("close", () => {
1778
+ clearInterval(timer);
1779
+ resolve();
1780
+ });
1781
+ });
1782
+ }
1783
+ async function writeSubagentLink(parentSessionPath, entry) {
1784
+ await mkdir3(dirname2(parentSessionPath), { recursive: true });
1785
+ const wrapped = {
1786
+ type: "custom",
1787
+ subtype: entry.type,
1788
+ subagentId: entry.subagentId,
1789
+ taskId: entry.taskId,
1790
+ childSessionPath: entry.childSessionPath,
1791
+ startedAt: entry.startedAt
1792
+ };
1793
+ const line = `${JSON.stringify(wrapped)}
1794
+ `;
1795
+ await appendFile(parentSessionPath, line, "utf8");
1796
+ }
1797
+ async function readSessionHeader(filePath) {
1798
+ let handle = null;
1799
+ try {
1800
+ handle = await open(filePath, "r");
1801
+ const stats = await handle.stat();
1802
+ const capacity = Math.min(stats.size, 64 * 1024);
1803
+ if (capacity === 0) return null;
1804
+ const buffer = Buffer.alloc(capacity);
1805
+ const { bytesRead } = await handle.read(buffer, 0, capacity, 0);
1806
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
1807
+ const newlineIdx = text.indexOf("\n");
1808
+ const firstLine = newlineIdx === -1 ? text : text.slice(0, newlineIdx);
1809
+ if (firstLine.trim().length === 0) return null;
1810
+ let parsed;
1811
+ try {
1812
+ parsed = JSON.parse(firstLine);
1813
+ } catch {
1814
+ return null;
1815
+ }
1816
+ if (!isPlainObject(parsed)) return null;
1817
+ const id = typeof parsed["id"] === "string" ? parsed["id"] : null;
1818
+ if (id === null) {
1819
+ const stem = basename2(filePath, ".jsonl");
1820
+ return {
1821
+ id: stem,
1822
+ version: typeof parsed["version"] === "number" ? parsed["version"] : 0,
1823
+ timestamp: typeof parsed["timestamp"] === "string" ? parsed["timestamp"] : null,
1824
+ cwd: typeof parsed["cwd"] === "string" ? parsed["cwd"] : null,
1825
+ parentSession: typeof parsed["parentSession"] === "string" ? parsed["parentSession"] : null,
1826
+ filePath,
1827
+ mtimeMs: stats.mtimeMs
1828
+ };
1829
+ }
1830
+ return {
1831
+ id,
1832
+ version: typeof parsed["version"] === "number" ? parsed["version"] : 0,
1833
+ timestamp: typeof parsed["timestamp"] === "string" ? parsed["timestamp"] : null,
1834
+ cwd: typeof parsed["cwd"] === "string" ? parsed["cwd"] : null,
1835
+ parentSession: typeof parsed["parentSession"] === "string" ? parsed["parentSession"] : null,
1836
+ filePath,
1837
+ mtimeMs: stats.mtimeMs
1838
+ };
1839
+ } catch {
1840
+ return null;
1841
+ } finally {
1842
+ if (handle !== null) {
1843
+ await handle.close().catch(() => {
1844
+ });
1845
+ }
1846
+ }
1847
+ }
1848
+ var EMPTY_CANT_COUNTS = {
1849
+ agentCount: 0,
1850
+ workflowCount: 0,
1851
+ pipelineCount: 0,
1852
+ hookCount: 0,
1853
+ skillCount: 0
1854
+ };
1855
+ function isRecord(v) {
1856
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1857
+ }
1858
+ function unwrapSpanned(value) {
1859
+ if (typeof value === "string") return value;
1860
+ if (isRecord(value) && typeof value["value"] === "string") {
1861
+ return value["value"];
1862
+ }
1863
+ return null;
1864
+ }
1865
+ function collectSkillNames(value, out) {
1866
+ if (!isRecord(value)) return;
1867
+ const arr = value["Array"];
1868
+ if (!Array.isArray(arr)) return;
1869
+ for (const item of arr) {
1870
+ if (!isRecord(item)) continue;
1871
+ const stringWrapper = item["String"];
1872
+ if (isRecord(stringWrapper) && typeof stringWrapper["raw"] === "string") {
1873
+ out.add(stringWrapper["raw"]);
1874
+ continue;
1875
+ }
1876
+ const identWrapper = item["Identifier"];
1877
+ if (typeof identWrapper === "string") {
1878
+ out.add(identWrapper);
1879
+ }
1880
+ }
1881
+ }
1882
+ async function extractCantCounts(sourcePath) {
1883
+ let parsed;
1884
+ try {
1885
+ parsed = await parseDocument(sourcePath);
1886
+ } catch {
1887
+ return { ...EMPTY_CANT_COUNTS };
1888
+ }
1889
+ if (!parsed.success || !isRecord(parsed.document)) {
1890
+ return { ...EMPTY_CANT_COUNTS };
1891
+ }
1892
+ const sections = parsed.document["sections"];
1893
+ if (!Array.isArray(sections)) {
1894
+ return { ...EMPTY_CANT_COUNTS };
1895
+ }
1896
+ let agentCount = 0;
1897
+ let workflowCount = 0;
1898
+ let pipelineCount = 0;
1899
+ let hookCount = 0;
1900
+ const skillNames = /* @__PURE__ */ new Set();
1901
+ for (const section of sections) {
1902
+ if (!isRecord(section)) continue;
1903
+ if (isRecord(section["Agent"])) {
1904
+ agentCount += 1;
1905
+ const agent = section["Agent"];
1906
+ const hooks = agent["hooks"];
1907
+ if (Array.isArray(hooks)) {
1908
+ hookCount += hooks.length;
1909
+ }
1910
+ const properties = agent["properties"];
1911
+ if (Array.isArray(properties)) {
1912
+ for (const prop of properties) {
1913
+ if (!isRecord(prop)) continue;
1914
+ const key = unwrapSpanned(prop["key"]);
1915
+ if (key === "skills") {
1916
+ collectSkillNames(prop["value"], skillNames);
1917
+ }
1918
+ }
1919
+ }
1920
+ continue;
1921
+ }
1922
+ if (isRecord(section["Workflow"])) {
1923
+ workflowCount += 1;
1924
+ continue;
1925
+ }
1926
+ if (isRecord(section["Pipeline"])) {
1927
+ pipelineCount += 1;
1928
+ continue;
1929
+ }
1930
+ if (isRecord(section["Hook"])) {
1931
+ hookCount += 1;
1932
+ }
1933
+ }
1934
+ return {
1935
+ agentCount,
1936
+ workflowCount,
1937
+ pipelineCount,
1938
+ hookCount,
1939
+ skillCount: skillNames.size
1940
+ };
1941
+ }
1942
+ function normaliseSeverity(raw) {
1943
+ if (raw === "warning" || raw === "info" || raw === "hint") return raw;
1944
+ return "error";
1945
+ }
802
1946
 
803
1947
  // src/core/harness/index.ts
804
1948
  function getHarnessFor(provider) {
@@ -818,28 +1962,67 @@ function getAllHarnesses() {
818
1962
  }
819
1963
  return result;
820
1964
  }
821
- function resolveDefaultTargetProviders() {
1965
+ function resolveDefaultTargetProviders(options = {}) {
1966
+ const mode = getExclusivityMode();
822
1967
  let primary = null;
823
1968
  try {
824
1969
  primary = getPrimaryHarness();
825
1970
  } catch {
826
1971
  primary = null;
827
1972
  }
828
- const installed = getInstalledProviders();
829
- if (primary !== null) {
830
- const primaryId = primary.provider.id;
831
- const primaryInstalled = installed.some((p) => p.id === primaryId);
832
- if (primaryInstalled) {
1973
+ let installed;
1974
+ try {
1975
+ installed = getInstalledProviders();
1976
+ } catch {
1977
+ installed = [];
1978
+ }
1979
+ const primaryId = primary?.provider.id ?? null;
1980
+ const primaryInstalled = primaryId !== null && installed.some((provider) => provider.id === primaryId);
1981
+ const explicit = options.explicit;
1982
+ const explicitContainsPrimary = explicit !== void 0 && primaryId !== null ? explicit.some((provider) => provider.id === primaryId) : false;
1983
+ const legacyFallback = () => {
1984
+ if (primary !== null && primaryInstalled) {
833
1985
  return [primary.provider];
834
1986
  }
1987
+ const highTier = installed.filter(
1988
+ (provider) => provider.priority === "primary" || provider.priority === "high"
1989
+ );
1990
+ if (highTier.length > 0) {
1991
+ return highTier;
1992
+ }
1993
+ return installed;
1994
+ };
1995
+ if (mode === "force-pi") {
1996
+ if (primary === null || !primaryInstalled) {
1997
+ throw new PiRequiredError();
1998
+ }
1999
+ return [primary.provider];
835
2000
  }
836
- const highTier = installed.filter(
837
- (provider) => provider.priority === "primary" || provider.priority === "high"
838
- );
839
- if (highTier.length > 0) {
840
- return highTier;
2001
+ if (mode === "legacy") {
2002
+ if (explicit !== void 0) {
2003
+ return explicit;
2004
+ }
2005
+ return legacyFallback();
2006
+ }
2007
+ if (explicit !== void 0 && explicit.length > 0 && !explicitContainsPrimary && primaryInstalled && !hasExplicitNonPiAutoWarned()) {
2008
+ console.warn(
2009
+ "Warning: Targeting a non-Pi provider explicitly is deprecated when Pi is installed. Future versions will route all runtime commands through Pi. To suppress this warning, set caamp.exclusivityMode to 'legacy'."
2010
+ );
2011
+ markExplicitNonPiAutoWarned();
841
2012
  }
842
- return installed;
2013
+ if (explicit !== void 0) {
2014
+ return explicit;
2015
+ }
2016
+ if (primary !== null && primaryInstalled) {
2017
+ return [primary.provider];
2018
+ }
2019
+ if (!hasPiAbsentAutoWarned()) {
2020
+ console.warn(
2021
+ "Warning: Pi is not installed. CAAMP is falling back to direct provider dispatch. Install Pi (https://github.com/mariozechner/pi-coding-agent) to enable orchestration, or set caamp.exclusivityMode to 'legacy' to suppress this warning."
2022
+ );
2023
+ markPiAbsentAutoWarned();
2024
+ }
2025
+ return legacyFallback();
843
2026
  }
844
2027
  async function dispatchInstallSkillAcrossProviders(sourcePath, skillName, providers, isGlobal, projectDir) {
845
2028
  const harnessTargets = [];
@@ -1105,63 +2288,395 @@ async function removeYamlConfig(filePath, configKey, serverName) {
1105
2288
  if (typeof next !== "object" || next === null) return false;
1106
2289
  current = next;
1107
2290
  }
1108
- if (!(serverName in current)) return false;
1109
- delete current[serverName];
1110
- const content = yaml.dump(existing, {
1111
- indent: 2,
1112
- lineWidth: -1,
1113
- noRefs: true,
1114
- sortKeys: false
1115
- });
1116
- await writeFile4(filePath, content, "utf-8");
1117
- return true;
1118
- }
1119
-
1120
- // src/core/formats/index.ts
1121
- async function readConfig(filePath, format) {
1122
- debug(`reading config: ${filePath} (format: ${format})`);
1123
- switch (format) {
1124
- case "json":
1125
- case "jsonc":
1126
- return readJsonConfig(filePath);
1127
- case "yaml":
1128
- return readYamlConfig(filePath);
1129
- case "toml":
1130
- return readTomlConfig(filePath);
1131
- default:
1132
- throw new Error(`Unsupported config format: ${format}`);
2291
+ if (!(serverName in current)) return false;
2292
+ delete current[serverName];
2293
+ const content = yaml.dump(existing, {
2294
+ indent: 2,
2295
+ lineWidth: -1,
2296
+ noRefs: true,
2297
+ sortKeys: false
2298
+ });
2299
+ await writeFile4(filePath, content, "utf-8");
2300
+ return true;
2301
+ }
2302
+
2303
+ // src/core/formats/index.ts
2304
+ async function readConfig(filePath, format) {
2305
+ debug(`reading config: ${filePath} (format: ${format})`);
2306
+ switch (format) {
2307
+ case "json":
2308
+ case "jsonc":
2309
+ return readJsonConfig(filePath);
2310
+ case "yaml":
2311
+ return readYamlConfig(filePath);
2312
+ case "toml":
2313
+ return readTomlConfig(filePath);
2314
+ default:
2315
+ throw new Error(`Unsupported config format: ${format}`);
2316
+ }
2317
+ }
2318
+ async function writeConfig(filePath, format, key, serverName, serverConfig) {
2319
+ debug(`writing config: ${filePath} (format: ${format}, key: ${key}, server: ${serverName})`);
2320
+ switch (format) {
2321
+ case "json":
2322
+ case "jsonc":
2323
+ return writeJsonConfig(filePath, key, serverName, serverConfig);
2324
+ case "yaml":
2325
+ return writeYamlConfig(filePath, key, serverName, serverConfig);
2326
+ case "toml":
2327
+ return writeTomlConfig(filePath, key, serverName, serverConfig);
2328
+ default:
2329
+ throw new Error(`Unsupported config format: ${format}`);
2330
+ }
2331
+ }
2332
+ async function removeConfig(filePath, format, key, serverName) {
2333
+ switch (format) {
2334
+ case "json":
2335
+ case "jsonc":
2336
+ return removeJsonConfig(filePath, key, serverName);
2337
+ case "yaml":
2338
+ return removeYamlConfig(filePath, key, serverName);
2339
+ case "toml":
2340
+ return removeTomlConfig(filePath, key, serverName);
2341
+ default:
2342
+ throw new Error(`Unsupported config format: ${format}`);
2343
+ }
2344
+ }
2345
+
2346
+ // src/core/mcp/reader.ts
2347
+ import { existsSync as existsSync8 } from "fs";
2348
+ import { stat as stat2 } from "fs/promises";
2349
+ function resolveMcpConfigPath(provider, scope, projectDir) {
2350
+ if (provider.capabilities.mcp === null) return null;
2351
+ return resolveProviderConfigPath(provider, scope, projectDir);
2352
+ }
2353
+ async function listMcpServers(provider, scope, projectDir) {
2354
+ const mcp = provider.capabilities.mcp;
2355
+ if (mcp === null) return [];
2356
+ const configPath = resolveMcpConfigPath(provider, scope, projectDir);
2357
+ if (configPath === null) return [];
2358
+ if (!existsSync8(configPath)) {
2359
+ debug(`mcp.list: ${provider.id} (${scope}) \u2014 config file missing at ${configPath}`);
2360
+ return [];
2361
+ }
2362
+ let parsed;
2363
+ try {
2364
+ parsed = await readConfig(configPath, mcp.configFormat);
2365
+ } catch (err) {
2366
+ const message = err instanceof Error ? err.message : String(err);
2367
+ debug(`mcp.list: ${provider.id} parse failed at ${configPath}: ${message}`);
2368
+ return [];
2369
+ }
2370
+ const servers = getNestedValue(parsed, mcp.configKey);
2371
+ if (servers === void 0 || servers === null || typeof servers !== "object") return [];
2372
+ const out = [];
2373
+ for (const [name, raw] of Object.entries(servers)) {
2374
+ out.push({
2375
+ name,
2376
+ providerId: provider.id,
2377
+ providerName: provider.toolName,
2378
+ scope,
2379
+ configPath,
2380
+ config: raw ?? {}
2381
+ });
2382
+ }
2383
+ return out;
2384
+ }
2385
+ async function listAllMcpServers(scope, projectDir) {
2386
+ const out = /* @__PURE__ */ new Map();
2387
+ for (const provider of getAllProviders()) {
2388
+ if (provider.capabilities.mcp === null) continue;
2389
+ const entries = await listMcpServers(provider, scope, projectDir);
2390
+ out.set(provider.id, entries);
2391
+ }
2392
+ return out;
2393
+ }
2394
+ async function detectMcpInstallations(scope, projectDir) {
2395
+ const out = [];
2396
+ for (const provider of getAllProviders()) {
2397
+ const mcp = provider.capabilities.mcp;
2398
+ if (mcp === null) continue;
2399
+ const configPath = resolveMcpConfigPath(provider, scope, projectDir);
2400
+ if (configPath === null) continue;
2401
+ const exists = existsSync8(configPath);
2402
+ let serverCount = null;
2403
+ let lastModified = null;
2404
+ if (exists) {
2405
+ try {
2406
+ const stats = await stat2(configPath);
2407
+ lastModified = stats.mtime.toISOString();
2408
+ } catch {
2409
+ lastModified = null;
2410
+ }
2411
+ const entries = await listMcpServers(provider, scope, projectDir);
2412
+ serverCount = entries.length;
2413
+ }
2414
+ out.push({
2415
+ providerId: provider.id,
2416
+ providerName: provider.toolName,
2417
+ scope,
2418
+ configPath,
2419
+ exists,
2420
+ serverCount,
2421
+ lastModified
2422
+ });
2423
+ }
2424
+ return out;
2425
+ }
2426
+
2427
+ // src/core/mcp/installer.ts
2428
+ async function installMcpServer(provider, serverName, config, opts) {
2429
+ const mcp = provider.capabilities.mcp;
2430
+ if (mcp === null) {
2431
+ throw new Error(`Provider ${provider.id} does not declare an MCP capability.`);
2432
+ }
2433
+ const configPath = resolveMcpConfigPath(provider, opts.scope, opts.projectDir);
2434
+ if (configPath === null) {
2435
+ throw new Error(
2436
+ `Provider ${provider.id} has no ${opts.scope}-scoped MCP config path available.`
2437
+ );
2438
+ }
2439
+ debug(
2440
+ `mcp.install: ${provider.id} ${serverName} \u2192 ${configPath} (format=${mcp.configFormat}, key=${mcp.configKey})`
2441
+ );
2442
+ const existing = await listMcpServers(provider, opts.scope, opts.projectDir);
2443
+ const conflicted = existing.some((e) => e.name === serverName);
2444
+ if (conflicted && opts.force !== true) {
2445
+ return {
2446
+ installed: false,
2447
+ conflicted: true,
2448
+ sourcePath: configPath,
2449
+ providerId: provider.id,
2450
+ serverName
2451
+ };
2452
+ }
2453
+ await writeConfig(configPath, mcp.configFormat, mcp.configKey, serverName, config);
2454
+ return {
2455
+ installed: true,
2456
+ conflicted,
2457
+ sourcePath: configPath,
2458
+ providerId: provider.id,
2459
+ serverName
2460
+ };
2461
+ }
2462
+
2463
+ // src/core/mcp/remover.ts
2464
+ import { existsSync as existsSync9 } from "fs";
2465
+ async function removeMcpServer(provider, serverName, opts) {
2466
+ const mcp = provider.capabilities.mcp;
2467
+ if (mcp === null) {
2468
+ return {
2469
+ providerId: provider.id,
2470
+ serverName,
2471
+ sourcePath: null,
2472
+ removed: false,
2473
+ reason: "no-mcp-capability"
2474
+ };
2475
+ }
2476
+ const configPath = resolveMcpConfigPath(provider, opts.scope, opts.projectDir);
2477
+ if (configPath === null) {
2478
+ return {
2479
+ providerId: provider.id,
2480
+ serverName,
2481
+ sourcePath: null,
2482
+ removed: false,
2483
+ reason: "no-config-path"
2484
+ };
2485
+ }
2486
+ if (!existsSync9(configPath)) {
2487
+ return {
2488
+ providerId: provider.id,
2489
+ serverName,
2490
+ sourcePath: configPath,
2491
+ removed: false,
2492
+ reason: "file-missing"
2493
+ };
2494
+ }
2495
+ debug(`mcp.remove: ${provider.id} ${serverName} \u2192 ${configPath}`);
2496
+ const removed = await removeConfig(configPath, mcp.configFormat, mcp.configKey, serverName);
2497
+ return {
2498
+ providerId: provider.id,
2499
+ serverName,
2500
+ sourcePath: configPath,
2501
+ removed,
2502
+ reason: removed ? null : "entry-missing"
2503
+ };
2504
+ }
2505
+ async function removeMcpServerFromAll(serverName, opts) {
2506
+ const out = [];
2507
+ for (const provider of getAllProviders()) {
2508
+ if (provider.capabilities.mcp === null) continue;
2509
+ out.push(await removeMcpServer(provider, serverName, opts));
2510
+ }
2511
+ return out;
2512
+ }
2513
+
2514
+ // src/core/sources/parser.ts
2515
+ var GITHUB_SHORTHAND = /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(?:\/(.+))?$/;
2516
+ var GITHUB_URL = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+)(?:\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?/;
2517
+ var GITLAB_URL = /^https?:\/\/(?:www\.)?gitlab\.com\/([^/]+)\/([^/]+)(?:\/-\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?/;
2518
+ var HTTP_URL = /^https?:\/\//;
2519
+ var NPM_SCOPED = /^@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
2520
+ var NPM_PACKAGE = /^[a-zA-Z0-9_.-]+$/;
2521
+ var LIBRARY_SKILL = /^(@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+|[a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)$/;
2522
+ function inferName(source, type) {
2523
+ if (type === "library") {
2524
+ const match = source.match(LIBRARY_SKILL);
2525
+ return match?.[2] ?? source;
2526
+ }
2527
+ if (type === "remote") {
2528
+ try {
2529
+ const url = new URL(source);
2530
+ const parts = url.hostname.split(".");
2531
+ if (parts.length >= 2) {
2532
+ const fallback = parts[0] ?? source;
2533
+ const secondLevel = parts[parts.length - 2] ?? fallback;
2534
+ const brand = parts.length === 3 ? secondLevel : fallback;
2535
+ if (brand !== "www" && brand !== "api" && brand !== "mcp") {
2536
+ return brand;
2537
+ }
2538
+ return secondLevel;
2539
+ }
2540
+ return parts[0] ?? source;
2541
+ } catch {
2542
+ return source;
2543
+ }
2544
+ }
2545
+ if (type === "package") {
2546
+ let name = source.replace(/^@[^/]+\//, "");
2547
+ name = name.replace(/^mcp-server-/, "");
2548
+ name = name.replace(/^server-/, "");
2549
+ name = name.replace(/-mcp$/, "");
2550
+ name = name.replace(/-server$/, "");
2551
+ return name;
2552
+ }
2553
+ if (type === "github" || type === "gitlab") {
2554
+ const match = source.match(/\/([^/]+?)(?:\.git)?$/);
2555
+ return match?.[1] ?? source;
2556
+ }
2557
+ if (type === "local") {
2558
+ const normalized = source.replace(/\\/g, "/").replace(/\/+$/, "");
2559
+ const lastSegment = normalized.split("/").pop();
2560
+ return lastSegment ?? source;
2561
+ }
2562
+ if (type === "command") {
2563
+ const parts = source.split(/\s+/);
2564
+ const command = parts.find(
2565
+ (p) => !p.startsWith("-") && p !== "npx" && p !== "node" && p !== "python" && p !== "python3"
2566
+ );
2567
+ return command ?? parts[0] ?? source;
2568
+ }
2569
+ return source;
2570
+ }
2571
+ function parseSource(input) {
2572
+ const ghUrlMatch = input.match(GITHUB_URL);
2573
+ if (ghUrlMatch) {
2574
+ const owner = ghUrlMatch[1];
2575
+ const repo = ghUrlMatch[2];
2576
+ const path = ghUrlMatch[4];
2577
+ if (!owner || !repo) {
2578
+ return { type: "command", value: input, inferredName: inferName(input, "command") };
2579
+ }
2580
+ const inferredName = path ? path.split("/").pop() ?? repo : repo;
2581
+ return {
2582
+ type: "github",
2583
+ value: input,
2584
+ inferredName,
2585
+ owner,
2586
+ repo,
2587
+ ref: ghUrlMatch[3],
2588
+ path
2589
+ };
2590
+ }
2591
+ const glUrlMatch = input.match(GITLAB_URL);
2592
+ if (glUrlMatch) {
2593
+ const owner = glUrlMatch[1];
2594
+ const repo = glUrlMatch[2];
2595
+ const path = glUrlMatch[4];
2596
+ if (!owner || !repo) {
2597
+ return { type: "command", value: input, inferredName: inferName(input, "command") };
2598
+ }
2599
+ const inferredName = path ? path.split("/").pop() ?? repo : repo;
2600
+ return {
2601
+ type: "gitlab",
2602
+ value: input,
2603
+ inferredName,
2604
+ owner,
2605
+ repo,
2606
+ ref: glUrlMatch[3],
2607
+ path
2608
+ };
2609
+ }
2610
+ if (HTTP_URL.test(input)) {
2611
+ return {
2612
+ type: "remote",
2613
+ value: input,
2614
+ inferredName: inferName(input, "remote")
2615
+ };
2616
+ }
2617
+ if (input.startsWith("/") || input.startsWith("./") || input.startsWith("../") || input.startsWith("~")) {
2618
+ return {
2619
+ type: "local",
2620
+ value: input,
2621
+ inferredName: inferName(input, "local")
2622
+ };
2623
+ }
2624
+ const ghShorthand = input.match(GITHUB_SHORTHAND);
2625
+ if (ghShorthand && !NPM_SCOPED.test(input)) {
2626
+ const owner = ghShorthand[1];
2627
+ const repo = ghShorthand[2];
2628
+ const path = ghShorthand[3];
2629
+ if (!owner || !repo) {
2630
+ return { type: "command", value: input, inferredName: inferName(input, "command") };
2631
+ }
2632
+ const inferredName = path ? path.split("/").pop() ?? repo : repo;
2633
+ return {
2634
+ type: "github",
2635
+ value: `https://github.com/${owner}/${repo}`,
2636
+ inferredName,
2637
+ owner,
2638
+ repo,
2639
+ path
2640
+ };
2641
+ }
2642
+ const libraryMatch = input.match(LIBRARY_SKILL);
2643
+ if (libraryMatch) {
2644
+ return {
2645
+ type: "library",
2646
+ value: input,
2647
+ inferredName: inferName(input, "library"),
2648
+ owner: libraryMatch[1],
2649
+ // This will be the package name, e.g. @cleocode/skills
2650
+ repo: libraryMatch[2]
2651
+ // This will be the skill name, e.g. ct-research-agent
2652
+ };
2653
+ }
2654
+ if (NPM_SCOPED.test(input)) {
2655
+ return {
2656
+ type: "package",
2657
+ value: input,
2658
+ inferredName: inferName(input, "package")
2659
+ };
1133
2660
  }
1134
- }
1135
- async function writeConfig(filePath, format, key, serverName, serverConfig) {
1136
- debug(`writing config: ${filePath} (format: ${format}, key: ${key}, server: ${serverName})`);
1137
- switch (format) {
1138
- case "json":
1139
- case "jsonc":
1140
- return writeJsonConfig(filePath, key, serverName, serverConfig);
1141
- case "yaml":
1142
- return writeYamlConfig(filePath, key, serverName, serverConfig);
1143
- case "toml":
1144
- return writeTomlConfig(filePath, key, serverName, serverConfig);
1145
- default:
1146
- throw new Error(`Unsupported config format: ${format}`);
2661
+ if (NPM_PACKAGE.test(input) && !input.includes(" ")) {
2662
+ return {
2663
+ type: "package",
2664
+ value: input,
2665
+ inferredName: inferName(input, "package")
2666
+ };
1147
2667
  }
2668
+ return {
2669
+ type: "command",
2670
+ value: input,
2671
+ inferredName: inferName(input, "command")
2672
+ };
1148
2673
  }
1149
- async function removeConfig(filePath, format, key, serverName) {
1150
- switch (format) {
1151
- case "json":
1152
- case "jsonc":
1153
- return removeJsonConfig(filePath, key, serverName);
1154
- case "yaml":
1155
- return removeYamlConfig(filePath, key, serverName);
1156
- case "toml":
1157
- return removeTomlConfig(filePath, key, serverName);
1158
- default:
1159
- throw new Error(`Unsupported config format: ${format}`);
1160
- }
2674
+ function isMarketplaceScoped(input) {
2675
+ return /^@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(input);
1161
2676
  }
1162
2677
 
1163
2678
  // src/core/skills/audit/scanner.ts
1164
- import { existsSync as existsSync8 } from "fs";
2679
+ import { existsSync as existsSync10 } from "fs";
1165
2680
  import { readFile as readFile5 } from "fs/promises";
1166
2681
 
1167
2682
  // src/core/skills/audit/rules.ts
@@ -1534,7 +3049,7 @@ var SEVERITY_WEIGHTS = {
1534
3049
  info: 0
1535
3050
  };
1536
3051
  async function scanFile(filePath, rules) {
1537
- if (!existsSync8(filePath)) {
3052
+ if (!existsSync10(filePath)) {
1538
3053
  return { file: filePath, findings: [], score: 100, passed: true };
1539
3054
  }
1540
3055
  const content = await readFile5(filePath, "utf-8");
@@ -1568,14 +3083,14 @@ async function scanFile(filePath, rules) {
1568
3083
  }
1569
3084
  async function scanDirectory(dirPath) {
1570
3085
  const { readdir: readdir3 } = await import("fs/promises");
1571
- const { join: join8 } = await import("path");
1572
- if (!existsSync8(dirPath)) return [];
3086
+ const { join: join9 } = await import("path");
3087
+ if (!existsSync10(dirPath)) return [];
1573
3088
  const entries = await readdir3(dirPath, { withFileTypes: true });
1574
3089
  const results = [];
1575
3090
  for (const entry of entries) {
1576
3091
  if (entry.isDirectory() || entry.isSymbolicLink()) {
1577
- const skillFile = join8(dirPath, entry.name, "SKILL.md");
1578
- if (existsSync8(skillFile)) {
3092
+ const skillFile = join9(dirPath, entry.name, "SKILL.md");
3093
+ if (existsSync10(skillFile)) {
1579
3094
  results.push(await scanFile(skillFile));
1580
3095
  }
1581
3096
  }
@@ -1626,178 +3141,14 @@ function toSarif(results) {
1626
3141
  };
1627
3142
  }
1628
3143
 
1629
- // src/core/sources/parser.ts
1630
- var GITHUB_SHORTHAND = /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(?:\/(.+))?$/;
1631
- var GITHUB_URL = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+)(?:\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?/;
1632
- var GITLAB_URL = /^https?:\/\/(?:www\.)?gitlab\.com\/([^/]+)\/([^/]+)(?:\/-\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?/;
1633
- var HTTP_URL = /^https?:\/\//;
1634
- var NPM_SCOPED = /^@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
1635
- var NPM_PACKAGE = /^[a-zA-Z0-9_.-]+$/;
1636
- var LIBRARY_SKILL = /^(@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+|[a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)$/;
1637
- function inferName(source, type) {
1638
- if (type === "library") {
1639
- const match = source.match(LIBRARY_SKILL);
1640
- return match?.[2] ?? source;
1641
- }
1642
- if (type === "remote") {
1643
- try {
1644
- const url = new URL(source);
1645
- const parts = url.hostname.split(".");
1646
- if (parts.length >= 2) {
1647
- const fallback = parts[0] ?? source;
1648
- const secondLevel = parts[parts.length - 2] ?? fallback;
1649
- const brand = parts.length === 3 ? secondLevel : fallback;
1650
- if (brand !== "www" && brand !== "api" && brand !== "mcp") {
1651
- return brand;
1652
- }
1653
- return secondLevel;
1654
- }
1655
- return parts[0] ?? source;
1656
- } catch {
1657
- return source;
1658
- }
1659
- }
1660
- if (type === "package") {
1661
- let name = source.replace(/^@[^/]+\//, "");
1662
- name = name.replace(/^mcp-server-/, "");
1663
- name = name.replace(/^server-/, "");
1664
- name = name.replace(/-mcp$/, "");
1665
- name = name.replace(/-server$/, "");
1666
- return name;
1667
- }
1668
- if (type === "github" || type === "gitlab") {
1669
- const match = source.match(/\/([^/]+?)(?:\.git)?$/);
1670
- return match?.[1] ?? source;
1671
- }
1672
- if (type === "local") {
1673
- const normalized = source.replace(/\\/g, "/").replace(/\/+$/, "");
1674
- const lastSegment = normalized.split("/").pop();
1675
- return lastSegment ?? source;
1676
- }
1677
- if (type === "command") {
1678
- const parts = source.split(/\s+/);
1679
- const command = parts.find(
1680
- (p) => !p.startsWith("-") && p !== "npx" && p !== "node" && p !== "python" && p !== "python3"
1681
- );
1682
- return command ?? parts[0] ?? source;
1683
- }
1684
- return source;
1685
- }
1686
- function parseSource(input) {
1687
- const ghUrlMatch = input.match(GITHUB_URL);
1688
- if (ghUrlMatch) {
1689
- const owner = ghUrlMatch[1];
1690
- const repo = ghUrlMatch[2];
1691
- const path = ghUrlMatch[4];
1692
- if (!owner || !repo) {
1693
- return { type: "command", value: input, inferredName: inferName(input, "command") };
1694
- }
1695
- const inferredName = path ? path.split("/").pop() ?? repo : repo;
1696
- return {
1697
- type: "github",
1698
- value: input,
1699
- inferredName,
1700
- owner,
1701
- repo,
1702
- ref: ghUrlMatch[3],
1703
- path
1704
- };
1705
- }
1706
- const glUrlMatch = input.match(GITLAB_URL);
1707
- if (glUrlMatch) {
1708
- const owner = glUrlMatch[1];
1709
- const repo = glUrlMatch[2];
1710
- const path = glUrlMatch[4];
1711
- if (!owner || !repo) {
1712
- return { type: "command", value: input, inferredName: inferName(input, "command") };
1713
- }
1714
- const inferredName = path ? path.split("/").pop() ?? repo : repo;
1715
- return {
1716
- type: "gitlab",
1717
- value: input,
1718
- inferredName,
1719
- owner,
1720
- repo,
1721
- ref: glUrlMatch[3],
1722
- path
1723
- };
1724
- }
1725
- if (HTTP_URL.test(input)) {
1726
- return {
1727
- type: "remote",
1728
- value: input,
1729
- inferredName: inferName(input, "remote")
1730
- };
1731
- }
1732
- if (input.startsWith("/") || input.startsWith("./") || input.startsWith("../") || input.startsWith("~")) {
1733
- return {
1734
- type: "local",
1735
- value: input,
1736
- inferredName: inferName(input, "local")
1737
- };
1738
- }
1739
- const ghShorthand = input.match(GITHUB_SHORTHAND);
1740
- if (ghShorthand && !NPM_SCOPED.test(input)) {
1741
- const owner = ghShorthand[1];
1742
- const repo = ghShorthand[2];
1743
- const path = ghShorthand[3];
1744
- if (!owner || !repo) {
1745
- return { type: "command", value: input, inferredName: inferName(input, "command") };
1746
- }
1747
- const inferredName = path ? path.split("/").pop() ?? repo : repo;
1748
- return {
1749
- type: "github",
1750
- value: `https://github.com/${owner}/${repo}`,
1751
- inferredName,
1752
- owner,
1753
- repo,
1754
- path
1755
- };
1756
- }
1757
- const libraryMatch = input.match(LIBRARY_SKILL);
1758
- if (libraryMatch) {
1759
- return {
1760
- type: "library",
1761
- value: input,
1762
- inferredName: inferName(input, "library"),
1763
- owner: libraryMatch[1],
1764
- // This will be the package name, e.g. @cleocode/skills
1765
- repo: libraryMatch[2]
1766
- // This will be the skill name, e.g. ct-research-agent
1767
- };
1768
- }
1769
- if (NPM_SCOPED.test(input)) {
1770
- return {
1771
- type: "package",
1772
- value: input,
1773
- inferredName: inferName(input, "package")
1774
- };
1775
- }
1776
- if (NPM_PACKAGE.test(input) && !input.includes(" ")) {
1777
- return {
1778
- type: "package",
1779
- value: input,
1780
- inferredName: inferName(input, "package")
1781
- };
1782
- }
1783
- return {
1784
- type: "command",
1785
- value: input,
1786
- inferredName: inferName(input, "command")
1787
- };
1788
- }
1789
- function isMarketplaceScoped(input) {
1790
- return /^@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(input);
1791
- }
1792
-
1793
3144
  // src/core/skills/lock.ts
1794
3145
  import { execFile } from "child_process";
1795
3146
  import { promisify } from "util";
1796
3147
  import { simpleGit } from "simple-git";
1797
3148
 
1798
3149
  // src/core/lock-utils.ts
1799
- import { existsSync as existsSync9 } from "fs";
1800
- import { mkdir as mkdir4, open, readFile as readFile6, rename as rename2, rm as rm4, stat, writeFile as writeFile5 } from "fs/promises";
3150
+ import { existsSync as existsSync11 } from "fs";
3151
+ import { mkdir as mkdir4, open as open2, readFile as readFile6, rename as rename2, rm as rm4, stat as stat3, writeFile as writeFile5 } from "fs/promises";
1801
3152
  var LOCK_GUARD_PATH = `${LOCK_FILE_PATH}.lock`;
1802
3153
  var STALE_LOCK_MS = 5e3;
1803
3154
  function sleep(ms) {
@@ -1805,7 +3156,7 @@ function sleep(ms) {
1805
3156
  }
1806
3157
  async function removeStaleLock() {
1807
3158
  try {
1808
- const info = await stat(LOCK_GUARD_PATH);
3159
+ const info = await stat3(LOCK_GUARD_PATH);
1809
3160
  if (Date.now() - info.mtimeMs > STALE_LOCK_MS) {
1810
3161
  await rm4(LOCK_GUARD_PATH, { force: true });
1811
3162
  return true;
@@ -1818,7 +3169,7 @@ async function acquireLockGuard(retries = 40, delayMs = 25) {
1818
3169
  await mkdir4(AGENTS_HOME, { recursive: true });
1819
3170
  for (let attempt = 0; attempt < retries; attempt += 1) {
1820
3171
  try {
1821
- const handle = await open(LOCK_GUARD_PATH, "wx");
3172
+ const handle = await open2(LOCK_GUARD_PATH, "wx");
1822
3173
  await handle.close();
1823
3174
  return;
1824
3175
  } catch (error) {
@@ -1844,7 +3195,7 @@ async function writeLockFileUnsafe(lock) {
1844
3195
  }
1845
3196
  async function readLockFile() {
1846
3197
  try {
1847
- if (!existsSync9(LOCK_FILE_PATH)) {
3198
+ if (!existsSync11(LOCK_FILE_PATH)) {
1848
3199
  return { version: 1, skills: {}, mcpServers: {} };
1849
3200
  }
1850
3201
  const content = await readFile6(LOCK_FILE_PATH, "utf-8");
@@ -2575,9 +3926,9 @@ async function recommendSkills2(query, criteria, options = {}) {
2575
3926
  }
2576
3927
 
2577
3928
  // src/core/skills/library-loader.ts
2578
- import { existsSync as existsSync10, readdirSync, readFileSync } from "fs";
3929
+ import { existsSync as existsSync12, readdirSync, readFileSync } from "fs";
2579
3930
  import { createRequire } from "module";
2580
- import { basename as basename2, dirname as dirname3, join as join5 } from "path";
3931
+ import { basename as basename3, dirname as dirname3, join as join6 } from "path";
2581
3932
  var require2 = createRequire(import.meta.url);
2582
3933
  function loadLibraryFromModule(root) {
2583
3934
  let mod;
@@ -2623,16 +3974,16 @@ function loadLibraryFromModule(root) {
2623
3974
  return mod;
2624
3975
  }
2625
3976
  function buildLibraryFromFiles(root) {
2626
- const catalogPath = join5(root, "skills.json");
2627
- if (!existsSync10(catalogPath)) {
3977
+ const catalogPath = join6(root, "skills.json");
3978
+ if (!existsSync12(catalogPath)) {
2628
3979
  throw new Error(`No skills.json found at ${root}`);
2629
3980
  }
2630
3981
  const catalogData = JSON.parse(readFileSync(catalogPath, "utf-8"));
2631
3982
  const entries = catalogData.skills ?? [];
2632
3983
  const version = catalogData.version ?? "0.0.0";
2633
- const manifestPath = join5(root, "skills", "manifest.json");
3984
+ const manifestPath = join6(root, "skills", "manifest.json");
2634
3985
  let manifest;
2635
- if (existsSync10(manifestPath)) {
3986
+ if (existsSync12(manifestPath)) {
2636
3987
  manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
2637
3988
  } else {
2638
3989
  manifest = {
@@ -2642,14 +3993,14 @@ function buildLibraryFromFiles(root) {
2642
3993
  skills: []
2643
3994
  };
2644
3995
  }
2645
- const profilesDir = join5(root, "profiles");
3996
+ const profilesDir = join6(root, "profiles");
2646
3997
  const profiles = /* @__PURE__ */ new Map();
2647
- if (existsSync10(profilesDir)) {
3998
+ if (existsSync12(profilesDir)) {
2648
3999
  for (const file of readdirSync(profilesDir)) {
2649
4000
  if (!file.endsWith(".json")) continue;
2650
4001
  try {
2651
4002
  const profile = JSON.parse(
2652
- readFileSync(join5(profilesDir, file), "utf-8")
4003
+ readFileSync(join6(profilesDir, file), "utf-8")
2653
4004
  );
2654
4005
  profiles.set(profile.name, profile);
2655
4006
  } catch {
@@ -2663,9 +4014,9 @@ function buildLibraryFromFiles(root) {
2663
4014
  function getSkillDir2(name) {
2664
4015
  const entry = skillMap.get(name);
2665
4016
  if (entry) {
2666
- return dirname3(join5(root, entry.path));
4017
+ return dirname3(join6(root, entry.path));
2667
4018
  }
2668
- return join5(root, "skills", name);
4019
+ return join6(root, "skills", name);
2669
4020
  }
2670
4021
  function resolveDeps(names, visited = /* @__PURE__ */ new Set()) {
2671
4022
  const result = [];
@@ -2693,8 +4044,8 @@ function buildLibraryFromFiles(root) {
2693
4044
  return resolveDeps([...new Set(skills)]);
2694
4045
  }
2695
4046
  function discoverFiles(dir, ext) {
2696
- if (!existsSync10(dir)) return [];
2697
- return readdirSync(dir).filter((f) => f.endsWith(ext)).map((f) => basename2(f, ext));
4047
+ if (!existsSync12(dir)) return [];
4048
+ return readdirSync(dir).filter((f) => f.endsWith(ext)).map((f) => basename3(f, ext));
2698
4049
  }
2699
4050
  const library = {
2700
4051
  version,
@@ -2710,14 +4061,14 @@ function buildLibraryFromFiles(root) {
2710
4061
  getSkillPath(name) {
2711
4062
  const entry = skillMap.get(name);
2712
4063
  if (entry) {
2713
- return join5(root, entry.path);
4064
+ return join6(root, entry.path);
2714
4065
  }
2715
- return join5(root, "skills", name, "SKILL.md");
4066
+ return join6(root, "skills", name, "SKILL.md");
2716
4067
  },
2717
4068
  getSkillDir: getSkillDir2,
2718
4069
  readSkillContent(name) {
2719
4070
  const skillPath = library.getSkillPath(name);
2720
- if (!existsSync10(skillPath)) {
4071
+ if (!existsSync12(skillPath)) {
2721
4072
  throw new Error(`Skill content not found: ${skillPath}`);
2722
4073
  }
2723
4074
  return readFileSync(skillPath, "utf-8");
@@ -2744,11 +4095,11 @@ function buildLibraryFromFiles(root) {
2744
4095
  return resolveProfileByName(name);
2745
4096
  },
2746
4097
  listSharedResources() {
2747
- return discoverFiles(join5(root, "skills", "_shared"), ".md");
4098
+ return discoverFiles(join6(root, "skills", "_shared"), ".md");
2748
4099
  },
2749
4100
  getSharedResourcePath(name) {
2750
- const resourcePath = join5(root, "skills", "_shared", `${name}.md`);
2751
- return existsSync10(resourcePath) ? resourcePath : void 0;
4101
+ const resourcePath = join6(root, "skills", "_shared", `${name}.md`);
4102
+ return existsSync12(resourcePath) ? resourcePath : void 0;
2752
4103
  },
2753
4104
  readSharedResource(name) {
2754
4105
  const resourcePath = library.getSharedResourcePath(name);
@@ -2756,15 +4107,15 @@ function buildLibraryFromFiles(root) {
2756
4107
  return readFileSync(resourcePath, "utf-8");
2757
4108
  },
2758
4109
  listProtocols() {
2759
- const rootProtocols = discoverFiles(join5(root, "protocols"), ".md");
4110
+ const rootProtocols = discoverFiles(join6(root, "protocols"), ".md");
2760
4111
  if (rootProtocols.length > 0) return rootProtocols;
2761
- return discoverFiles(join5(root, "skills", "protocols"), ".md");
4112
+ return discoverFiles(join6(root, "skills", "protocols"), ".md");
2762
4113
  },
2763
4114
  getProtocolPath(name) {
2764
- const rootPath = join5(root, "protocols", `${name}.md`);
2765
- if (existsSync10(rootPath)) return rootPath;
2766
- const skillsPath = join5(root, "skills", "protocols", `${name}.md`);
2767
- return existsSync10(skillsPath) ? skillsPath : void 0;
4115
+ const rootPath = join6(root, "protocols", `${name}.md`);
4116
+ if (existsSync12(rootPath)) return rootPath;
4117
+ const skillsPath = join6(root, "skills", "protocols", `${name}.md`);
4118
+ return existsSync12(skillsPath) ? skillsPath : void 0;
2768
4119
  },
2769
4120
  readProtocol(name) {
2770
4121
  const protocolPath = library.getProtocolPath(name);
@@ -2789,8 +4140,8 @@ function buildLibraryFromFiles(root) {
2789
4140
  if (!entry.version) {
2790
4141
  issues.push({ level: "warn", field: "version", message: "Missing version" });
2791
4142
  }
2792
- const skillPath = join5(root, entry.path);
2793
- if (!existsSync10(skillPath)) {
4143
+ const skillPath = join6(root, entry.path);
4144
+ if (!existsSync12(skillPath)) {
2794
4145
  issues.push({
2795
4146
  level: "error",
2796
4147
  field: "path",
@@ -2849,15 +4200,15 @@ __export(catalog_exports, {
2849
4200
  validateAll: () => validateAll,
2850
4201
  validateSkillFrontmatter: () => validateSkillFrontmatter
2851
4202
  });
2852
- import { existsSync as existsSync11 } from "fs";
2853
- import { join as join6 } from "path";
4203
+ import { existsSync as existsSync13 } from "fs";
4204
+ import { join as join7 } from "path";
2854
4205
  var _library = null;
2855
4206
  function registerSkillLibrary(library) {
2856
4207
  _library = library;
2857
4208
  }
2858
4209
  function registerSkillLibraryFromPath(root) {
2859
- const indexPath = join6(root, "index.js");
2860
- if (existsSync11(indexPath)) {
4210
+ const indexPath = join7(root, "index.js");
4211
+ if (existsSync13(indexPath)) {
2861
4212
  _library = loadLibraryFromModule(root);
2862
4213
  return;
2863
4214
  }
@@ -2868,13 +4219,13 @@ function clearRegisteredLibrary() {
2868
4219
  }
2869
4220
  function discoverLibrary() {
2870
4221
  const envPath = process.env["CAAMP_SKILL_LIBRARY"];
2871
- if (envPath && existsSync11(envPath)) {
4222
+ if (envPath && existsSync13(envPath)) {
2872
4223
  try {
2873
- const indexPath = join6(envPath, "index.js");
2874
- if (existsSync11(indexPath)) {
4224
+ const indexPath = join7(envPath, "index.js");
4225
+ if (existsSync13(indexPath)) {
2875
4226
  return loadLibraryFromModule(envPath);
2876
4227
  }
2877
- if (existsSync11(join6(envPath, "skills.json"))) {
4228
+ if (existsSync13(join7(envPath, "skills.json"))) {
2878
4229
  return buildLibraryFromFiles(envPath);
2879
4230
  }
2880
4231
  } catch {
@@ -2981,9 +4332,9 @@ function getLibraryRoot() {
2981
4332
  }
2982
4333
 
2983
4334
  // src/core/skills/discovery.ts
2984
- import { existsSync as existsSync12 } from "fs";
4335
+ import { existsSync as existsSync14 } from "fs";
2985
4336
  import { readdir as readdir2, readFile as readFile7 } from "fs/promises";
2986
- import { join as join7 } from "path";
4337
+ import { join as join8 } from "path";
2987
4338
  import matter from "gray-matter";
2988
4339
  async function parseSkillFile(filePath) {
2989
4340
  try {
@@ -3007,8 +4358,8 @@ async function parseSkillFile(filePath) {
3007
4358
  }
3008
4359
  }
3009
4360
  async function discoverSkill(skillDir) {
3010
- const skillFile = join7(skillDir, "SKILL.md");
3011
- if (!existsSync12(skillFile)) return null;
4361
+ const skillFile = join8(skillDir, "SKILL.md");
4362
+ if (!existsSync14(skillFile)) return null;
3012
4363
  const metadata = await parseSkillFile(skillFile);
3013
4364
  if (!metadata) return null;
3014
4365
  return {
@@ -3019,12 +4370,12 @@ async function discoverSkill(skillDir) {
3019
4370
  };
3020
4371
  }
3021
4372
  async function discoverSkills(rootDir) {
3022
- if (!existsSync12(rootDir)) return [];
4373
+ if (!existsSync14(rootDir)) return [];
3023
4374
  const entries = await readdir2(rootDir, { withFileTypes: true });
3024
4375
  const skills = [];
3025
4376
  for (const entry of entries) {
3026
4377
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
3027
- const skillDir = join7(rootDir, entry.name);
4378
+ const skillDir = join8(rootDir, entry.name);
3028
4379
  const skill = await discoverSkill(skillDir);
3029
4380
  if (skill) {
3030
4381
  skills.push(skill);
@@ -3048,7 +4399,7 @@ async function discoverSkillsMulti(dirs) {
3048
4399
  }
3049
4400
 
3050
4401
  // src/core/skills/validator.ts
3051
- import { existsSync as existsSync13 } from "fs";
4402
+ import { existsSync as existsSync15 } from "fs";
3052
4403
  import { readFile as readFile8 } from "fs/promises";
3053
4404
  import matter2 from "gray-matter";
3054
4405
  var RESERVED_NAMES = [
@@ -3070,7 +4421,7 @@ var WARN_BODY_LINES = 500;
3070
4421
  var WARN_DESCRIPTION_LENGTH = 50;
3071
4422
  async function validateSkill(filePath) {
3072
4423
  const issues = [];
3073
- if (!existsSync13(filePath)) {
4424
+ if (!existsSync15(filePath)) {
3074
4425
  return {
3075
4426
  valid: false,
3076
4427
  issues: [{ level: "error", field: "file", message: "File does not exist" }],
@@ -3205,6 +4556,13 @@ export {
3205
4556
  selectProvidersByMinimumPriority,
3206
4557
  installBatchWithRollback,
3207
4558
  updateInstructionsSingleOperation,
4559
+ DEFAULT_EXCLUSIVITY_MODE,
4560
+ EXCLUSIVITY_MODE_ENV_VAR,
4561
+ PiRequiredError,
4562
+ isExclusivityMode,
4563
+ getExclusivityMode,
4564
+ setExclusivityMode,
4565
+ resetExclusivityModeOverride,
3208
4566
  PiHarness,
3209
4567
  getHarnessFor,
3210
4568
  getPrimaryHarness,
@@ -3219,17 +4577,25 @@ export {
3219
4577
  writeConfig,
3220
4578
  removeConfig,
3221
4579
  readLockFile,
4580
+ resolveMcpConfigPath,
4581
+ listMcpServers,
4582
+ listAllMcpServers,
4583
+ detectMcpInstallations,
4584
+ installMcpServer,
4585
+ removeMcpServer,
4586
+ removeMcpServerFromAll,
4587
+ fetchWithTimeout,
4588
+ formatNetworkError,
4589
+ parseSource,
4590
+ isMarketplaceScoped,
3222
4591
  scanFile,
3223
4592
  scanDirectory,
3224
4593
  toSarif,
3225
- parseSource,
3226
- isMarketplaceScoped,
3227
4594
  recordSkillInstall,
3228
4595
  removeSkillFromLock,
3229
4596
  getTrackedSkills,
3230
4597
  checkSkillUpdate,
3231
4598
  checkAllSkillUpdates,
3232
- formatNetworkError,
3233
4599
  MarketplaceClient,
3234
4600
  RECOMMENDATION_ERROR_CODES,
3235
4601
  tokenizeCriteriaValue,
@@ -3258,4 +4624,4 @@ export {
3258
4624
  discoverSkillsMulti,
3259
4625
  validateSkill
3260
4626
  };
3261
- //# sourceMappingURL=chunk-43GULI6J.js.map
4627
+ //# sourceMappingURL=chunk-JC77OAHA.js.map