@cleocode/caamp 2026.4.7 → 2026.4.10

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.
@@ -511,12 +511,78 @@ async function updateInstructionsSingleOperation(providers, content, scope = "pr
511
511
  return summary;
512
512
  }
513
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
+
514
568
  // src/core/harness/pi.ts
515
569
  import { spawn } from "child_process";
516
570
  import { existsSync as existsSync4 } from "fs";
517
- import { cp as cp3, mkdir as mkdir3, open, readdir, readFile, rename, rm as rm3, stat, 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";
518
583
  import { homedir as homedir2 } from "os";
519
584
  import { basename as basename2, dirname as dirname2, extname, join as join5 } from "path";
585
+ import { parseDocument, validateDocument } from "@cleocode/cant";
520
586
 
521
587
  // src/core/harness/scope.ts
522
588
  import { homedir } from "os";
@@ -626,6 +692,38 @@ async function atomicWriteJson(filePath, data) {
626
692
  `, "utf8");
627
693
  await rename(tmp, filePath);
628
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
+ }
629
727
  var PiHarness = class {
630
728
  /**
631
729
  * Construct a harness bound to a resolved Pi provider.
@@ -662,19 +760,25 @@ var PiHarness = class {
662
760
  return scope.kind === "global" ? join5(getPiAgentDir2(), "AGENTS.md") : join5(scope.projectDir, "AGENTS.md");
663
761
  }
664
762
  // ── Skills ──────────────────────────────────────────────────────────
665
- /** {@inheritDoc Harness.installSkill} */
763
+ /**
764
+ * Install a skill directory into the resolved Pi skills location.
765
+ */
666
766
  async installSkill(sourcePath, skillName, scope) {
667
767
  const targetDir = join5(this.skillsDir(scope), skillName);
668
768
  await rm3(targetDir, { recursive: true, force: true });
669
769
  await mkdir3(dirname2(targetDir), { recursive: true });
670
770
  await cp3(sourcePath, targetDir, { recursive: true });
671
771
  }
672
- /** {@inheritDoc Harness.removeSkill} */
772
+ /**
773
+ * Remove a skill directory from the resolved Pi skills location.
774
+ */
673
775
  async removeSkill(skillName, scope) {
674
776
  const targetDir = join5(this.skillsDir(scope), skillName);
675
777
  await rm3(targetDir, { recursive: true, force: true });
676
778
  }
677
- /** {@inheritDoc Harness.listSkills} */
779
+ /**
780
+ * List the installed skill directories at the given scope.
781
+ */
678
782
  async listSkills(scope) {
679
783
  const dir = this.skillsDir(scope);
680
784
  if (!existsSync4(dir)) return [];
@@ -682,7 +786,10 @@ var PiHarness = class {
682
786
  return entries.filter((e) => e.isDirectory()).map((e) => e.name);
683
787
  }
684
788
  // ── Instructions ────────────────────────────────────────────────────
685
- /** {@inheritDoc Harness.injectInstructions} */
789
+ /**
790
+ * Inject or replace a CAAMP-managed instruction block inside the Pi
791
+ * `AGENTS.md` file for the resolved scope.
792
+ */
686
793
  async injectInstructions(content, scope) {
687
794
  const filePath = this.agentsMdPath(scope);
688
795
  await mkdir3(dirname2(filePath), { recursive: true });
@@ -706,7 +813,10 @@ ${MARKER_END}`;
706
813
  }
707
814
  await writeFile(filePath, updated, "utf8");
708
815
  }
