portable-agent-layer 0.85.23 → 0.86.0

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 (38) hide show
  1. package/README.md +7 -1
  2. package/assets/agents/gemini-researcher.md +2 -2
  3. package/assets/agents/grok-researcher.md +2 -2
  4. package/assets/agents/perplexity-researcher.md +1 -1
  5. package/assets/agents/subagent-author.md +1 -1
  6. package/assets/skills/analyze-youtube/SKILL.md +1 -1
  7. package/assets/skills/analyze-youtube/tools/youtube-analyze.ts +1 -0
  8. package/assets/skills/create-subagent/SKILL.md +2 -2
  9. package/assets/skills/create-subagent/authoring-guide.md +13 -1
  10. package/assets/skills/deep-research/SKILL.md +1 -0
  11. package/assets/skills/deep-research/tools/gemini-search.ts +1 -0
  12. package/assets/skills/deep-research/tools/grok-search.ts +1 -0
  13. package/assets/skills/deep-research/tools/perplexity-search.ts +1 -1
  14. package/assets/skills/fyzz-chat-api/SKILL.md +2 -2
  15. package/assets/skills/fyzz-chat-api/tools/fyzz-api.ts +1 -1
  16. package/package.json +1 -1
  17. package/src/cli/doctor/agents.ts +16 -1
  18. package/src/cli/doctor/health.ts +7 -6
  19. package/src/cli/doctor/inference.ts +136 -15
  20. package/src/cli/index.ts +1 -1
  21. package/src/cli/skill.ts +10 -2
  22. package/src/hooks/handlers/agenda.ts +4 -4
  23. package/src/hooks/lib/agent-definition.ts +57 -0
  24. package/src/hooks/lib/frontmatter.ts +21 -8
  25. package/src/hooks/lib/inference.ts +100 -52
  26. package/src/hooks/lib/log.ts +10 -4
  27. package/src/hooks/lib/models.ts +34 -0
  28. package/src/hooks/lib/opencode-config.ts +16 -5
  29. package/src/hooks/lib/pal-env.ts +51 -0
  30. package/src/hooks/lib/paths.ts +7 -1
  31. package/src/targets/agent-render.ts +103 -0
  32. package/src/targets/codex/install.ts +6 -1
  33. package/src/targets/codex/uninstall.ts +8 -1
  34. package/src/targets/lib.ts +72 -89
  35. package/src/tools/control-room/static.ts +3 -8
  36. package/src/tools/lib/skill-doctor.ts +4 -4
  37. package/src/tools/self-model.ts +1 -1
  38. package/src/tools/subagent-doctor.ts +50 -40
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * PAL — Codex uninstaller
3
3
  * Removes only PAL-owned hooks from ~/.codex/hooks.json. Preserves user hooks.
4
- * Removes PAL skill symlinks.
4
+ * Removes PAL skill symlinks and PAL agents.
5
5
  */
6
6
 
