@cleocode/caamp 2026.4.7 → 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.
@@ -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.
@@ -716,21 +814,79 @@ ${MARKER_END}`;
716
814
  await writeFile(filePath, stripped.length === 0 ? "" : `${stripped}
717
815
  `, "utf8");
718
816
  }
719
- // ── Subagent spawn ──────────────────────────────────────────────────
817
+ // ── Subagent spawn (ADR-035 §D6) ────────────────────────────────────
720
818
  /**
721
- * {@inheritDoc Harness.spawnSubagent}
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.
722
822
  *
723
823
  * @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.
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.
831
+ *
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.
729
870
  *
730
- * Throws immediately when the provider entry is missing a `spawnCommand`
731
- * 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.
732
888
  */
733
- async spawnSubagent(task) {
889
+ async spawnSubagent(task, opts = {}) {
734
890
  const cmd = this.provider.capabilities.spawn.spawnCommand;
735
891
  if (cmd === null || cmd.length === 0) {
736
892
  throw new Error(
@@ -741,44 +897,312 @@ ${MARKER_END}`;
741
897
  if (typeof program !== "string" || program.length === 0) {
742
898
  throw new Error("PiHarness.spawnSubagent: invalid spawnCommand (missing program)");
743
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");
744
936
  const baseArgs = cmd.slice(1);
745
937
  const args = [...baseArgs, task.prompt];
746
938
  const child = spawn(program, args, {
747
- cwd: task.cwd,
748
- env: { ...process.env, ...task.env },
939
+ cwd: opts.cwd ?? task.cwd,
940
+ env: { ...process.env, ...task.env, ...opts.env },
749
941
  stdio: ["ignore", "pipe", "pipe"]
750
942
  });
751
- let stdout = "";
752
- 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
+ };
753
1014
  child.stdout?.on("data", (chunk) => {
754
- stdout += chunk.toString("utf8");
1015
+ const text = chunk.toString("utf8");
1016
+ stdoutAccum += text;
1017
+ stdoutBuffer += text;
1018
+ flushStdoutBuffer(false);
755
1019
  });
756
1020
  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
- });
1021
+ const text = chunk.toString("utf8");
1022
+ stderrAccum += text;
1023
+ stderrBuffer += text;
1024
+ flushStderrBuffer(false);
768
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();
769
1052
  if (task.signal !== void 0) {
770
- task.signal.addEventListener("abort", () => {
771
- 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", () => {
772
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
+ }
773
1111
  }
774
1112
  return {
1113
+ subagentId,
1114
+ taskId,
1115
+ childSessionPath,
775
1116
  pid: child.pid ?? null,
1117
+ startedAt,
1118
+ exitPromise,
776
1119
  result,
1120
+ terminate: terminateImpl,
777
1121
  abort: () => {
778
- child.kill();
779
- }
1122
+ void terminateImpl();
1123
+ },
1124
+ recentStderr: () => stderrRing.slice()
780
1125
  };
781
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
+ }
782
1206
  // ── Settings ────────────────────────────────────────────────────────
783
1207
  /** {@inheritDoc Harness.readSettings} */
784
1208
  async readSettings(scope) {
@@ -1191,7 +1615,185 @@ ${MARKER_END}`;
1191
1615
  }
1192
1616
  return removed;
1193
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
+ }
1194
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
+ }
1195
1797
  async function readSessionHeader(filePath) {
1196
1798
  let handle = null;
1197
1799
  try {
@@ -1243,6 +1845,104 @@ async function readSessionHeader(filePath) {
1243
1845
  }
1244
1846
  }
1245
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
+ }
1246
1946
 
1247
1947
  // src/core/harness/index.ts
1248
1948
  function getHarnessFor(provider) {
@@ -1262,28 +1962,67 @@ function getAllHarnesses() {
1262
1962
  }
1263
1963
  return result;
1264
1964
  }
1265
- function resolveDefaultTargetProviders() {
1965
+ function resolveDefaultTargetProviders(options = {}) {
1966
+ const mode = getExclusivityMode();
1266
1967
  let primary = null;
1267
1968
  try {
1268
1969
  primary = getPrimaryHarness();
1269
1970
  } catch {
1270
1971
  primary = null;
1271
1972
  }
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) {
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) {
1277
1985
  return [primary.provider];
1278
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];
1279
2000
  }
1280
- const highTier = installed.filter(
1281
- (provider) => provider.priority === "primary" || provider.priority === "high"
1282
- );
1283
- if (highTier.length > 0) {
1284
- 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();
2012
+ }
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();
1285
2024
  }
1286
- return installed;
2025
+ return legacyFallback();
1287
2026
  }
1288
2027
  async function dispatchInstallSkillAcrossProviders(sourcePath, skillName, providers, isGlobal, projectDir) {
1289
2028
  const harnessTargets = [];
@@ -3817,6 +4556,13 @@ export {
3817
4556
  selectProvidersByMinimumPriority,
3818
4557
  installBatchWithRollback,
3819
4558
  updateInstructionsSingleOperation,
4559
+ DEFAULT_EXCLUSIVITY_MODE,
4560
+ EXCLUSIVITY_MODE_ENV_VAR,
4561
+ PiRequiredError,
4562
+ isExclusivityMode,
4563
+ getExclusivityMode,
4564
+ setExclusivityMode,
4565
+ resetExclusivityModeOverride,
3820
4566
  PiHarness,
3821
4567
  getHarnessFor,
3822
4568
  getPrimaryHarness,
@@ -3878,4 +4624,4 @@ export {
3878
4624
  discoverSkillsMulti,
3879
4625
  validateSkill
3880
4626
  };
3881
- //# sourceMappingURL=chunk-HEAGCHKU.js.map
4627
+ //# sourceMappingURL=chunk-JC77OAHA.js.map