709
- /** {@inheritDoc Harness.removeInstructions} */
816
+ /**
817
+ * Remove the CAAMP-managed instruction block from the Pi `AGENTS.md`
818
+ * file at the resolved scope.
819
+ */
710
820
  async removeInstructions(scope) {
711
821
  const filePath = this.agentsMdPath(scope);
712
822
  if (!existsSync4(filePath)) return;
@@ -716,21 +826,79 @@ ${MARKER_END}`;
716
826
  await writeFile(filePath, stripped.length === 0 ? "" : `${stripped}
717
827
  `, "utf8");
718
828
  }
719
- // ── Subagent spawn ──────────────────────────────────────────────────
829
+ // ── Subagent spawn (ADR-035 §D6) ────────────────────────────────────
720
830
  /**
721
- * {@inheritDoc Harness.spawnSubagent}
831
+ * Spawn a subagent through Pi's configured `spawnCommand` and return a
832
+ * live handle bound to the canonical streaming, attribution, and
833
+ * cleanup contract.
722
834
  *
723
835
  * @remarks
724
- * Invokes Pi's configured `spawnCommand` (e.g.
725
- * `["pi", "--mode", "json", "-p", "--no-session"]`) with the task prompt
726
- * appended as the trailing positional argument. The {@link SubagentTask.targetProviderId}
727
- * is a routing hint carried in the prompt stream; Pi's own extension
728
- * layer dispatches to the correct inner agent.
836
+ * Per ADR-035 §D6 this is the **only** sanctioned subagent spawn path
837
+ * in CLEO. All historical direct `child_process.spawn` callers in
838
+ * subagent contexts (including the `cant-bridge.ts` Pi extension and
839
+ * the legacy CLEO orchestrator paths) MUST migrate to this method so
840
+ * the contract below holds uniformly. A custom biome rule banning
841
+ * raw `spawn()` from subagent code is planned for v3 cleanup but is
842
+ * intentionally NOT enforced in v2 to keep the migration incremental.
729
843
  *
730
- * Throws immediately when the provider entry is missing a `spawnCommand`
731
- * so callers see configuration errors early rather than at child-exit time.
844
+ * **Streaming semantics** Pi's `--mode json` produces line-delimited
845
+ * JSON on stdout. The harness:
846
+ *
847
+ * - Line-buffers stdout, parses each line as JSON, and forwards a
848
+ * `{ kind: 'message', subagentId, lineNumber, payload }`
849
+ * {@link SubagentStreamEvent} via {@link SubagentSpawnOptions.onStream}.
850
+ * Non-parseable lines increment a warning counter (recorded in the
851
+ * child session as `{ type: 'raw' }`) but never crash the loop.
852
+ * - Line-buffers stderr separately, forwards each line as
853
+ * `{ kind: 'stderr', subagentId, payload: { line } }`, and stores
854
+ * it in a 100-line ring buffer accessible via
855
+ * {@link SubagentHandle.recentStderr}. Stderr is **never** injected
856
+ * into the parent LLM context per ADR-035 §D6.
857
+ * - Emits a final `{ kind: 'exit', subagentId, payload: SubagentExitResult }`
858
+ * when the child terminates.
859
+ *
860
+ * **Session attribution** — Every spawn produces a child session JSONL
861
+ * file at
862
+ * `~/.pi/agent/sessions/subagents/subagent-{parentSessionId}-{taskId}.jsonl`.
863
+ * The header line records the subagentId, taskId, and parent linkage.
864
+ * When {@link SubagentTask.parentSessionPath} is supplied, a
865
+ * {@link SubagentLinkEntry} is appended to the parent session file as
866
+ * a JSONL line so listing the parent surfaces its children.
867
+ *
868
+ * **Exit propagation** — {@link SubagentHandle.exitPromise} resolves
869
+ * with `{ code, signal, childSessionPath, durationMs }` exactly once
870
+ * when the child exits. The promise NEVER rejects: failure is
871
+ * encoded by a non-zero `code`, a non-null `signal`, or partial
872
+ * output preserved in the child session file.
873
+ *
874
+ * **Cleanup** — {@link SubagentHandle.terminate} sends SIGTERM, waits
875
+ * the configured grace window, then sends SIGKILL if the child is
876
+ * still alive. The grace window is sourced from
877
+ * {@link SubagentSpawnOptions.terminateGraceMs} when supplied,
878
+ * otherwise from `settings.json:pi.subagent.terminateGraceMs`,
879
+ * otherwise from {@link DEFAULT_TERMINATE_GRACE_MS}. A
880
+ * `subagent_exit` entry with reason `terminated` is appended to the
881
+ * child session file when cleanup runs.
882
+ *
883
+ * **Concurrency** — Use the static helpers
884
+ * {@link PiHarness.raceSubagents} and
885
+ * {@link PiHarness.settleAllSubagents} to compose `parallel: race`
886
+ * and `parallel: settle` constructs from CANT workflows over multiple
887
+ * handles.
888
+ *
889
+ * **Orphan handling** — On the first spawn the harness registers a
890
+ * process-wide `'exit'` handler that terminates every still-active
891
+ * subagent so a parent crash never strands children.
892
+ *
893
+ * Throws immediately when the provider entry is missing a
894
+ * `spawnCommand` so callers see configuration errors early rather
895
+ * than at child-exit time.
896
+ *
897
+ * @param task - Subagent task specification.
898
+ * @param opts - Per-call streaming and cleanup overrides.
899
+ * @returns A live subagent handle.
732
900
  */
733
- async spawnSubagent(task) {
901
+ async spawnSubagent(task, opts = {}) {
734
902
  const cmd = this.provider.capabilities.spawn.spawnCommand;
735
903
  if (cmd === null || cmd.length === 0) {
736
904
  throw new Error(
@@ -741,46 +909,316 @@ ${MARKER_END}`;
741
909
  if (typeof program !== "string" || program.length === 0) {
742
910
  throw new Error("PiHarness.spawnSubagent: invalid spawnCommand (missing program)");
743
911
  }
912
+ const taskId = task.taskId ?? generateShortId();
913
+ const parentSessionId = task.parentSessionId ?? "orphan";
914
+ const subagentId = `sub-${taskId}-${generateShortId().slice(0, 6)}`;
915
+ const childSessionPath = join5(
916
+ getPiAgentDir2(),
917
+ "sessions",
918
+ "subagents",
919
+ `subagent-${parentSessionId}-${taskId}.jsonl`
920
+ );
921
+ await mkdir3(dirname2(childSessionPath), { recursive: true });
922
+ let grace = opts.terminateGraceMs;
923
+ if (grace === void 0) {
924
+ try {
925
+ const settings = await this.readSettings({ kind: "global" });
926
+ grace = readTerminateGraceFromSettings(settings, DEFAULT_TERMINATE_GRACE_MS);
927
+ } catch {
928
+ grace = DEFAULT_TERMINATE_GRACE_MS;
929
+ }
930
+ }
931
+ if (!Number.isFinite(grace) || grace < 0) {
932
+ grace = DEFAULT_TERMINATE_GRACE_MS;
933
+ }
934
+ const startedAt = /* @__PURE__ */ new Date();
935
+ const startedAtIso = startedAt.toISOString();
936
+ const sessionHeader = {
937
+ type: "session",
938
+ version: 3,
939
+ id: subagentId,
940
+ timestamp: startedAtIso,
941
+ cwd: opts.cwd ?? task.cwd ?? process.cwd(),
942
+ parentSession: task.parentSessionId ?? null,
943
+ taskId,
944
+ childSessionPath
945
+ };
946
+ await writeFile(childSessionPath, `${JSON.stringify(sessionHeader)}
947
+ `, "utf8");
744
948
  const baseArgs = cmd.slice(1);
745
949
  const args = [...baseArgs, task.prompt];
746
950
  const child = spawn(program, args, {
747
- cwd: task.cwd,
748
- env: { ...process.env, ...task.env },
951
+ cwd: opts.cwd ?? task.cwd,
952
+ env: { ...process.env, ...task.env, ...opts.env },
749
953
  stdio: ["ignore", "pipe", "pipe"]
750
954
  });
751
- let stdout = "";
752
- let stderr = "";
955
+ let stdoutAccum = "";
956
+ let stderrAccum = "";
957
+ let stdoutBuffer = "";
958
+ let stderrBuffer = "";
959
+ let stdoutLineNumber = 0;
960
+ let nonJsonLineCount = 0;
961
+ const stderrRing = [];
962
+ const safeOnStream = (event) => {
963
+ if (opts.onStream === void 0) return;
964
+ try {
965
+ opts.onStream(event);
966
+ } catch (err) {
967
+ const message = err instanceof Error ? err.message : String(err);
968
+ stderrRing.push(`[onStream] ${message}`);
969
+ if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
970
+ }
971
+ };
972
+ const writeChildSession = (entry) => {
973
+ void appendFile(childSessionPath, `${JSON.stringify(entry)}
974
+ `, "utf8").catch(() => {
975
+ const synthetic = `[childSession] failed to append entry`;
976
+ stderrRing.push(synthetic);
977
+ if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
978
+ });
979
+ };
980
+ const flushStdoutBuffer = (final) => {
981
+ let nlIdx = stdoutBuffer.indexOf("\n");
982
+ while (nlIdx !== -1) {
983
+ const line = stdoutBuffer.slice(0, nlIdx);
984
+ stdoutBuffer = stdoutBuffer.slice(nlIdx + 1);
985
+ this.handleStdoutLine(line, {
986
+ subagentId,
987
+ increment: () => ++stdoutLineNumber,
988
+ incrementNonJson: () => ++nonJsonLineCount,
989
+ writeChildSession,
990
+ safeOnStream
991
+ });
992
+ nlIdx = stdoutBuffer.indexOf("\n");
993
+ }
994
+ if (final && stdoutBuffer.length > 0) {
995
+ const remainder = stdoutBuffer;
996
+ stdoutBuffer = "";
997
+ this.handleStdoutLine(remainder, {
998
+ subagentId,
999
+ increment: () => ++stdoutLineNumber,
1000
+ incrementNonJson: () => ++nonJsonLineCount,
1001
+ writeChildSession,
1002
+ safeOnStream
1003
+ });
1004
+ }
1005
+ };
1006
+ const flushStderrBuffer = (final) => {
1007
+ let nlIdx = stderrBuffer.indexOf("\n");
1008
+ while (nlIdx !== -1) {
1009
+ const line = stderrBuffer.slice(0, nlIdx);
1010
+ stderrBuffer = stderrBuffer.slice(nlIdx + 1);
1011
+ stderrRing.push(line);
1012
+ if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
1013
+ writeChildSession({ type: "subagent_stderr", line });
1014
+ safeOnStream({ kind: "stderr", subagentId, payload: { line } });
1015
+ nlIdx = stderrBuffer.indexOf("\n");
1016
+ }
1017
+ if (final && stderrBuffer.length > 0) {
1018
+ const line = stderrBuffer;
1019
+ stderrBuffer = "";
1020
+ stderrRing.push(line);
1021
+ if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
1022
+ writeChildSession({ type: "subagent_stderr", line });
1023
+ safeOnStream({ kind: "stderr", subagentId, payload: { line } });
1024
+ }
1025
+ };
753
1026
  child.stdout?.on("data", (chunk) => {
754
- stdout += chunk.toString("utf8");
1027
+ const text = chunk.toString("utf8");
1028
+ stdoutAccum += text;
1029
+ stdoutBuffer += text;
1030
+ flushStdoutBuffer(false);
755
1031
  });
756
1032
  child.stderr?.on("data", (chunk) => {
757
- stderr += chunk.toString("utf8");
758
- });
759
- const result = new Promise((resolve) => {
760
- child.on("close", (exitCode) => {
761
- let parsed;
762
- try {
763
- parsed = JSON.parse(stdout);
764
- } catch {
765
- }
766
- resolve({ exitCode, stdout, stderr, parsed });
767
- });
1033
+ const text = chunk.toString("utf8");
1034
+ stderrAccum += text;
1035
+ stderrBuffer += text;
1036
+ flushStderrBuffer(false);
768
1037
  });
1038
+ let terminating = false;
1039
+ let terminationReason = "natural";
1040
+ let terminatePromise = null;
1041
+ const terminateImpl = () => {
1042
+ if (terminatePromise !== null) return terminatePromise;
1043
+ terminating = true;
1044
+ terminationReason = "terminated";
1045
+ terminatePromise = terminateSubagent(child, grace ?? DEFAULT_TERMINATE_GRACE_MS);
1046
+ return terminatePromise;
1047
+ };
1048
+ const terminateSync = () => {
1049
+ if (terminating) return;
1050
+ terminating = true;
1051
+ terminationReason = "terminated";
1052
+ try {
1053
+ child.kill("SIGTERM");
1054
+ } catch {
1055
+ }
1056
+ };
1057
+ const activeRecord = {
1058
+ child,
1059
+ subagentId,
1060
+ terminate: terminateSync
1061
+ };
1062
+ activeSubagents.add(activeRecord);
1063
+ ensureOrphanSweeperRegistered();
769
1064
  if (task.signal !== void 0) {
770
- task.signal.addEventListener("abort", () => {
771
- child.kill();
1065
+ const onAbort = () => {
1066
+ void terminateImpl();
1067
+ };
1068
+ if (task.signal.aborted) {
1069
+ onAbort();
1070
+ } else {
1071
+ task.signal.addEventListener("abort", onAbort, { once: true });
1072
+ }
1073
+ }
1074
+ const exitPromise = new Promise((resolve) => {
1075
+ child.on("close", (exitCode, signal) => {
1076
+ flushStdoutBuffer(true);
1077
+ flushStderrBuffer(true);
1078
+ const durationMs = Date.now() - startedAt.getTime();
1079
+ writeChildSession({
1080
+ type: "subagent_exit",
1081
+ code: exitCode,
1082
+ signal,
1083
+ reason: terminationReason,
1084
+ durationMs,
1085
+ nonJsonLineCount
1086
+ });
1087
+ activeSubagents.delete(activeRecord);
1088
+ const result2 = {
1089
+ code: exitCode,
1090
+ signal,
1091
+ childSessionPath,
1092
+ durationMs
1093
+ };
1094
+ safeOnStream({ kind: "exit", subagentId, payload: result2 });
1095
+ resolve(result2);
772
1096
  });
1097
+ child.on("error", () => {
1098
+ });
1099
+ });
1100
+ const result = exitPromise.then(({ code }) => {
1101
+ let parsed;
1102
+ try {
1103
+ parsed = JSON.parse(stdoutAccum);
1104
+ } catch {
1105
+ }
1106
+ return { exitCode: code, stdout: stdoutAccum, stderr: stderrAccum, parsed };
1107
+ });
1108
+ const linkEntry = {
1109
+ type: "subagent_link",
1110
+ subagentId,
1111
+ taskId,
1112
+ childSessionPath,
1113
+ startedAt: startedAtIso
1114
+ };
1115
+ if (task.parentSessionPath !== void 0 && task.parentSessionPath.length > 0) {
1116
+ try {
1117
+ await writeSubagentLink(task.parentSessionPath, linkEntry);
1118
+ safeOnStream({ kind: "link", subagentId, payload: linkEntry });
1119
+ } catch {
1120
+ stderrRing.push(`[link] failed to write subagent_link to parent`);
1121
+ if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
1122
+ }
773
1123
  }
774
1124
  return {
1125
+ subagentId,
1126
+ taskId,
1127
+ childSessionPath,
775
1128
  pid: child.pid ?? null,
1129
+ startedAt,
1130
+ exitPromise,
776
1131
  result,
1132
+ terminate: terminateImpl,
777
1133
  abort: () => {
778
- child.kill();
779
- }
1134
+ void terminateImpl();
1135
+ },
1136
+ recentStderr: () => stderrRing.slice()
780
1137
  };
781
1138
  }
1139
+ /**
1140
+ * Race a set of subagent handles, returning the first one that exits.
1141
+ *
1142
+ * @remarks
1143
+ * Maps CANT's `parallel: race` construct (per ADR-035 §D6) onto the
1144
+ * canonical {@link spawnSubagent} contract. The losing handles are
1145
+ * gracefully terminated via {@link SubagentHandle.terminate} once the
1146
+ * first settles so no straggler children outlive the race.
1147
+ *
1148
+ * @param handles - Subagent handles to race.
1149
+ * @returns The {@link SubagentExitResult} of the first child to exit.
1150
+ * @throws When `handles` is empty (caller bug — a race over zero
1151
+ * children has no winner).
1152
+ */
1153
+ static async raceSubagents(handles) {
1154
+ if (handles.length === 0) {
1155
+ throw new Error("PiHarness.raceSubagents: cannot race an empty handle list");
1156
+ }
1157
+ const tagged = handles.map(
1158
+ (handle, index) => handle.exitPromise.then((value) => ({ index, value }))
1159
+ );
1160
+ const winner = await Promise.race(tagged);
1161
+ const losers = [];
1162
+ for (let i = 0; i < handles.length; i += 1) {
1163
+ if (i === winner.index) continue;
1164
+ const loser = handles[i];
1165
+ if (loser === void 0) continue;
1166
+ losers.push(loser.terminate().catch(() => void 0));
1167
+ }
1168
+ await Promise.all(losers);
1169
+ return winner.value;
1170
+ }
1171
+ /**
1172
+ * Settle a set of subagent handles, returning a parallel array of
1173
+ * results.
1174
+ *
1175
+ * @remarks
1176
+ * Maps CANT's `parallel: settle` construct (per ADR-035 §D6) onto the
1177
+ * canonical {@link spawnSubagent} contract. Because
1178
+ * {@link SubagentHandle.exitPromise} never rejects, every entry in
1179
+ * the returned array is `{ status: 'fulfilled', value: ... }` under
1180
+ * normal operation; the `PromiseSettledResult` shape is preserved
1181
+ * for forward compatibility with future failure modes.
1182
+ *
1183
+ * @param handles - Subagent handles to settle.
1184
+ * @returns Parallel array of settled exit results, one per input.
1185
+ */
1186
+ static async settleAllSubagents(handles) {
1187
+ return Promise.allSettled(handles.map((h) => h.exitPromise));
1188
+ }
1189
+ /**
1190
+ * Per-line stdout dispatcher used by the streaming buffer flusher.
1191
+ *
1192
+ * @remarks
1193
+ * Extracted as a private method so the line-handling logic stays
1194
+ * close to {@link spawnSubagent} but does not bloat the parent
1195
+ * function. Skips empty lines (a leading newline produces a zero-
1196
+ * length entry that has no semantic meaning).
1197
+ */
1198
+ handleStdoutLine(rawLine, ctx) {
1199
+ const trimmed = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
1200
+ if (trimmed.length === 0) return;
1201
+ const lineNumber = ctx.increment();
1202
+ let parsed;
1203
+ try {
1204
+ parsed = JSON.parse(trimmed);
1205
+ } catch {
1206
+ ctx.incrementNonJson();
1207
+ ctx.writeChildSession({ type: "raw", lineNumber, line: trimmed });
1208
+ return;
1209
+ }
1210
+ ctx.writeChildSession({ type: "custom_message", lineNumber, payload: parsed });
1211
+ ctx.safeOnStream({
1212
+ kind: "message",
1213
+ subagentId: ctx.subagentId,
1214
+ lineNumber,
1215
+ payload: parsed
1216
+ });
1217
+ }
782
1218
  // ── Settings ────────────────────────────────────────────────────────
783
- /** {@inheritDoc Harness.readSettings} */
1219
+ /**
1220
+ * Read the Pi `settings.json` file for the resolved scope.
1221
+ */
784
1222
  async readSettings(scope) {
785
1223
  const filePath = this.settingsPath(scope);
786
1224
  if (!existsSync4(filePath)) return {};
@@ -791,7 +1229,10 @@ ${MARKER_END}`;
791
1229
  return {};
792
1230
  }
793
1231
  }
794
- /** {@inheritDoc Harness.writeSettings} */
1232
+ /**
1233
+ * Merge a partial patch into the Pi `settings.json` file for the
1234
+ * resolved scope using an atomic write.
1235
+ */
795
1236
  async writeSettings(patch, scope) {
796
1237
  const filePath = this.settingsPath(scope);
797
1238
  const current = await this.readSettings(scope);
@@ -799,7 +1240,10 @@ ${MARKER_END}`;
799
1240
  const merged = deepMerge(currentObj, patch);
800
1241
  await atomicWriteJson(filePath, merged);
801
1242
  }
802
- /** {@inheritDoc Harness.configureModels} */
1243
+ /**
1244
+ * Persist the supplied model-name patterns into `settings.enabledModels`
1245
+ * at the resolved scope.
1246
+ */
803
1247
  async configureModels(modelPatterns, scope) {
804
1248
  await this.writeSettings({ enabledModels: modelPatterns }, scope);
805
1249
  }
@@ -824,7 +1268,10 @@ ${MARKER_END}`;
824
1268
  return join5(getPiAgentDir2(), "sessions");
825
1269
  }
826
1270
  // ── Extensions (Wave-1, T263) ───────────────────────────────────────
827
- /** {@inheritDoc Harness.installExtension} */
1271
+ /**
1272
+ * Install a Pi extension `.ts` source file into the resolved tier's
1273
+ * extensions directory, validating that it has a default export.
1274
+ */
828
1275
  async installExtension(sourcePath, name, tier, projectDir, opts) {
829
1276
  if (!existsSync4(sourcePath)) {
830
1277
  throw new Error(`installExtension: source file does not exist: ${sourcePath}`);
@@ -856,7 +1303,9 @@ ${MARKER_END}`;
856
1303
  await writeFile(targetPath, contents, "utf8");
857
1304
  return { targetPath, tier };
858
1305
  }