7
7
  import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -11,6 +11,7 @@ import {
11
11
  loadCodexHooksTemplate,
12
12
  log,
13
13
  readJson,
14
+ removeAgentsFromCodex,
14
15
  removeCodexStatuslineConfig,
15
16
  removeSkills,
16
17
  unmergeCodexHooks,
@@ -87,6 +88,12 @@ if (removed.length > 0) {
87
88
  log.info("No PAL skills found");
88
89
  }
89
90
 
91
+ // --- Remove PAL agents ---
92
+ const removedAgents = removeAgentsFromCodex(resolve(CODEX_DIR, "agents"));
93
+ if (removedAgents.length > 0) {
94
+ log.success(`Removed ${removedAgents.length} agent(s): ${removedAgents.join(", ")}`);
95
+ }
96
+
90
97
  // --- Disable hooks in config.toml ---
91
98
  const CONFIG_FILE = resolve(CODEX_DIR, "config.toml");
92
99
  disableCodexHooks(CONFIG_FILE);
@@ -18,8 +18,10 @@ import {
18
18
  } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { dirname, isAbsolute, resolve, sep } from "node:path";
21
+ import { AGENT_PLATFORMS, type AgentPlatform } from "../hooks/lib/agent-definition";
21
22
  import { assets, palHome, platform } from "../hooks/lib/paths";
22
23
  import { declaredTriggers } from "../hooks/lib/skill-triggers";
24
+ import { agentFileName, renderAgentForPlatform } from "./agent-render";
23
25
 
24
26
  // --- Colored logging ---
25
27
 
@@ -993,7 +995,7 @@ export function removeSkills(claudeSkillsDir: string): string[] {
993
995
 
994
996
  // --- Agents ---
995
997
 
996
- const claudeAgentsDir = () => resolve(platform.claudeDir(), "agents");
998
+ const claudeAgentsDir = () => nativeAgentsDir("claude");
997
999
 
998
1000
  /**
999
1001
  * Install PAL agent definitions into ~/.claude/agents/.
@@ -1031,81 +1033,28 @@ export function countAgents(): number {
1031
1033
  }
1032
1034
  }
1033
1035
 
1034
- // --- Agent platform extraction ---
1036
+ // --- Agent platform rendering ---
1035
1037
 
1036
- const AGENT_PLATFORMS = ["claude", "opencode", "cursor", "copilot"] as const;
1037
- type AgentPlatform = (typeof AGENT_PLATFORMS)[number];
1038
-
1039
- /**
1040
- * Extract a platform-specific agent file from the unified agent format.
1041
- *
1042
- * Each agent .md defines platform blocks at the top level:
1043
- * claude: → fields for Claude Code
1044
- * opencode: → fields for opencode
1045
- * cursor: → fields for Cursor
1046
- *
1047
- * Global fields (name, description) are always included.
1048
- * The target platform block is un-indented and merged into the root.
1049
- * All other platform blocks are stripped.
1050
- */
1051
- function extractAgentForPlatform(content: string, platform: AgentPlatform): string {
1052
- const parts = content.split(/^---\s*$/m);
1053
- if (parts.length < 3) return content;
1054
-
1055
- const frontmatter = parts[1];
1056
- const body = parts.slice(2).join("---");
1057
-
1058
- const globalLines: string[] = [];
1059
- const platformLines: Record<AgentPlatform, string[]> = {
1060
- claude: [],
1061
- opencode: [],
1062
- cursor: [],
1063
- copilot: [],
1064
- };
1065
- let currentPlatform: AgentPlatform | null = null;
1066
-
1067
- for (const line of frontmatter.split("\n")) {
1068
- if (!line.trim()) continue;
1069
-
1070
- const platformMatch = new RegExp(/^(claude|opencode|cursor|copilot):\s*$/).exec(line);
1071
- if (platformMatch) {
1072
- currentPlatform = platformMatch[1] as AgentPlatform;
1073
- continue;
1074
- }
1075
-
1076
- if (currentPlatform) {
1077
- if (new RegExp(/^ {2}/).exec(line)) {
1078
- platformLines[currentPlatform].push(line.slice(2)); // un-indent one level
1079
- continue;
1080
- }
1081
- currentPlatform = null; // end of platform block
1082
- }
1083
-
1084
- globalLines.push(line);
1085
- }
1086
-
1087
- const newFrontmatter = [...globalLines, ...platformLines[platform]]
1088
- .filter((l) => l.trim())
1089
- .join("\n");
1038
+ const shippedAgentStems = () =>
1039
+ readdirSync(assets.agents())
1040
+ .filter((f) => f.endsWith(".md"))
1041
+ .map((f) => f.replace(/\.md$/, ""));
1090
1042
 
1091
- return `---\n${newFrontmatter}\n---\n${body}`;
1092
- }
1043
+ const shippedAgentSource = (stem: string) =>
1044
+ readFileSync(resolve(assets.agents(), `${stem}.md`), "utf-8");
1093
1045
 
1094
1046
  /** Install agents for a platform into a target directory. Always overwrites. */
1095
1047
  function installAgents(targetDir: string, platform: AgentPlatform): number {
1096
- const agentsDir = assets.agents();
1097
- if (!existsSync(agentsDir)) return 0;
1048
+ if (!existsSync(assets.agents())) return 0;
1098
1049
 
1099
1050
  mkdirSync(targetDir, { recursive: true });
1100
1051
  let count = 0;
1101
1052
 
1102
- for (const file of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
1103
- const name = file.replace(/\.md$/, "");
1104
- const installed = reportOnlyOnFailure(`${platform} agent ${name}`, () => {
1105
- const content = readFileSync(resolve(agentsDir, file), "utf-8");
1053
+ for (const stem of shippedAgentStems()) {
1054
+ const installed = reportOnlyOnFailure(`${platform} agent ${stem}`, () => {
1106
1055
  writeFileSync(
1107
- resolve(targetDir, file),
1108
- extractAgentForPlatform(content, platform),
1056
+ resolve(targetDir, agentFileName(stem, platform)),
1057
+ renderAgentForPlatform(shippedAgentSource(stem), platform),
1109
1058
  "utf-8"
1110
1059
  );
1111
1060
  });
@@ -1114,18 +1063,31 @@ function installAgents(targetDir: string, platform: AgentPlatform): number {
1114
1063
  return count;
1115
1064
  }
1116
1065
 
1066
+ /** Shipped agents in targetDir that differ from what installAgents would write there. */
1067
+ export function staleShippedAgents(targetDir: string, agent: AgentPlatform): string[] {
1068
+ if (!existsSync(assets.agents())) return [];
1069
+ const isStale = (stem: string) => {
1070
+ const installed = resolve(targetDir, agentFileName(stem, agent));
1071
+ return (
1072
+ !existsSync(installed) ||
1073
+ readFileSync(installed, "utf-8") !==
1074
+ renderAgentForPlatform(shippedAgentSource(stem), agent)
1075
+ );
1076
+ };
1077
+ return shippedAgentStems().filter(isStale).sort();
1078
+ }
1079
+
1117
1080
  /** Remove PAL agents from a directory. */
1118
- function uninstallAgents(targetDir: string, label: string): string[] {
1119
- const agentsDir = assets.agents();
1120
- if (!existsSync(agentsDir)) return [];
1081
+ function uninstallAgents(targetDir: string, platform: AgentPlatform): string[] {
1082
+ if (!existsSync(assets.agents())) return [];
1121
1083
 
1122
1084
  const removed: string[] = [];
1123
- for (const file of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
1124
- const dst = resolve(targetDir, file);
1085
+ for (const stem of shippedAgentStems()) {
1086
+ const dst = resolve(targetDir, agentFileName(stem, platform));
1125
1087
  if (existsSync(dst)) {
1126
1088
  unlinkSync(dst);
1127
- removed.push(file.replace(/\.md$/, ""));
1128
- log.detail(`Removed ${label} agent: ${file.replace(/\.md$/, "")}`);
1089
+ removed.push(stem);
1090
+ log.detail(`Removed ${platform} agent: ${stem}`);
1129
1091
  }
1130
1092
  }
1131
1093
  return removed;
@@ -1147,6 +1109,10 @@ export function copyAgentsForCopilot(copilotAgentsDir: string): number {
1147
1109
  return installAgents(copilotAgentsDir, "copilot");
1148
1110
  }
1149
1111
 
1112
+ export function copyAgentsForCodex(codexAgentsDir: string): number {
1113
+ return installAgents(codexAgentsDir, "codex");
1114
+ }
1115
+
1150
1116
  export function removeAgentsFromCursor(cursorAgentsDir: string): string[] {
1151
1117
  return uninstallAgents(cursorAgentsDir, "cursor");
1152
1118
  }
@@ -1155,6 +1121,10 @@ export function removeAgentsFromCopilot(copilotAgentsDir: string): string[] {
1155
1121
  return uninstallAgents(copilotAgentsDir, "copilot");
1156
1122
  }
1157
1123
 
1124
+ export function removeAgentsFromCodex(codexAgentsDir: string): string[] {
1125
+ return uninstallAgents(codexAgentsDir, "codex");
1126
+ }
1127
+
1158
1128
  // --- Personal subagents (user-authored, under ~/.pal/agents/) ---
1159
1129
 
1160
1130
  /**
@@ -1169,12 +1139,19 @@ const palAgentsStore = () => resolve(palHome(), "agents");
1169
1139
  * exists (mirrors linkPersonalSkill's per-agent gate).
1170
1140
  */
1171
1141
  function personalSubagentTargets(): { agent: AgentPlatform; dir: string }[] {
1172
- return [
1173
- { agent: "claude", dir: resolve(platform.claudeDir(), "agents") },
1174
- { agent: "opencode", dir: resolve(platform.opencodeDir(), "agents") },
1175
- { agent: "cursor", dir: resolve(platform.cursorDir(), "agents") },
1176
- { agent: "copilot", dir: resolve(platform.copilotDir(), "agents") },
1177
- ];
1142
+ return AGENT_PLATFORMS.map((agent) => ({ agent, dir: nativeAgentsDir(agent) }));
1143
+ }
1144
+
1145
+ /** The directory each agent reads its subagent definitions from. */
1146
+ export function nativeAgentsDir(agent: AgentPlatform): string {
1147
+ const home = {
1148
+ claude: platform.claudeDir,
1149
+ opencode: platform.opencodeDir,
1150
+ cursor: platform.cursorDir,
1151
+ copilot: platform.copilotDir,
1152
+ codex: platform.codexDir,
1153
+ }[agent];
1154
+ return resolve(home(), "agents");
1178
1155
  }
1179
1156
 
1180
1157
  /** Names of the subagents PAL ships (assets/agents/*.md). */
@@ -1215,17 +1192,23 @@ export function installPersonalSubagent(name: string): string[] {
1215
1192
  );
1216
1193
  }
1217
1194
  const content = readFileSync(src, "utf-8");
1218
- const installed: string[] = [];
1219
- for (const { agent, dir } of personalSubagentTargets()) {
1220
- if (!existsSync(dir)) continue; // agent not installed
1221
- writeFileSync(
1222
- resolve(dir, `${name}.md`),
1223
- extractAgentForPlatform(content, agent),
1224
- "utf-8"
1225
- );
1226
- installed.push(agent);
1195
+ const renders = personalSubagentTargets()
1196
+ .filter(({ dir }) => existsSync(dir))
1197
+ .map(({ agent, dir }) => ({
1198
+ agent,
1199
+ file: resolve(dir, agentFileName(name, agent)),
1200
+ text: renderForPersonalSubagent(content, agent),
1201
+ }));
1202
+ for (const { file, text } of renders) writeFileSync(file, text, "utf-8");
1203
+ return renders.map(({ agent }) => agent);
1204
+ }
1205
+
1206
+ function renderForPersonalSubagent(content: string, agent: AgentPlatform): string {
1207
+ try {
1208
+ return renderAgentForPlatform(content, agent);
1209
+ } catch (e) {
1210
+ throw new Error(`Cannot build the ${agent} subagent: ${(e as Error).message}`);
1227
1211
  }
1228
- return installed;
1229
1212
  }
1230
1213
 
1231
1214
  /** Load and resolve the Copilot hooks template, substituting PKG_ROOT */
@@ -6,7 +6,8 @@
6
6
 
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { existsSync } from "node:fs";
9
- import { isAbsolute, relative, resolve } from "node:path";
9
+ import { resolve } from "node:path";
10
+ import { isInside } from "../../hooks/lib/paths";
10
11
 
11
12
  const UI_DIST = resolve(import.meta.dir, "ui", "dist");
12
13
 
@@ -57,12 +58,6 @@ export function indexHtml(): Response {
57
58
  */
58
59
  export function staticAsset(pathname: string): Response | null {
59
60
  const target = resolve(UI_DIST, `.${pathname}`);
60
- if (!isInsideDist(target)) return null;
61
+ if (!isInside(UI_DIST, target)) return null;
61
62
  return existsSync(target) ? new Response(Bun.file(target)) : null;
62
63
  }
63
-
64
- /** Compared as paths, not strings — a separator differs by platform. */
65
- function isInsideDist(target: string): boolean {
66
- const rel = relative(UI_DIST, target);
67
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
68
- }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { existsSync, readdirSync, readFileSync } from "node:fs";
14
14
  import { basename, extname, relative, resolve } from "node:path";
15
+ import { splitFrontmatter } from "../../hooks/lib/frontmatter";
15
16
  import { palHome, toPath } from "../../hooks/lib/paths";
16
17
  import { declaredTriggers } from "../../hooks/lib/skill-triggers";
17
18
 
@@ -90,8 +91,8 @@ export function findAbsolutePaths(skillDir: string): string[] {
90
91
 
91
92
  /** Split a SKILL.md into frontmatter fields and body. */
92
93
  export function parseSkill(content: string): ParsedSkill {
93
- const parts = content.split(/^---\s*$/m);
94
- if (parts.length < 3) {
94
+ const split = splitFrontmatter(content);
95
+ if (!split) {
95
96
  return {
96
97
  name: null,
97
98
  description: null,
@@ -103,8 +104,7 @@ export function parseSkill(content: string): ParsedSkill {
103
104
  body: content,
104
105
  };
105
106
  }
106
- const frontmatter = parts[1];
107
- const body = parts.slice(2).join("---");
107
+ const { frontmatter, body } = split;
108
108
  const name = /^name:\s*"?(.+?)"?\s*$/m.exec(frontmatter)?.[1] ?? null;
109
109
  const rawDescription = /^description:[ \t]*(.*?)\s*$/m.exec(frontmatter)?.[1] ?? null;
110
110
  const descriptionQuoted =
@@ -84,7 +84,7 @@ async function composeSelfModel(days: number): Promise<string> {
84
84
  const result = await inference({
85
85
  system: buildPrompt(ai.name, principal.name),
86
86
  user: inferenceUserContent(rawData, readFileOrEmpty(selfModelPath())),
87
- model: SONNET_MODEL,
87
+ tier: "medium",
88
88
  maxTokens: 1500,
89
89
  timeout: 90000,
90
90
  caller: "self-model",
@@ -2,7 +2,7 @@
2
2
  * subagent-doctor — static evaluator for a personal subagent definition
3
3
  * (~/.pal/agents/<name>.md) with merged multi-platform frontmatter. Checks only
4
4
  * what is mechanically verifiable: name/description constraints, per-platform
5
- * blocks (claude/opencode/cursor/copilot), model/tools/permission shape, body
5
+ * blocks (claude/opencode/cursor/copilot/codex), model/tools/permission shape, body
6
6
  * length, and absolute-path portability.
7
7
  *
8
8
  * Library: import { lintSubagent, formatSubagentReport } from ".../subagent-doctor"
@@ -13,6 +13,11 @@
13
13
 
14
14
  import { existsSync, readdirSync, readFileSync } from "node:fs";
15
15
  import { basename, resolve } from "node:path";
16
+ import {
17
+ AGENT_PLATFORMS,
18
+ type AgentPlatform,
19
+ parseAgentDefinition,
20
+ } from "../hooks/lib/agent-definition";
16
21
  import { assets, namesAPath, palHome, toPath } from "../hooks/lib/paths";
17
22
 
18
23
  type Level = "pass" | "warn" | "error";
@@ -31,9 +36,6 @@ export interface SubagentReport {
31
36
  warnings: number;
32
37
  }
33
38
 
34
- const AGENT_PLATFORMS = ["claude", "opencode", "cursor", "copilot"] as const;
35
- type AgentPlatform = (typeof AGENT_PLATFORMS)[number];
36
-
37
39
  interface ParsedSubagent {
38
40
  hasFrontmatter: boolean;
39
41
  name: string | null;
@@ -53,6 +55,9 @@ const MAX_BODY_LINES = 500;
53
55
  const KNOWN_MODELS = ["inherit", "fable", "sonnet", "opus", "haiku"];
54
56
  const PERMISSION_VALUES = ["allow", "ask", "deny"];
55
57
  const OPENCODE_MODES = ["subagent", "primary", "all"];
58
+ const CODEX_SANDBOX_MODES = ["read-only", "workspace-write", "danger-full-access"];
59
+ const CODEX_TABLE_KEYS = ["tools", "skills"];
60
+ const SHARED_FIELDS = ["name", "description"];
56
61
 
57
62
  /** Machine/user-specific absolute paths that will not survive an export. */
58
63
  const ABSOLUTE_PATH_RE =
@@ -71,42 +76,11 @@ function shippedAgentNames(): Set<string> {
71
76
 
72
77
  /** Split a merged subagent .md into global fields, per-platform blocks, and body. */
73
78
  function parseSubagent(content: string): ParsedSubagent {
74
- const parts = content.split(/^---\s*$/m);
75
- if (parts.length < 3) {
76
- return {
77
- hasFrontmatter: false,
78
- name: null,
79
- description: null,
80
- descriptionQuoted: false,
81
- global: [],
82
- platforms: {},
83
- body: content,
84
- };
85
- }
86
- const frontmatter = parts[1];
87
- const body = parts.slice(2).join("---");
88
-
89
- const global: string[] = [];
90
- const platforms: Partial<Record<AgentPlatform, string[]>> = {};
91
- let current: AgentPlatform | null = null;
92
-
93
- for (const line of frontmatter.split("\n")) {
94
- if (!line.trim()) continue;
95
- const pm = /^(claude|opencode|cursor|copilot):\s*$/.exec(line);
96
- if (pm) {
97
- current = pm[1] as AgentPlatform;
98
- platforms[current] ??= [];
99
- continue;
100
- }
101
- if (current) {
102
- if (/^ {2}/.test(line)) {
103
- platforms[current]?.push(line.slice(2));
104
- continue;
105
- }
106
- current = null;
107
- }
108
- global.push(line);
79
+ const definition = parseAgentDefinition(content);
80
+ if (!definition.hasFrontmatter) {
81
+ return { ...definition, name: null, description: null, descriptionQuoted: false };
109
82
  }
83
+ const { global, platforms, body } = definition;
110
84
 
111
85
  const globalText = global.join("\n");
112
86
  const name = /^name:\s*"?(.+?)"?\s*$/m.exec(globalText)?.[1] ?? null;
@@ -139,6 +113,10 @@ function fieldValue(lines: string[], key: string): string | null {
139
113
  return null;
140
114
  }
141
115
 
116
+ function topLevelKeys(lines: string[]): string[] {
117
+ return lines.flatMap((line) => /^([A-Za-z0-9_-]+):/.exec(line)?.[1] ?? []);
118
+ }
119
+
142
120
  /** True when a block declares `key:` (with or without an inline value). */
143
121
  function hasField(lines: string[], key: string): boolean {
144
122
  return lines.some((l) => new RegExp(`^${key}:`).test(l));
@@ -202,6 +180,26 @@ function checkCopilotBlock(lines: string[], add: AddFinding): void {
202
180
  if (model) validateModel(model, "copilot", add);
203
181
  }
204
182
 
183
+ function checkCodexBlock(lines: string[], add: AddFinding): void {
184
+ const model = fieldValue(lines, "model");
185
+ if (model) validateModel(model, "codex", add);
186
+ const sandbox = fieldValue(lines, "sandbox_mode");
187
+ if (sandbox !== null && !CODEX_SANDBOX_MODES.includes(sandbox)) {
188
+ add(
189
+ "warn",
190
+ "codex.sandbox_mode",
191
+ `sandbox_mode "${sandbox}" — expected one of ${CODEX_SANDBOX_MODES.join(", ")}`
192
+ );
193
+ }
194
+ for (const key of CODEX_TABLE_KEYS.filter((k) => hasField(lines, k))) {
195
+ add(
196
+ "warn",
197
+ `codex.${key}`,
198
+ `Codex reads \`${key}\` as a config table, not a list — the agent will not load; name tools or skills in the body instead`
199
+ );
200
+ }
201
+ }
202
+
205
203
  function validateModel(model: string, platform: string, add: AddFinding): void {
206
204
  const known = KNOWN_MODELS.includes(model) || /^(claude-|gpt-|o\d|gemini-)/.test(model);
207
205
  if (!known) {
@@ -311,6 +309,17 @@ export function lintSubagent(file: string): SubagentReport {
311
309
  );
312
310
  }
313
311
 
312
+ const unmappedGlobals = topLevelKeys(parsed.global).filter(
313
+ (key) => !SHARED_FIELDS.includes(key)
314
+ );
315
+ if (unmappedGlobals.length > 0) {
316
+ add(
317
+ "error",
318
+ "global.fields",
319
+ `global ${unmappedGlobals.join(", ")} has no Codex key, so linking fails — move it into the platform blocks that use it`
320
+ );
321
+ }
322
+
314
323
  // ── platform blocks ──
315
324
  const present = AGENT_PLATFORMS.filter((p) => (platforms[p]?.length ?? 0) > 0);
316
325
  present.length > 0
@@ -318,13 +327,14 @@ export function lintSubagent(file: string): SubagentReport {
318
327
  : add(
319
328
  "warn",
320
329
  "platforms",
321
- "no platform block — the subagent installs with only name/description; add a claude:/opencode:/cursor:/copilot: block to set model, tools, mode"
330
+ "no platform block — the subagent installs with only name/description; add a claude:/opencode:/cursor:/copilot:/codex: block to set model, tools, mode"
322
331
  );
323
332
 
324
333
  if (platforms.claude) checkClaudeBlock(platforms.claude, add);
325
334
  if (platforms.opencode) checkOpencodeBlock(platforms.opencode, add);
326
335
  if (platforms.cursor) checkCursorBlock(platforms.cursor, add);
327
336
  if (platforms.copilot) checkCopilotBlock(platforms.copilot, add);
337
+ if (platforms.codex) checkCodexBlock(platforms.codex, add);
328
338
 
329
339
  // ── skills field: Claude preloads it; the others have no such field ──
330
340
  for (const p of ["opencode", "cursor", "copilot"] as const) {