pi-agent-replicate 0.0.0-stage → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +27 -0
  2. package/CHANGELOG.md +152 -0
  3. package/CONTEXT.md +63 -0
  4. package/LICENSE +21 -0
  5. package/README.md +68 -2
  6. package/RELEASING.md +132 -0
  7. package/assets/banner.png +0 -0
  8. package/config.json.example +6 -0
  9. package/docs/README.md +23 -0
  10. package/docs/adr/0003-installable-role-packs.md +29 -0
  11. package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
  12. package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
  13. package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
  14. package/docs/configuration.md +14 -0
  15. package/docs/development.md +27 -0
  16. package/docs/public-subagent-tree-api.md +58 -0
  17. package/docs/retained-roles.md +71 -0
  18. package/docs/settled-lifecycle-contract.md +71 -0
  19. package/docs/worktree-subagents.md +58 -0
  20. package/examples/role-pack/extension.ts +18 -0
  21. package/examples/role-pack/package.json +16 -0
  22. package/examples/role-pack/roles/example-reviewer.md +12 -0
  23. package/index.d.ts +116 -0
  24. package/index.js +1064 -0
  25. package/index.ts +155 -0
  26. package/package.json +70 -4
  27. package/pi-extension/subagents/activity.js +575 -0
  28. package/pi-extension/subagents/activity.ts +980 -0
  29. package/pi-extension/subagents/completion.js +184 -0
  30. package/pi-extension/subagents/completion.ts +229 -0
  31. package/pi-extension/subagents/delivery-drain.js +15 -0
  32. package/pi-extension/subagents/delivery-drain.ts +20 -0
  33. package/pi-extension/subagents/delivery-files.js +86 -0
  34. package/pi-extension/subagents/delivery-files.ts +105 -0
  35. package/pi-extension/subagents/delivery-log.js +51 -0
  36. package/pi-extension/subagents/delivery-log.ts +51 -0
  37. package/pi-extension/subagents/delivery-payload.ts +55 -0
  38. package/pi-extension/subagents/delivery.ts +219 -0
  39. package/pi-extension/subagents/execution-owner.js +30 -0
  40. package/pi-extension/subagents/execution-owner.ts +38 -0
  41. package/pi-extension/subagents/herdr.js +645 -0
  42. package/pi-extension/subagents/herdr.ts +927 -0
  43. package/pi-extension/subagents/index.ts +3400 -0
  44. package/pi-extension/subagents/launch.js +774 -0
  45. package/pi-extension/subagents/launch.ts +1124 -0
  46. package/pi-extension/subagents/lifecycle.js +477 -0
  47. package/pi-extension/subagents/lifecycle.ts +582 -0
  48. package/pi-extension/subagents/lineage.js +806 -0
  49. package/pi-extension/subagents/lineage.ts +841 -0
  50. package/pi-extension/subagents/replica-background.js +32 -0
  51. package/pi-extension/subagents/replica-background.ts +38 -0
  52. package/pi-extension/subagents/replica-branch.ts +45 -0
  53. package/pi-extension/subagents/replica-directory-settings.ts +51 -0
  54. package/pi-extension/subagents/replica-history.js +22 -0
  55. package/pi-extension/subagents/replica-history.ts +21 -0
  56. package/pi-extension/subagents/replica-work.js +326 -0
  57. package/pi-extension/subagents/replica-work.ts +336 -0
  58. package/pi-extension/subagents/replica-worker.js +74 -0
  59. package/pi-extension/subagents/replica-worker.ts +71 -0
  60. package/pi-extension/subagents/replication.ts +99 -0
  61. package/pi-extension/subagents/result-delivery-queue.ts +33 -0
  62. package/pi-extension/subagents/role-catalog.js +123 -0
  63. package/pi-extension/subagents/role-catalog.ts +24 -0
  64. package/pi-extension/subagents/runtime-routing.js +154 -0
  65. package/pi-extension/subagents/runtime-routing.ts +239 -0
  66. package/pi-extension/subagents/session-binding.js +346 -0
  67. package/pi-extension/subagents/session-binding.ts +339 -0
  68. package/pi-extension/subagents/session-work.js +219 -0
  69. package/pi-extension/subagents/session-work.ts +167 -0
  70. package/pi-extension/subagents/session.js +318 -0
  71. package/pi-extension/subagents/session.ts +448 -0
  72. package/pi-extension/subagents/settled-contract.js +47 -0
  73. package/pi-extension/subagents/settled-contract.ts +175 -0
  74. package/pi-extension/subagents/source-state.js +180 -0
  75. package/pi-extension/subagents/source-state.ts +184 -0
  76. package/pi-extension/subagents/status.ts +493 -0
  77. package/pi-extension/subagents/subagent-done.js +805 -0
  78. package/pi-extension/subagents/subagent-done.ts +878 -0
  79. package/pi-extension/subagents/terminal.js +111 -0
  80. package/pi-extension/subagents/terminal.ts +208 -0
  81. package/pi-extension/subagents/watchdog.js +122 -0
  82. package/pi-extension/subagents/watchdog.ts +171 -0