859
- /** {@inheritDoc Harness.removeExtension} */
1306
+ /**
1307
+ * Remove a Pi extension `.ts` source file from the resolved tier.
1308
+ */
860
1309
  async removeExtension(name, tier, projectDir) {
861
1310
  const dir = resolveTierDir({ tier, kind: "extensions", projectDir });
862
1311
  const targetPath = join5(dir, `${name}.ts`);
@@ -864,7 +1313,10 @@ ${MARKER_END}`;
864
1313
  await rm3(targetPath, { force: true });
865
1314
  return true;
866
1315
  }
867
- /** {@inheritDoc Harness.listExtensions} */
1316
+ /**
1317
+ * List Pi extension files across every tier in precedence order,
1318
+ * flagging shadowed entries from lower tiers.
1319
+ */
868
1320
  async listExtensions(projectDir) {
869
1321
  const tiers = resolveAllTiers("extensions", projectDir);
870
1322
  const out = [];
@@ -895,7 +1347,10 @@ ${MARKER_END}`;
895
1347
  return out;
896
1348
  }
897
1349
  // ── Sessions (Wave-1, T264) ─────────────────────────────────────────
898
- /** {@inheritDoc Harness.listSessions} */
1350
+ /**
1351
+ * List Pi session JSONL files (including subagent children when
1352
+ * requested), summarising only the header line per file.
1353
+ */
899
1354
  async listSessions(opts) {
900
1355
  const rootDir = this.sessionsDir();
901
1356
  if (!existsSync4(rootDir)) return [];
@@ -935,7 +1390,9 @@ ${MARKER_END}`;
935
1390
  summaries.sort((a, b) => b.mtimeMs - a.mtimeMs);
936
1391
  return summaries;
937
1392
  }
938
- /** {@inheritDoc Harness.showSession} */
1393
+ /**
1394
+ * Show the full entries of a single Pi session by id.
1395
+ */
939
1396
  async showSession(id) {
940
1397
  const summaries = await this.listSessions({ includeSubagents: true });
941
1398
  const match = summaries.find((s) => s.id === id);
@@ -951,7 +1408,10 @@ ${MARKER_END}`;
951
1408
  return { summary: match, entries };
952
1409
  }
953
1410
  // ── Models (Wave-1, T265) ───────────────────────────────────────────
954
- /** {@inheritDoc Harness.readModelsConfig} */
1411
+ /**
1412
+ * Read the Pi `models.json` file for the resolved scope, tolerating
1413
+ * missing or malformed files by returning an empty provider map.
1414
+ */
955
1415
  async readModelsConfig(scope) {
956
1416
  const filePath = this.modelsConfigPath(scope);
957
1417
  if (!existsSync4(filePath)) return { providers: {} };
@@ -977,12 +1437,18 @@ ${MARKER_END}`;
977
1437
  return { providers: {} };
978
1438
  }
979
1439
  }
980
- /** {@inheritDoc Harness.writeModelsConfig} */
1440
+ /**
1441
+ * Write the Pi `models.json` file for the resolved scope via an atomic
1442
+ * tmp-then-rename sequence.
1443
+ */
981
1444
  async writeModelsConfig(config, scope) {
982
1445
  const filePath = this.modelsConfigPath(scope);
983
1446
  await atomicWriteJson(filePath, config);
984
1447
  }
985
- /** {@inheritDoc Harness.listModels} */
1448
+ /**
1449
+ * Compose a flat `ModelListEntry` list from `models.json` plus the
1450
+ * `enabledModels` and default-model hints in `settings.json`.
1451
+ */
986
1452
  async listModels(scope) {
987
1453
  const models = await this.readModelsConfig(scope);
988
1454
  const settings = await this.readSettings(scope);
@@ -1044,7 +1510,10 @@ ${MARKER_END}`;
1044
1510
  return out;
1045
1511
  }