@@ -0,0 +1,123 @@
1
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ const EVENT = "pi-herdr-subagents:roles:discover:v1";
5
+ const SPAWNING = new Set(["subagent", "subagent_interrupt", "subagents_list", "subagent_resume", "subagent_cancel"]);
6
+ const LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
7
+ const config = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
8
+ const fm = (s, k) => s.split("\n").find(x => x.startsWith(`${k}:`))?.slice(k.length + 1).trim() || undefined;
9
+ function parse(s, fallback) { const m = s.match(/^---\n([\s\S]*?)\n---/); if (!m)
10
+ return null; const f = m[1], mode = fm(f, "session-mode"), prompt = fm(f, "system-prompt"), thinking = fm(f, "thinking"); const b = (k) => { const x = fm(f, k); return x == null ? undefined : x === "true"; }; return { name: fm(f, "name") ?? fallback, description: fm(f, "description"), model: fm(f, "model"), tools: fm(f, "tools"), skills: fm(f, "skills") ?? fm(f, "skill"), denyTools: fm(f, "deny-tools"), spawning: b("spawning"), allowSelfSpawn: b("allow-self-spawn"), autoExit: b("auto-exit"), interactive: b("interactive"), sessionMode: mode === "standalone" || mode === "lineage-only" || mode === "fork" ? mode : undefined, cwd: fm(f, "cwd"), body: s.replace(/^---\n[\s\S]*?\n---\n*/, "").trim() || undefined, systemPromptMode: prompt === "append" || prompt === "replace" ? prompt : undefined, thinking: LEVELS.has(thinking ?? "") ? thinking : undefined, disableModelInvocation: fm(f, "disable-model-invocation")?.toLowerCase() === "true" }; }
11
+ function markdown(path) { const s = statSync(path); if (s.isFile())
12
+ return path.endsWith(".md") ? [path] : []; return s.isDirectory() ? readdirSync(path).filter(x => x.endsWith(".md")).sort().map(x => join(path, x)) : []; }
13
+ function packageMeta(path) { let d = statSync(path).isDirectory() ? path : dirname(path); for (;;) {
14
+ const p = join(d, "package.json");
15
+ if (existsSync(p))
16
+ try {
17
+ const x = JSON.parse(readFileSync(p, "utf8"));
18
+ return { provider: typeof x.name === "string" ? x.name : undefined, providerVersion: typeof x.version === "string" ? x.version : undefined };
19
+ }
20
+ catch {
21
+ return {};
22
+ }
23
+ const parent = dirname(d);
24
+ if (parent === d)
25
+ return {};
26
+ d = parent;
27
+ } }
28
+ function legacy(content, name, path) { const m = content.match(/^---\n([\s\S]*?)\n---/), cli = m ? fm(m[1], "cli") : undefined; if (!cli)
29
+ return null; const n = fm(m[1], "name") ?? name; return { code: "external-cli-unsupported", message: `Role "${n}" requests external CLI "${cli}" in ${path}. pi-agent-replicate is Pi-only; remove the cli and cli-model fields and select Claude through an authenticated Pi provider/model ID.`, path, agentName: n }; }
30
+ export function resolveDenyTools(agent) { const out = new Set(); if (!agent)
31
+ return out; if (agent.spawning === false)
32
+ for (const x of SPAWNING)
33
+ out.add(x); for (const x of (agent.denyTools ?? "").split(",").map(x => x.trim()).filter(Boolean))
34
+ out.add(x); return out; }
35
+ export function effectiveRoleTools(requested, agent) { const allow = agent?.tools ? new Set(agent.tools.split(",").map(x => x.trim()).filter(Boolean)) : undefined; const deny = resolveDenyTools(agent); const out = [...new Set(requested)].filter(x => (!allow || allow.has(x)) && !deny.has(x)); if (!out.length)
36
+ throw new Error("effective child tool list is empty"); return out; }
37
+ export function discoverAgentCatalog(pi) { const agents = new Map(), diagnostics = []; const add = (path, source) => { if (!existsSync(path))
38
+ return; for (const p of markdown(path)) {
39
+ const n = basename(p, ".md");
40
+ let c;
41
+ try {
42
+ c = readFileSync(p, "utf8");
43
+ }
44
+ catch (e) {
45
+ diagnostics.push({ code: "unreadable-role-definition", message: `Cannot read role definition ${p}: ${e instanceof Error ? e.message : String(e)}`, path: p, agentName: n });
46
+ continue;
47
+ }
48
+ const old = legacy(c, n, p);
49
+ if (old) {
50
+ diagnostics.push(old);
51
+ agents.delete(old.agentName ?? n);
52
+ continue;
53
+ }
54
+ const a = parse(c, n);
55
+ if (a)
56
+ agents.set(a.name, { ...a, source, path: p });
57
+ } }; const paths = []; try {
58
+ pi?.events?.emit?.(EVENT, { apiVersion: 1, register(path) { if (typeof path !== "string" || !isAbsolute(path))
59
+ diagnostics.push({ code: "invalid-role-pack-path", message: "Role packs must register an absolute file or directory path." });
60
+ else
61
+ paths.push(resolve(path)); } });
62
+ }
63
+ catch (e) {
64
+ diagnostics.push({ code: "role-pack-discovery-failed", message: `Role-pack discovery failed: ${e instanceof Error ? e.message : String(e)}` });
65
+ } const packs = new Map(); for (const p of new Set(paths)) {
66
+ if (!existsSync(p)) {
67
+ diagnostics.push({ code: "missing-role-pack-path", message: `Registered role-pack path does not exist: ${p}`, path: p });
68
+ continue;
69
+ }
70
+ let files, meta;
71
+ try {
72
+ if (statSync(p).isFile() && !p.endsWith(".md")) {
73
+ diagnostics.push({ code: "invalid-role-pack-file", message: `Registered role-pack file must use the .md extension: ${p}`, path: p });
74
+ continue;
75
+ }
76
+ }
77
+ catch { }
78
+ try {
79
+ files = markdown(p);
80
+ meta = packageMeta(p);
81
+ }
82
+ catch (e) {
83
+ diagnostics.push({ code: "unreadable-role-pack-path", message: `Cannot read registered role-pack path ${p}: ${e instanceof Error ? e.message : String(e)}`, path: p });
84
+ continue;
85
+ }
86
+ for (const f of files) {
87
+ const n = basename(f, ".md");
88
+ let c;
89
+ try {
90
+ c = readFileSync(f, "utf8");
91
+ }
92
+ catch (e) {
93
+ diagnostics.push({ code: "unreadable-role-definition", message: `Cannot read role definition ${f}: ${e instanceof Error ? e.message : String(e)}`, path: f, agentName: n, provider: meta.provider });
94
+ continue;
95
+ }
96
+ const old = legacy(c, n, f);
97
+ if (old) {
98
+ diagnostics.push({ ...old, provider: meta.provider });
99
+ continue;
100
+ }
101
+ const a = parse(c, n);
102
+ if (!a) {
103
+ diagnostics.push({ code: "invalid-role-definition", message: `Role definition must start with frontmatter: ${f}`, path: f, agentName: n, provider: meta.provider });
104
+ continue;
105
+ }
106
+ if (a.name !== n) {
107
+ diagnostics.push({ code: "role-name-mismatch", message: `Role name "${a.name}" must match filename "${n}" in ${f}`, path: f, agentName: n, provider: meta.provider });
108
+ continue;
109
+ }
110
+ if (!a.description) {
111
+ diagnostics.push({ code: "missing-role-description", message: `Role "${a.name}" must declare a description in ${f}`, path: f, agentName: a.name, provider: meta.provider });
112
+ continue;
113
+ }
114
+ packs.set(a.name, [...(packs.get(a.name) ?? []), { ...a, source: "package", path: f, ...meta }]);
115
+ }
116
+ } for (const [n, defs] of packs) {
117
+ if (defs.length > 1) {
118
+ diagnostics.push({ code: "duplicate-package-role", message: `Role "${n}" is contributed by multiple role packs: ${defs.map(x => x.provider ?? x.path).sort().join(", ")}`, agentName: n });
119
+ continue;
120
+ }
121
+ agents.set(n, defs[0]);
122
+ } add(join(config(), "agents"), "global"); add(join(process.cwd(), ".pi", "agents"), "project"); return { agents: [...agents.values()], diagnostics }; }
123
+ export function resolveAgent(name, pi) { return discoverAgentCatalog(pi).agents.find(x => x.name === name); }
@@ -0,0 +1,24 @@
1
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+
5
+ export type AgentSource = "package" | "global" | "project";
6
+ export type SessionMode = "standalone" | "lineage-only" | "fork";
7
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
8
+ export interface AgentDefinition { name:string; description?:string; model?:string; tools?:string; skills?:string; denyTools?:string; spawning?:boolean; allowSelfSpawn?:boolean; autoExit?:boolean; interactive?:boolean; thinking?:ThinkingLevel; sessionMode?:SessionMode; cwd?:string; body?:string; systemPromptMode?:"append"|"replace"; disableModelInvocation:boolean; }
9
+ export interface ListedAgentDefinition extends AgentDefinition { source:AgentSource; path:string; provider?:string; providerVersion?:string; }
10
+ export interface AgentDiagnostic { code:string; message:string; path?:string; agentName?:string; provider?:string; }
11
+ export interface AgentCatalog { agents:ListedAgentDefinition[]; diagnostics:AgentDiagnostic[]; }
12
+ const EVENT="pi-herdr-subagents:roles:discover:v1";
13
+ const SPAWNING=new Set(["subagent","subagent_interrupt","subagents_list","subagent_resume","subagent_cancel"]);
14
+ const LEVELS=new Set(["off","minimal","low","medium","high","xhigh","max"]);
15
+ const config=()=>process.env.PI_CODING_AGENT_DIR??join(homedir(),".pi","agent");
16
+ const fm=(s:string,k:string)=>s.split("\n").find(x=>x.startsWith(`${k}:`))?.slice(k.length+1).trim()||undefined;
17
+ function parse(s:string,fallback:string):AgentDefinition|null { const m=s.match(/^---\n([\s\S]*?)\n---/); if(!m)return null; const f=m[1], mode=fm(f,"session-mode"), prompt=fm(f,"system-prompt"), thinking=fm(f,"thinking"); const b=(k:string)=>{const x=fm(f,k);return x==null?undefined:x==="true";}; return {name:fm(f,"name")??fallback,description:fm(f,"description"),model:fm(f,"model"),tools:fm(f,"tools"),skills:fm(f,"skills")??fm(f,"skill"),denyTools:fm(f,"deny-tools"),spawning:b("spawning"),allowSelfSpawn:b("allow-self-spawn"),autoExit:b("auto-exit"),interactive:b("interactive"),sessionMode:mode==="standalone"||mode==="lineage-only"||mode==="fork"?mode:undefined,cwd:fm(f,"cwd"),body:s.replace(/^---\n[\s\S]*?\n---\n*/,"").trim()||undefined,systemPromptMode:prompt==="append"||prompt==="replace"?prompt:undefined,thinking:LEVELS.has(thinking??"")?thinking as ThinkingLevel:undefined,disableModelInvocation:fm(f,"disable-model-invocation")?.toLowerCase()==="true"}; }
18
+ function markdown(path:string):string[] { const s=statSync(path); if(s.isFile())return path.endsWith(".md")?[path]:[]; return s.isDirectory()?readdirSync(path).filter(x=>x.endsWith(".md")).sort().map(x=>join(path,x)):[]; }
19
+ function packageMeta(path:string):{provider?:string;providerVersion?:string}{ let d=statSync(path).isDirectory()?path:dirname(path); for(;;){const p=join(d,"package.json");if(existsSync(p))try{const x=JSON.parse(readFileSync(p,"utf8"));return {provider:typeof x.name==="string"?x.name:undefined,providerVersion:typeof x.version==="string"?x.version:undefined};}catch{return {};}const parent=dirname(d);if(parent===d)return {};d=parent;} }
20
+ function legacy(content:string,name:string,path:string):AgentDiagnostic|null { const m=content.match(/^---\n([\s\S]*?)\n---/),cli=m?fm(m[1],"cli"):undefined;if(!cli)return null;const n=fm(m![1],"name")??name;return {code:"external-cli-unsupported",message:`Role "${n}" requests external CLI "${cli}" in ${path}. pi-agent-replicate is Pi-only; remove the cli and cli-model fields and select Claude through an authenticated Pi provider/model ID.`,path,agentName:n}; }
21
+ export function resolveDenyTools(agent:Pick<AgentDefinition,"spawning"|"denyTools">|null|undefined):Set<string>{const out=new Set<string>();if(!agent)return out;if(agent.spawning===false)for(const x of SPAWNING)out.add(x);for(const x of (agent.denyTools??"").split(",").map(x=>x.trim()).filter(Boolean))out.add(x);return out;}
22
+ export function effectiveRoleTools(requested:readonly string[],agent:Pick<AgentDefinition,"spawning"|"tools"|"denyTools">|null|undefined):string[]{const allow=agent?.tools?new Set(agent.tools.split(",").map(x=>x.trim()).filter(Boolean)):undefined;const deny=resolveDenyTools(agent);const out=[...new Set(requested)].filter(x=>(!allow||allow.has(x))&&!deny.has(x));if(!out.length)throw new Error("effective child tool list is empty");return out;}
23
+ export function discoverAgentCatalog(pi?:{events?:{emit?:(event:string,payload:unknown)=>void}}):AgentCatalog {const agents=new Map<string,ListedAgentDefinition>(),diagnostics:AgentDiagnostic[]=[];const add=(path:string,source:AgentSource)=>{if(!existsSync(path))return;for(const p of markdown(path)){const n=basename(p,".md");let c:string;try{c=readFileSync(p,"utf8");}catch(e){diagnostics.push({code:"unreadable-role-definition",message:`Cannot read role definition ${p}: ${e instanceof Error?e.message:String(e)}`,path:p,agentName:n});continue;}const old=legacy(c,n,p);if(old){diagnostics.push(old);agents.delete(old.agentName??n);continue;}const a=parse(c,n);if(a)agents.set(a.name,{...a,source,path:p});}};const paths:string[]=[];try{pi?.events?.emit?.(EVENT,{apiVersion:1,register(path:unknown){if(typeof path!=="string"||!isAbsolute(path))diagnostics.push({code:"invalid-role-pack-path",message:"Role packs must register an absolute file or directory path."});else paths.push(resolve(path));}});}catch(e){diagnostics.push({code:"role-pack-discovery-failed",message:`Role-pack discovery failed: ${e instanceof Error?e.message:String(e)}`});}const packs=new Map<string,ListedAgentDefinition[]>();for(const p of new Set(paths)){if(!existsSync(p)){diagnostics.push({code:"missing-role-pack-path",message:`Registered role-pack path does not exist: ${p}`,path:p});continue;}let files:string[],meta:ReturnType<typeof packageMeta>;try{if(statSync(p).isFile()&&!p.endsWith(".md")){diagnostics.push({code:"invalid-role-pack-file",message:`Registered role-pack file must use the .md extension: ${p}`,path:p});continue;}}catch{}try{files=markdown(p);meta=packageMeta(p);}catch(e){diagnostics.push({code:"unreadable-role-pack-path",message:`Cannot read registered role-pack path ${p}: ${e instanceof Error?e.message:String(e)}`,path:p});continue;}for(const f of files){const n=basename(f,".md");let c:string;try{c=readFileSync(f,"utf8");}catch(e){diagnostics.push({code:"unreadable-role-definition",message:`Cannot read role definition ${f}: ${e instanceof Error?e.message:String(e)}`,path:f,agentName:n,provider:meta.provider});continue;}const old=legacy(c,n,f);if(old){diagnostics.push({...old,provider:meta.provider});continue;}const a=parse(c,n);if(!a){diagnostics.push({code:"invalid-role-definition",message:`Role definition must start with frontmatter: ${f}`,path:f,agentName:n,provider:meta.provider});continue;}if(a.name!==n){diagnostics.push({code:"role-name-mismatch",message:`Role name "${a.name}" must match filename "${n}" in ${f}`,path:f,agentName:n,provider:meta.provider});continue;}if(!a.description){diagnostics.push({code:"missing-role-description",message:`Role "${a.name}" must declare a description in ${f}`,path:f,agentName:a.name,provider:meta.provider});continue;}packs.set(a.name,[...(packs.get(a.name)??[]),{...a,source:"package",path:f,...meta}]);}}for(const [n,defs] of packs){if(defs.length>1){diagnostics.push({code:"duplicate-package-role",message:`Role "${n}" is contributed by multiple role packs: ${defs.map(x=>x.provider??x.path).sort().join(", ")}`,agentName:n});continue;}agents.set(n,defs[0]);}add(join(config(),"agents"),"global");add(join(process.cwd(),".pi","agents"),"project");return {agents:[...agents.values()],diagnostics};}
24
+ export function resolveAgent(name:string,pi?:{events?:{emit?:(event:string,payload:unknown)=>void}}){return discoverAgentCatalog(pi).agents.find(x=>x.name===name);}
@@ -0,0 +1,154 @@
1
+ import { clampThinkingLevel, getSupportedThinkingLevels, } from "@earendil-works/pi-ai";
2
+ export const THINKING_LEVELS = [
3
+ "off",
4
+ "minimal",
5
+ "low",
6
+ "medium",
7
+ "high",
8
+ "xhigh",
9
+ "max",
10
+ ];
11
+ export function isThinkingLevel(value) {
12
+ return THINKING_LEVELS.includes(value);
13
+ }
14
+ export class RuntimeResolutionError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "RuntimeResolutionError";
18
+ }
19
+ }
20
+ export function parseExactModelRef(reference) {
21
+ const trimmed = reference.trim();
22
+ const separator = trimmed.indexOf("/");
23
+ if (separator <= 0 || separator === trimmed.length - 1)
24
+ return undefined;
25
+ const provider = trimmed.slice(0, separator).trim();
26
+ const modelId = trimmed.slice(separator + 1).trim();
27
+ return provider && modelId ? { provider, modelId } : undefined;
28
+ }
29
+ function toRoutingModel(value) {
30
+ if (!value || typeof value.provider !== "string" || typeof value.id !== "string") {
31
+ return undefined;
32
+ }
33
+ return {
34
+ provider: value.provider,
35
+ id: value.id,
36
+ reasoning: value.reasoning ?? false,
37
+ thinkingLevelMap: value.thinkingLevelMap,
38
+ input: Array.isArray(value.input) ? value.input : undefined,
39
+ contextWindow: typeof value.contextWindow === "number" ? value.contextWindow : undefined,
40
+ maxTokens: typeof value.maxTokens === "number" ? value.maxTokens : undefined,
41
+ cost: value.cost,
42
+ };
43
+ }
44
+ export function wrapPiModelRegistry(registry) {
45
+ return {
46
+ find(provider, modelId) {
47
+ return toRoutingModel(registry.find(provider, modelId));
48
+ },
49
+ available() {
50
+ const direct = registry.getAvailable?.() ?? [];
51
+ const source = direct.length > 0 ? direct : registry.getAll?.() ?? [];
52
+ const models = [];
53
+ const seen = new Set();
54
+ for (const raw of source) {
55
+ const candidate = toRoutingModel(raw);
56
+ if (!candidate)
57
+ continue;
58
+ if (direct.length === 0 && registry.hasConfiguredAuth && !registry.hasConfiguredAuth(raw))
59
+ continue;
60
+ const ref = `${candidate.provider}/${candidate.id}`;
61
+ if (seen.has(ref))
62
+ continue;
63
+ seen.add(ref);
64
+ models.push(candidate);
65
+ }
66
+ return models;
67
+ },
68
+ hasConfiguredAuth(model) {
69
+ if (!registry.hasConfiguredAuth) {
70
+ return (registry.getAvailable?.() ?? []).some((candidate) => candidate.provider === model.provider && candidate.id === model.id);
71
+ }
72
+ const original = registry.find(model.provider, model.id);
73
+ return !!original && registry.hasConfiguredAuth(original);
74
+ },
75
+ };
76
+ }
77
+ function asPiModel(model) {
78
+ return {
79
+ provider: model.provider,
80
+ id: model.id,
81
+ name: model.id,
82
+ api: "openai-completions",
83
+ baseUrl: "",
84
+ reasoning: model.reasoning,
85
+ thinkingLevelMap: model.thinkingLevelMap,
86
+ input: (model.input?.filter((entry) => entry === "text" || entry === "image") ?? ["text"]),
87
+ contextWindow: model.contextWindow ?? 0,
88
+ maxTokens: model.maxTokens ?? 0,
89
+ cost: {
90
+ input: model.cost?.input ?? 0,
91
+ output: model.cost?.output ?? 0,
92
+ cacheRead: model.cost?.cacheRead ?? 0,
93
+ cacheWrite: model.cost?.cacheWrite ?? 0,
94
+ },
95
+ };
96
+ }
97
+ function formatSupported(model) {
98
+ const levels = getSupportedThinkingLevels(asPiModel(model));
99
+ return levels.length > 0 ? levels.join(", ") : "(none)";
100
+ }
101
+ /** Resolve a subagent runtime from role frontmatter, then the parent runtime. */
102
+ export function resolveRuntimePlan(role, parent, registry) {
103
+ let provider = parent.provider;
104
+ let modelId = parent.modelId;
105
+ let selectedModel = registry.find(provider, modelId);
106
+ const modelSource = role.model ? "agent" : "parent";
107
+ if (role.model) {
108
+ const parsed = parseExactModelRef(role.model);
109
+ if (!parsed || role.model !== `${parsed.provider}/${parsed.modelId}`) {
110
+ throw new RuntimeResolutionError(`role model ${JSON.stringify(role.model)} must be one exact authenticated provider/model-id`);
111
+ }
112
+ const found = registry.find(parsed.provider, parsed.modelId);
113
+ if (!found)
114
+ throw new RuntimeResolutionError(`unknown role model ${JSON.stringify(role.model)}`);
115
+ if (!registry.hasConfiguredAuth(found)) {
116
+ throw new RuntimeResolutionError(`role model ${JSON.stringify(role.model)} has no configured authentication`);
117
+ }
118
+ provider = found.provider;
119
+ modelId = found.id;
120
+ selectedModel = found;
121
+ }
122
+ const thinkingSource = role.thinking ? "agent" : "parent";
123
+ const preferredThinking = role.thinking ?? parent.thinking;
124
+ let thinking = preferredThinking;
125
+ let thinkingAdjustment;
126
+ if (role.thinking) {
127
+ if (!selectedModel) {
128
+ throw new RuntimeResolutionError("model capability information is unavailable; cannot validate role thinking");
129
+ }
130
+ const supported = getSupportedThinkingLevels(asPiModel(selectedModel));
131
+ if (!supported.includes(preferredThinking)) {
132
+ throw new RuntimeResolutionError(`role thinking ${JSON.stringify(preferredThinking)} is not supported by ${JSON.stringify(`${provider}/${modelId}`)}; supported: ${formatSupported(selectedModel)}`);
133
+ }
134
+ }
135
+ else if (selectedModel) {
136
+ thinking = clampThinkingLevel(asPiModel(selectedModel), preferredThinking);
137
+ if (thinking !== preferredThinking) {
138
+ thinkingAdjustment = {
139
+ from: preferredThinking,
140
+ to: thinking,
141
+ reason: selectedModel.reasoning ? "inherited-clamp" : "non-reasoning",
142
+ };
143
+ }
144
+ }
145
+ return {
146
+ provider,
147
+ modelId,
148
+ model: `${provider}/${modelId}`,
149
+ thinking,
150
+ modelSource,
151
+ thinkingSource,
152
+ ...(thinkingAdjustment ? { thinkingAdjustment } : {}),
153
+ };
154
+ }
@@ -0,0 +1,239 @@
1
+ import {
2
+ clampThinkingLevel,
3
+ getSupportedThinkingLevels,
4
+ type Model,
5
+ type ModelThinkingLevel,
6
+ } from "@earendil-works/pi-ai";
7
+
8
+ export const THINKING_LEVELS = [
9
+ "off",
10
+ "minimal",
11
+ "low",
12
+ "medium",
13
+ "high",
14
+ "xhigh",
15
+ "max",
16
+ ] as const;
17
+
18
+ export function isThinkingLevel(value: string): value is ThinkingLevel {
19
+ return (THINKING_LEVELS as readonly string[]).includes(value);
20
+ }
21
+
22
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
23
+ export type RuntimeSource = "agent" | "parent";
24
+
25
+ export interface RoleRuntime {
26
+ model?: string;
27
+ thinking?: ThinkingLevel;
28
+ }
29
+
30
+ export interface ParentRuntime {
31
+ provider: string;
32
+ modelId: string;
33
+ thinking: ThinkingLevel;
34
+ }
35
+
36
+ export interface RoutingModel {
37
+ provider: string;
38
+ id: string;
39
+ reasoning: boolean;
40
+ thinkingLevelMap?: Model<any>["thinkingLevelMap"];
41
+ input?: string[];
42
+ contextWindow?: number;
43
+ maxTokens?: number;
44
+ cost?: {
45
+ input?: number;
46
+ output?: number;
47
+ cacheRead?: number;
48
+ cacheWrite?: number;
49
+ };
50
+ }
51
+
52
+ export interface ModelRegistryAdapter {
53
+ find(provider: string, modelId: string): RoutingModel | undefined;
54
+ available(): RoutingModel[];
55
+ hasConfiguredAuth(model: { provider: string; id: string }): boolean;
56
+ }
57
+
58
+ export interface ResolvedRuntimePlan {
59
+ provider: string;
60
+ modelId: string;
61
+ model: string;
62
+ thinking: ThinkingLevel;
63
+ modelSource: RuntimeSource;
64
+ thinkingSource: RuntimeSource;
65
+ thinkingAdjustment?: {
66
+ from: ThinkingLevel;
67
+ to: ThinkingLevel;
68
+ reason: "non-reasoning" | "inherited-clamp";
69
+ };
70
+ observed?: {
71
+ model?: string;
72
+ thinking?: ThinkingLevel;
73
+ };
74
+ runtimeMismatch?: string;
75
+ }
76
+
77
+ export class RuntimeResolutionError extends Error {
78
+ constructor(message: string) {
79
+ super(message);
80
+ this.name = "RuntimeResolutionError";
81
+ }
82
+ }
83
+
84
+ export function parseExactModelRef(
85
+ reference: string,
86
+ ): { provider: string; modelId: string } | undefined {
87
+ const trimmed = reference.trim();
88
+ const separator = trimmed.indexOf("/");
89
+ if (separator <= 0 || separator === trimmed.length - 1) return undefined;
90
+ const provider = trimmed.slice(0, separator).trim();
91
+ const modelId = trimmed.slice(separator + 1).trim();
92
+ return provider && modelId ? { provider, modelId } : undefined;
93
+ }
94
+
95
+ function toRoutingModel(value: any): RoutingModel | undefined {
96
+ if (!value || typeof value.provider !== "string" || typeof value.id !== "string") {
97
+ return undefined;
98
+ }
99
+ return {
100
+ provider: value.provider,
101
+ id: value.id,
102
+ reasoning: value.reasoning ?? false,
103
+ thinkingLevelMap: value.thinkingLevelMap,
104
+ input: Array.isArray(value.input) ? value.input : undefined,
105
+ contextWindow: typeof value.contextWindow === "number" ? value.contextWindow : undefined,
106
+ maxTokens: typeof value.maxTokens === "number" ? value.maxTokens : undefined,
107
+ cost: value.cost,
108
+ };
109
+ }
110
+
111
+ export function wrapPiModelRegistry(registry: {
112
+ find(provider: string, modelId: string): any;
113
+ getAvailable?: () => any[];
114
+ getAll?: () => any[];
115
+ hasConfiguredAuth?: (model: any) => boolean;
116
+ }): ModelRegistryAdapter {
117
+ return {
118
+ find(provider, modelId) {
119
+ return toRoutingModel(registry.find(provider, modelId));
120
+ },
121
+ available() {
122
+ const direct = registry.getAvailable?.() ?? [];
123
+ const source = direct.length > 0 ? direct : registry.getAll?.() ?? [];
124
+ const models: RoutingModel[] = [];
125
+ const seen = new Set<string>();
126
+ for (const raw of source) {
127
+ const candidate = toRoutingModel(raw);
128
+ if (!candidate) continue;
129
+ if (direct.length === 0 && registry.hasConfiguredAuth && !registry.hasConfiguredAuth(raw)) continue;
130
+ const ref = `${candidate.provider}/${candidate.id}`;
131
+ if (seen.has(ref)) continue;
132
+ seen.add(ref);
133
+ models.push(candidate);
134
+ }
135
+ return models;
136
+ },
137
+ hasConfiguredAuth(model) {
138
+ if (!registry.hasConfiguredAuth) {
139
+ return (registry.getAvailable?.() ?? []).some(
140
+ (candidate) => candidate.provider === model.provider && candidate.id === model.id,
141
+ );
142
+ }
143
+ const original = registry.find(model.provider, model.id);
144
+ return !!original && registry.hasConfiguredAuth(original);
145
+ },
146
+ };
147
+ }
148
+
149
+ function asPiModel(model: RoutingModel): Model<any> {
150
+ return {
151
+ provider: model.provider,
152
+ id: model.id,
153
+ name: model.id,
154
+ api: "openai-completions",
155
+ baseUrl: "",
156
+ reasoning: model.reasoning,
157
+ thinkingLevelMap: model.thinkingLevelMap,
158
+ input: (model.input?.filter((entry): entry is "text" | "image" =>
159
+ entry === "text" || entry === "image") ?? ["text"]),
160
+ contextWindow: model.contextWindow ?? 0,
161
+ maxTokens: model.maxTokens ?? 0,
162
+ cost: {
163
+ input: model.cost?.input ?? 0,
164
+ output: model.cost?.output ?? 0,
165
+ cacheRead: model.cost?.cacheRead ?? 0,
166
+ cacheWrite: model.cost?.cacheWrite ?? 0,
167
+ },
168
+ };
169
+ }
170
+
171
+ function formatSupported(model: RoutingModel): string {
172
+ const levels = getSupportedThinkingLevels(asPiModel(model));
173
+ return levels.length > 0 ? levels.join(", ") : "(none)";
174
+ }
175
+
176
+ /** Resolve a subagent runtime from role frontmatter, then the parent runtime. */
177
+ export function resolveRuntimePlan(
178
+ role: RoleRuntime,
179
+ parent: ParentRuntime,
180
+ registry: ModelRegistryAdapter,
181
+ ): ResolvedRuntimePlan {
182
+ let provider = parent.provider;
183
+ let modelId = parent.modelId;
184
+ let selectedModel = registry.find(provider, modelId);
185
+ const modelSource: RuntimeSource = role.model ? "agent" : "parent";
186
+
187
+ if (role.model) {
188
+ const parsed = parseExactModelRef(role.model);
189
+ if (!parsed || role.model !== `${parsed.provider}/${parsed.modelId}`) {
190
+ throw new RuntimeResolutionError(
191
+ `role model ${JSON.stringify(role.model)} must be one exact authenticated provider/model-id`,
192
+ );
193
+ }
194
+ const found = registry.find(parsed.provider, parsed.modelId);
195
+ if (!found) throw new RuntimeResolutionError(`unknown role model ${JSON.stringify(role.model)}`);
196
+ if (!registry.hasConfiguredAuth(found)) {
197
+ throw new RuntimeResolutionError(`role model ${JSON.stringify(role.model)} has no configured authentication`);
198
+ }
199
+ provider = found.provider;
200
+ modelId = found.id;
201
+ selectedModel = found;
202
+ }
203
+
204
+ const thinkingSource: RuntimeSource = role.thinking ? "agent" : "parent";
205
+ const preferredThinking = role.thinking ?? parent.thinking;
206
+ let thinking = preferredThinking;
207
+ let thinkingAdjustment: ResolvedRuntimePlan["thinkingAdjustment"];
208
+
209
+ if (role.thinking) {
210
+ if (!selectedModel) {
211
+ throw new RuntimeResolutionError("model capability information is unavailable; cannot validate role thinking");
212
+ }
213
+ const supported = getSupportedThinkingLevels(asPiModel(selectedModel));
214
+ if (!supported.includes(preferredThinking as ModelThinkingLevel)) {
215
+ throw new RuntimeResolutionError(
216
+ `role thinking ${JSON.stringify(preferredThinking)} is not supported by ${JSON.stringify(`${provider}/${modelId}`)}; supported: ${formatSupported(selectedModel)}`,
217
+ );
218
+ }
219
+ } else if (selectedModel) {
220
+ thinking = clampThinkingLevel(asPiModel(selectedModel), preferredThinking) as ThinkingLevel;
221
+ if (thinking !== preferredThinking) {
222
+ thinkingAdjustment = {
223
+ from: preferredThinking,
224
+ to: thinking,
225
+ reason: selectedModel.reasoning ? "inherited-clamp" : "non-reasoning",
226
+ };
227
+ }
228
+ }
229
+
230
+ return {
231
+ provider,
232
+ modelId,
233
+ model: `${provider}/${modelId}`,
234
+ thinking,
235
+ modelSource,
236
+ thinkingSource,
237
+ ...(thinkingAdjustment ? { thinkingAdjustment } : {}),
238
+ };
239
+ }