1046
1512
  // ── Prompts (Wave-1, T266) ──────────────────────────────────────────
1047
- /** {@inheritDoc Harness.installPrompt} */
1513
+ /**
1514
+ * Install a Pi prompt directory (containing `prompt.md`) into the
1515
+ * resolved tier's prompts directory.
1516
+ */
1048
1517
  async installPrompt(sourceDir, name, tier, projectDir, opts) {
1049
1518
  if (!existsSync4(sourceDir)) {
1050
1519
  throw new Error(`installPrompt: source directory does not exist: ${sourceDir}`);
@@ -1070,7 +1539,10 @@ ${MARKER_END}`;
1070
1539
  await cp3(sourceDir, targetPath, { recursive: true });
1071
1540
  return { targetPath, tier };
1072
1541
  }
1073
- /** {@inheritDoc Harness.listPrompts} */
1542
+ /**
1543
+ * List Pi prompt directories across every tier in precedence order,
1544
+ * flagging shadowed entries from lower tiers.
1545
+ */
1074
1546
  async listPrompts(projectDir) {
1075
1547
  const tiers = resolveAllTiers("prompts", projectDir);
1076
1548
  const out = [];
@@ -1098,7 +1570,9 @@ ${MARKER_END}`;
1098
1570
  }
1099
1571
  return out;
1100
1572
  }
1101
- /** {@inheritDoc Harness.removePrompt} */
1573
+ /**
1574
+ * Remove a Pi prompt directory from the resolved tier.
1575
+ */
1102
1576
  async removePrompt(name, tier, projectDir) {
1103
1577
  const dir = resolveTierDir({ tier, kind: "prompts", projectDir });
1104
1578
  const targetPath = join5(dir, name);
@@ -1107,7 +1581,11 @@ ${MARKER_END}`;
1107
1581
  return true;
1108
1582
  }
1109
1583
  // ── Themes (Wave-1, T267) ───────────────────────────────────────────
1110
- /** {@inheritDoc Harness.installTheme} */
1584
+ /**
1585
+ * Install a Pi theme file (`.ts`/`.tsx`/`.mts`/`.json`) into the
1586
+ * resolved tier's themes directory, blocking same-stem conflicts
1587
+ * unless `--force` is supplied.
1588
+ */
1111
1589
  async installTheme(sourceFile, name, tier, projectDir, opts) {
1112
1590
  if (!existsSync4(sourceFile)) {
1113
1591
  throw new Error(`installTheme: source file does not exist: ${sourceFile}`);
@@ -1146,7 +1624,10 @@ ${MARKER_END}`;
1146
1624
  await writeFile(targetPath, contents);
1147
1625
  return { targetPath, tier };
1148
1626
  }
1149
- /** {@inheritDoc Harness.listThemes} */
1627
+ /**
1628
+ * List Pi theme files across every tier in precedence order, flagging
1629
+ * shadowed entries from lower tiers.
1630
+ */
1150
1631
  async listThemes(projectDir) {
1151
1632
  const tiers = resolveAllTiers("themes", projectDir);
1152
1633
  const out = [];
@@ -1178,7 +1659,10 @@ ${MARKER_END}`;
1178
1659
  }
1179
1660
  return out;
1180
1661
  }
1181
- /** {@inheritDoc Harness.removeTheme} */
1662
+ /**
1663
+ * Remove a Pi theme from the resolved tier, matching any of the
1664
+ * supported theme extensions for the given name stem.
1665
+ */
1182
1666
  async removeTheme(name, tier, projectDir) {
1183
1667
  const dir = resolveTierDir({ tier, kind: "themes", projectDir });
1184
1668
  let removed = false;
@@ -1191,7 +1675,187 @@ ${MARKER_END}`;
1191
1675
  }
1192
1676
  return removed;
1193
1677
  }
1678
+ // ── CANT profiles (Wave-1, T276) ────────────────────────────────────
1679
+ /**
1680
+ * Install a `.cant` profile into the resolved tier after passing it
1681
+ * through the cant-core validator.
1682
+ *
1683
+ * Validates the source via {@link PiHarness.validateCantProfile}
1684
+ * before copying so we never persist a `.cant` file the runtime bridge
1685
+ * cannot load. The target layout is `<tier-root>/cant/<name>.cant`,
1686
+ * resolved through `resolveTierDir` so the project/user/global
1687
+ * hierarchy stays consistent with the other Wave-1 verbs.
1688
+ */
1689
+ async installCantProfile(sourcePath, name, tier, projectDir, opts) {
1690
+ if (!existsSync4(sourcePath)) {
1691
+ throw new Error(`installCantProfile: source file does not exist: ${sourcePath}`);
1692
+ }
1693
+ const stats = await stat(sourcePath);
1694
+ if (!stats.isFile()) {
1695
+ throw new Error(`installCantProfile: source path is not a regular file: ${sourcePath}`);
1696
+ }
1697
+ const ext = extname(sourcePath);
1698
+ if (ext !== ".cant") {
1699
+ throw new Error(
1700
+ `installCantProfile: expected a CANT source file (.cant), got: ${ext || "(no extension)"}`
1701
+ );
1702
+ }
1703
+ const validation = await this.validateCantProfile(sourcePath);
1704
+ if (!validation.valid) {
1705
+ const firstError = validation.errors.find((e) => e.severity === "error") ?? validation.errors[0];
1706
+ const detail = firstError !== void 0 ? ` (${firstError.ruleId} at ${firstError.line}:${firstError.col}: ${firstError.message})` : "";
1707
+ throw new Error(`installCantProfile: source file failed cant-core validation${detail}`);
1708
+ }
1709
+ const dir = resolveTierDir({ tier, kind: "cant", projectDir });
1710
+ const targetPath = join5(dir, `${name}.cant`);
1711
+ if (existsSync4(targetPath) && opts?.force !== true) {
1712
+ throw new Error(
1713
+ `installCantProfile: target already exists at ${targetPath} (pass --force to overwrite)`
1714
+ );
1715
+ }
1716
+ const contents = await readFile(sourcePath);
1717
+ await mkdir3(dir, { recursive: true });
1718
+ await writeFile(targetPath, contents);
1719
+ return { targetPath, tier, counts: validation.counts };
1720
+ }
1721
+ /**
1722
+ * Remove a `.cant` profile from the resolved tier.
1723
+ */
1724
+ async removeCantProfile(name, tier, projectDir) {
1725
+ const dir = resolveTierDir({ tier, kind: "cant", projectDir });
1726
+ const targetPath = join5(dir, `${name}.cant`);
1727
+ if (!existsSync4(targetPath)) return false;
1728
+ await rm3(targetPath, { force: true });
1729
+ return true;
1730
+ }
1731
+ /**
1732
+ * List installed `.cant` profiles across every tier in precedence
1733
+ * order, parsing each file to extract section counts.
1734
+ *
1735
+ * Walks every tier in `TIER_PRECEDENCE` order, parsing each
1736
+ * discovered `.cant` file via cant-core to extract a
1737
+ * {@link CantProfileCounts} bag. Higher-precedence tiers shadow
1738
+ * lower-precedence entries with the same name; shadowed entries
1739
+ * still appear in the result but carry the
1740
+ * `shadowedByHigherTier` flag so callers can render the precedence
1741
+ * story without losing visibility of the duplicate.
1742
+ */
1743
+ async listCantProfiles(projectDir) {
1744
+ const tiers = resolveAllTiers("cant", projectDir);
1745
+ const out = [];
1746
+ const seenNames = /* @__PURE__ */ new Set();
1747
+ for (const { tier, dir } of tiers) {
1748
+ if (!existsSync4(dir)) continue;
1749
+ let entries;
1750
+ try {
1751
+ entries = await readdir(dir, { withFileTypes: true });
1752
+ } catch {
1753
+ continue;
1754
+ }
1755
+ for (const entry of entries) {
1756
+ if (!entry.isFile()) continue;
1757
+ const fileName = entry.name;
1758
+ if (!fileName.endsWith(".cant")) continue;
1759
+ const name = fileName.slice(0, -".cant".length);
1760
+ const sourcePath = join5(dir, fileName);
1761
+ const counts = await extractCantCounts(sourcePath);
1762
+ const shadowed = seenNames.has(name);
1763
+ const profile = {
1764
+ name,
1765
+ tier,
1766
+ sourcePath,
1767
+ counts
1768
+ };
1769
+ if (shadowed) {
1770
+ profile.shadowedByHigherTier = true;
1771
+ }
1772
+ out.push(profile);
1773
+ seenNames.add(name);
1774
+ }
1775
+ }
1776
+ return out;
1777
+ }
1778
+ /**
1779
+ * Validate a `.cant` source file against cant-core's parser and
1780
+ * 42-rule linter, returning section counts and per-diagnostic detail.
1781
+ *
1782
+ * Pure validator. Reads the file, runs `parseDocument` to derive
1783
+ * counts (when parsing succeeds) and `validateDocument` to collect
1784
+ * the 42-rule diagnostic feed. The two calls are kept independent so
1785
+ * we can still report counts for files that pass parsing but fail a
1786
+ * lint rule.
1787
+ */
1788
+ async validateCantProfile(sourcePath) {
1789
+ if (!existsSync4(sourcePath)) {
1790
+ throw new Error(`validateCantProfile: source file does not exist: ${sourcePath}`);
1791
+ }
1792
+ const stats = await stat(sourcePath);
1793
+ if (!stats.isFile()) {
1794
+ throw new Error(`validateCantProfile: source path is not a regular file: ${sourcePath}`);
1795
+ }
1796
+ const counts = await extractCantCounts(sourcePath);
1797
+ const validation = await validateDocument(sourcePath);
1798
+ const errors = validation.diagnostics.map((d) => ({
1799
+ ruleId: d.ruleId,
1800
+ message: d.message,
1801
+ line: d.line,
1802
+ col: d.col,
1803
+ severity: normaliseSeverity(d.severity)
1804
+ }));
1805
+ return {
1806
+ valid: validation.valid,
1807
+ errors,
1808
+ counts
1809
+ };
1810
+ }
1194
1811
  };
1812
+ async function terminateSubagent(child, graceMs) {
1813
+ if (child.exitCode !== null || child.signalCode !== null) {
1814
+ return;
1815
+ }
1816
+ try {
1817
+ child.kill("SIGTERM");
1818
+ } catch {
1819
+ return;
1820
+ }
1821
+ const pollInterval = Math.min(25, Math.max(1, graceMs));
1822
+ const deadline = Date.now() + graceMs;
1823
+ await new Promise((resolve) => {
1824
+ const timer = setInterval(() => {
1825
+ if (child.exitCode !== null || child.signalCode !== null) {
1826
+ clearInterval(timer);
1827
+ resolve();
1828
+ return;
1829
+ }
1830
+ if (Date.now() >= deadline) {
1831
+ clearInterval(timer);
1832
+ try {
1833
+ child.kill("SIGKILL");
1834
+ } catch {
1835
+ }
1836
+ resolve();
1837
+ }
1838
+ }, pollInterval);
1839
+ child.once("close", () => {
1840
+ clearInterval(timer);
1841
+ resolve();
1842
+ });
1843
+ });
1844
+ }
1845
+ async function writeSubagentLink(parentSessionPath, entry) {
1846
+ await mkdir3(dirname2(parentSessionPath), { recursive: true });
1847
+ const wrapped = {
1848
+ type: "custom",
1849
+ subtype: entry.type,
1850
+ subagentId: entry.subagentId,
1851
+ taskId: entry.taskId,
1852
+ childSessionPath: entry.childSessionPath,
1853
+ startedAt: entry.startedAt
1854
+ };
1855
+ const line = `${JSON.stringify(wrapped)}
1856
+ `;
1857
+ await appendFile(parentSessionPath, line, "utf8");
1858
+ }
1195
1859
  async function readSessionHeader(filePath) {
1196
1860
  let handle = null;
1197
1861
  try {
@@ -1243,6 +1907,104 @@ async function readSessionHeader(filePath) {
1243
1907
  }
1244
1908
  }
1245
1909
  }
1910
+ var EMPTY_CANT_COUNTS = {
1911
+ agentCount: 0,
1912
+ workflowCount: 0,
1913
+ pipelineCount: 0,
1914
+ hookCount: 0,
1915
+ skillCount: 0
1916
+ };
1917
+ function isRecord(v) {
1918
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1919
+ }
1920
+ function unwrapSpanned(value) {
1921
+ if (typeof value === "string") return value;
1922
+ if (isRecord(value) && typeof value["value"] === "string") {
1923
+ return value["value"];
1924
+ }
1925
+ return null;
1926
+ }
1927
+ function collectSkillNames(value, out) {
1928
+ if (!isRecord(value)) return;
1929
+ const arr = value["Array"];
1930
+ if (!Array.isArray(arr)) return;
1931
+ for (const item of arr) {
1932
+ if (!isRecord(item)) continue;
1933
+ const stringWrapper = item["String"];
1934
+ if (isRecord(stringWrapper) && typeof stringWrapper["raw"] === "string") {
1935
+ out.add(stringWrapper["raw"]);
1936
+ continue;
1937
+ }
1938
+ const identWrapper = item["Identifier"];
1939
+ if (typeof identWrapper === "string") {
1940
+ out.add(identWrapper);
1941
+ }
1942
+ }
1943
+ }
1944
+ async function extractCantCounts(sourcePath) {
1945
+ let parsed;
1946
+ try {
1947
+ parsed = await parseDocument(sourcePath);
1948
+ } catch {
1949
+ return { ...EMPTY_CANT_COUNTS };
1950
+ }
1951
+ if (!parsed.success || !isRecord(parsed.document)) {
1952
+ return { ...EMPTY_CANT_COUNTS };
1953
+ }
1954
+ const sections = parsed.document["sections"];
1955
+ if (!Array.isArray(sections)) {
1956
+ return { ...EMPTY_CANT_COUNTS };
1957
+ }
1958
+ let agentCount = 0;
1959
+ let workflowCount = 0;
1960
+ let pipelineCount = 0;
1961
+ let hookCount = 0;
1962
+ const skillNames = /* @__PURE__ */ new Set();
1963
+ for (const section of sections) {
1964
+ if (!isRecord(section)) continue;
1965
+ if (isRecord(section["Agent"])) {
1966
+ agentCount += 1;
1967
+ const agent = section["Agent"];
1968
+ const hooks = agent["hooks"];
1969
+ if (Array.isArray(hooks)) {
1970
+ hookCount += hooks.length;
1971
+ }
1972
+ const properties = agent["properties"];
1973
+ if (Array.isArray(properties)) {
1974
+ for (const prop of properties) {
1975
+ if (!isRecord(prop)) continue;
1976
+ const key = unwrapSpanned(prop["key"]);
1977
+ if (key === "skills") {
1978
+ collectSkillNames(prop["value"], skillNames);
1979
+ }
1980
+ }
1981
+ }
1982
+ continue;
1983
+ }
1984
+ if (isRecord(section["Workflow"])) {
1985
+ workflowCount += 1;
1986
+ continue;
1987
+ }
1988
+ if (isRecord(section["Pipeline"])) {
1989
+ pipelineCount += 1;
1990
+ continue;
1991
+ }
1992
+ if (isRecord(section["Hook"])) {
1993
+ hookCount += 1;
1994
+ }
1995
+ }
1996
+ return {
1997
+ agentCount,
1998
+ workflowCount,
1999
+ pipelineCount,
2000
+ hookCount,
2001
+ skillCount: skillNames.size
2002
+ };
2003
+ }
2004
+ function normaliseSeverity(raw) {
2005
+ if (raw === "warning" || raw === "info" || raw === "hint") return raw;
2006
+ return "error";
2007
+ }
1246
2008
 
1247
2009
  // src/core/harness/index.ts
1248
2010
  function getHarnessFor(provider) {
@@ -1262,28 +2024,67 @@ function getAllHarnesses() {
1262
2024
  }
1263
2025
  return result;
1264
2026
  }
1265
- function resolveDefaultTargetProviders() {
2027
+ function resolveDefaultTargetProviders(options = {}) {
2028
+ const mode = getExclusivityMode();
1266
2029
  let primary = null;
1267
2030
  try {
1268
2031
  primary = getPrimaryHarness();
1269
2032
  } catch {
1270
2033
  primary = null;
1271
2034
  }
1272
- const installed = getInstalledProviders();
1273
- if (primary !== null) {
1274
- const primaryId = primary.provider.id;
1275
- const primaryInstalled = installed.some((p) => p.id === primaryId);
1276
- if (primaryInstalled) {
2035
+ let installed;
2036
+ try {
2037
+ installed = getInstalledProviders();
2038
+ } catch {
2039
+ installed = [];
2040
+ }
2041
+ const primaryId = primary?.provider.id ?? null;
2042
+ const primaryInstalled = primaryId !== null && installed.some((provider) => provider.id === primaryId);
2043
+ const explicit = options.explicit;
2044
+ const explicitContainsPrimary = explicit !== void 0 && primaryId !== null ? explicit.some((provider) => provider.id === primaryId) : false;
2045
+ const legacyFallback = () => {
2046
+ if (primary !== null && primaryInstalled) {
1277
2047
  return [primary.provider];
1278
2048
  }
2049
+ const highTier = installed.filter(
2050
+ (provider) => provider.priority === "primary" || provider.priority === "high"
2051
+ );
2052
+ if (highTier.length > 0) {
2053
+ return highTier;
2054
+ }
2055
+ return installed;
2056
+ };
2057
+ if (mode === "force-pi") {
2058
+ if (primary === null || !primaryInstalled) {
2059
+ throw new PiRequiredError();
2060
+ }
2061
+ return [primary.provider];
1279
2062
  }
1280
- const highTier = installed.filter(
1281
- (provider) => provider.priority === "primary" || provider.priority === "high"
1282
- );
1283
- if (highTier.length > 0) {
1284
- return highTier;
2063
+ if (mode === "legacy") {
2064
+ if (explicit !== void 0) {
2065
+ return explicit;
2066
+ }
2067
+ return legacyFallback();
2068
+ }
2069
+ if (explicit !== void 0 && explicit.length > 0 && !explicitContainsPrimary && primaryInstalled && !hasExplicitNonPiAutoWarned()) {
2070
+ console.warn(
2071
+ "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'."
2072
+ );
2073
+ markExplicitNonPiAutoWarned();
2074
+ }
2075
+ if (explicit !== void 0) {
2076
+ return explicit;
2077
+ }
2078
+ if (primary !== null && primaryInstalled) {
2079
+ return [primary.provider];
2080
+ }
2081
+ if (!hasPiAbsentAutoWarned()) {
2082
+ console.warn(
2083
+ "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."
2084
+ );
2085
+ markPiAbsentAutoWarned();
1285
2086
  }
1286
- return installed;
2087
+ return legacyFallback();
1287
2088
  }
1288
2089
  async function dispatchInstallSkillAcrossProviders(sourcePath, skillName, providers, isGlobal, projectDir) {
1289
2090
  const harnessTargets = [];
@@ -3817,6 +4618,13 @@ export {
3817
4618
  selectProvidersByMinimumPriority,
3818
4619
  installBatchWithRollback,
3819
4620
  updateInstructionsSingleOperation,
4621
+ DEFAULT_EXCLUSIVITY_MODE,
4622
+ EXCLUSIVITY_MODE_ENV_VAR,
4623
+ PiRequiredError,
4624
+ isExclusivityMode,
4625
+ getExclusivityMode,
4626
+ setExclusivityMode,
4627
+ resetExclusivityModeOverride,
3820
4628
  PiHarness,
3821
4629
  getHarnessFor,
3822
4630
  getPrimaryHarness,
@@ -3878,4 +4686,4 @@ export {
3878
4686
  discoverSkillsMulti,
3879
4687
  validateSkill
3880
4688
  };
3881
- //# sourceMappingURL=chunk-HEAGCHKU.js.map
4689
+ //# sourceMappingURL=chunk-XVZT7K6F.js.map