portable-agent-layer 0.85.24 → 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 (34) 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 +89 -11
  20. package/src/cli/index.ts +0 -0
  21. package/src/cli/skill.ts +10 -2
  22. package/src/hooks/lib/agent-definition.ts +57 -0
  23. package/src/hooks/lib/frontmatter.ts +21 -8
  24. package/src/hooks/lib/inference.ts +40 -9
  25. package/src/hooks/lib/log.ts +10 -4
  26. package/src/hooks/lib/pal-env.ts +51 -0
  27. package/src/hooks/lib/paths.ts +7 -1
  28. package/src/targets/agent-render.ts +103 -0
  29. package/src/targets/codex/install.ts +6 -1
  30. package/src/targets/codex/uninstall.ts +8 -1
  31. package/src/targets/lib.ts +72 -89
  32. package/src/tools/control-room/static.ts +3 -8
  33. package/src/tools/lib/skill-doctor.ts +4 -4
  34. package/src/tools/subagent-doctor.ts +50 -40
@@ -39,15 +39,16 @@ import {
39
39
  isFixedModelRoute,
40
40
  } from "./models";
41
41
  import { opencodeTierModel, writeInstructionFreeConfig } from "./opencode-config";
42
+ import { withPalEnv } from "./pal-env";
42
43
  import { buildSpawnGuardEnv, getInferenceDepth, SPAWN_GUARD_ENV } from "./spawn-guard";
43
44
  import { findBinaryOnPath } from "./which";
44
45
 
45
46
  export function hasApiKey(): boolean {
46
- return !!process.env.PAL_ANTHROPIC_API_KEY;
47
+ return !!withPalEnv().PAL_ANTHROPIC_API_KEY;
47
48
  }
48
49
 
49
50
  export function hasOpenAiKey(): boolean {
50
- return !!process.env.PAL_OPENAI_API_KEY;
51
+ return !!withPalEnv().PAL_OPENAI_API_KEY;
51
52
  }
52
53
 
53
54
  type InferenceRoute =
@@ -304,6 +305,27 @@ export function buildClaudeArgs(
304
305
  return args;
305
306
  }
306
307
 
308
+ type ClaudeAuthMode = "token" | "native";
309
+
310
+ function claudeAuthMode(): ClaudeAuthMode {
311
+ return withPalEnv().CLAUDE_CODE_OAUTH_TOKEN ? "token" : "native";
312
+ }
313
+
314
+ /** Lines logged before spawns were tagged came from the native login. */
315
+ export function loggedClaudeAuthMode(message: string): ClaudeAuthMode {
316
+ return /\bauth=token\b/.test(message) ? "token" : "native";
317
+ }
318
+
319
+ /** https://code.claude.com/docs/en/authentication#authentication-precedence */
320
+ const WITHOUT_KEYS_OUTRANKING_TOKEN = {
321
+ ANTHROPIC_API_KEY: undefined,
322
+ ANTHROPIC_AUTH_TOKEN: undefined,
323
+ };
324
+
325
+ function credentialOverridesFor(mode: ClaudeAuthMode) {
326
+ return mode === "token" ? WITHOUT_KEYS_OUTRANKING_TOKEN : {};
327
+ }
328
+
307
329
  /**
308
330
  * Claude keeps a real system prompt, unlike the other agents, so the system text
309
331
  * reaches it through --system-prompt-file rather than --system-prompt. Only the
@@ -317,16 +339,22 @@ async function inferenceViaClaudeSpawn(
317
339
  bin: string,
318
340
  opts: InferenceOptions
319
341
  ): Promise<InferenceResult> {
342
+ const mode = claudeAuthMode();
343
+ const spawnClaude = (args: string[]) =>
344
+ inferenceViaCliSpawn(bin, args, opts.user, opts, undefined, {
345
+ env: credentialOverridesFor(mode),
346
+ logTag: `auth=${mode}`,
347
+ });
320
348
  const system = opts.jsonSchema
321
349
  ? injectJsonSchemaInstruction(opts.system ?? "", opts.jsonSchema)
322
350
  : opts.system;
323
- if (!system) return inferenceViaCliSpawn(bin, buildClaudeArgs(opts), opts.user, opts);
351
+ if (!system) return spawnClaude(buildClaudeArgs(opts));
324
352
 
325
353
  const dir = await mkdtemp(join(tmpdir(), "pal-system-"));
326
354
  try {
327
355
  const file = join(dir, "system-prompt.md");
328
356
  await writeFile(file, system, "utf-8");
329
- return await inferenceViaCliSpawn(bin, buildClaudeArgs(opts, file), opts.user, opts);
357
+ return await spawnClaude(buildClaudeArgs(opts, file));
330
358
  } finally {
331
359
  await rm(dir, { recursive: true, force: true });
332
360
  }
@@ -653,7 +681,8 @@ async function singleCliAttempt(
653
681
 
654
682
  interface SpawnPlace {
655
683
  cwd?: string;
656
- env?: Record<string, string>;
684
+ env?: Record<string, string | undefined>;
685
+ logTag?: string;
657
686
  }
658
687
 
659
688
  /**
@@ -670,11 +699,13 @@ async function inferenceViaCliSpawn(
670
699
  place: SpawnPlace = {}
671
700
  ): Promise<InferenceResult> {
672
701
  const timeout = opts.timeout ?? 15000;
673
- const env = { ...buildSpawnGuardEnv(process.env), ...place.env };
702
+ const env = { ...buildSpawnGuardEnv(withPalEnv(process.env)), ...place.env };
674
703
  const started = Date.now();
675
704
  const caller = opts.caller ?? "anonymous";
676
705
  const session = opts.sessionId ?? "-";
677
- const tag = `caller=${caller} sessionId=${session}`;
706
+ const tag = [`caller=${caller}`, `sessionId=${session}`, place.logTag]
707
+ .filter(Boolean)
708
+ .join(" ");
678
709
  // Friendly name for logs — strip path + extension so cross-platform diffs
679
710
  // (e.g. C:\…\claude.cmd vs /usr/local/bin/claude) read the same in debug.log.
680
711
  const binaryName = basename(binary).replace(/\.(cmd|bat|exe|com)$/i, "");
@@ -746,7 +777,7 @@ async function inferenceViaCliSpawn(
746
777
  // ─────────────────────────────────────────────────────────────────────────────
747
778
 
748
779
  async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult> {
749
- const apiKey = process.env.PAL_ANTHROPIC_API_KEY;
780
+ const apiKey = withPalEnv().PAL_ANTHROPIC_API_KEY;
750
781
  if (!apiKey) return { success: false };
751
782
 
752
783
  const { system, user, maxTokens = 200, timeout = 5000, jsonSchema } = opts;
@@ -814,7 +845,7 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
814
845
  // ─────────────────────────────────────────────────────────────────────────────
815
846
 
816
847
  async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceResult> {
817
- const apiKey = process.env.PAL_OPENAI_API_KEY;
848
+ const apiKey = withPalEnv().PAL_OPENAI_API_KEY;
818
849
  if (!apiKey) return { success: false };
819
850
 
820
851
  const { system, user, maxTokens = 500, timeout = 15000, jsonSchema } = opts;
@@ -72,6 +72,8 @@ export interface HookErrorGroup {
72
72
  count: number;
73
73
  last: string;
74
74
  lastAt: number;
75
+ /** The newest error's whole message, for tags `last` trims away. */
76
+ lastMessage?: string;
75
77
  }
76
78
 
77
79
  interface LoggedError {
@@ -97,8 +99,11 @@ function readAllLogs(): string {
97
99
  .join("\n");
98
100
  }
99
101
 
102
+ const LOGGED_LINE = /^\[(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\] /;
103
+ const ERROR_LINE = new RegExp(`${LOGGED_LINE.source}ERROR `);
104
+
100
105
  function loggedAt(line: string): number {
101
- const match = /^\[(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\]/.exec(line);
106
+ const match = LOGGED_LINE.exec(line);
102
107
  return match ? Date.parse(`${match[1]}T${match[2]}Z`) : 0;
103
108
  }
104
109
 
@@ -114,7 +119,8 @@ function groupBySource(errors: LoggedError[]): HookErrorGroup[] {
114
119
  const source = /^([^:]+):/.exec(message)?.[1] ?? "unknown";
115
120
  const group = groups.get(source) ?? { source, count: 0, last: "", lastAt: 0 };
116
121
  group.count++;
117
- group.last = whatTheAgentSaid(message.slice(source.length + 1).trim()).slice(0, 120);
122
+ group.lastMessage = message.slice(source.length + 1).trim();
123
+ group.last = whatTheAgentSaid(group.lastMessage).slice(0, 120);
118
124
  group.lastAt = at;
119
125
  groups.set(source, group);
120
126
  }
@@ -125,10 +131,10 @@ export function recentHookErrors(now: number = Date.now()): HookErrorGroup[] {
125
131
  try {
126
132
  const recent = readAllLogs()
127
133
  .split("\n")
128
- .filter((line) => line.includes("] ERROR ") && loggedAt(line) > now - DAY_MS)
134
+ .filter((line) => ERROR_LINE.test(line) && loggedAt(line) > now - DAY_MS)
129
135
  .map((line) => ({
130
136
  at: loggedAt(line),
131
- message: line.replace(/^\[.*?\] ERROR /, ""),
137
+ message: line.replace(ERROR_LINE, ""),
132
138
  }));
133
139
  return groupBySource(recent);
134
140
  } catch {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Credentials PAL reads from ~/.pal/.env. A variable already set in the
3
+ * environment wins; the file fills the gaps. Agents launched from a desktop
4
+ * app never read a shell profile, so hooks they spawn see only this file.
5
+ */
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { palHome } from "./paths";
10
+
11
+ type Env = Record<string, string | undefined>;
12
+
13
+ export function palEnvPath(): string {
14
+ return resolve(palHome(), ".env");
15
+ }
16
+
17
+ function withoutExportPrefix(line: string): string {
18
+ return line.startsWith("export ") ? line.slice("export ".length).trimStart() : line;
19
+ }
20
+
21
+ function unquoted(value: string): string {
22
+ return value.replace(/^["']|["']$/g, "");
23
+ }
24
+
25
+ export function parsePalEnv(content: string): Record<string, string> {
26
+ const values: Record<string, string> = {};
27
+ for (const raw of content.split("\n")) {
28
+ const line = withoutExportPrefix(raw.trim());
29
+ if (!line || line.startsWith("#")) continue;
30
+ const eq = line.indexOf("=");
31
+ if (eq <= 0) continue;
32
+ values[line.slice(0, eq).trim()] = unquoted(line.slice(eq + 1).trim());
33
+ }
34
+ return values;
35
+ }
36
+
37
+ export function readPalEnvFile(): Record<string, string> {
38
+ try {
39
+ return parsePalEnv(readFileSync(palEnvPath(), "utf-8"));
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ export function withPalEnv(env: Env = process.env): Env {
46
+ const merged: Env = { ...env };
47
+ for (const [key, value] of Object.entries(readPalEnvFile())) {
48
+ if (!merged[key]) merged[key] = value;
49
+ }
50
+ return merged;
51
+ }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
2
  import { homedir, tmpdir } from "node:os";
3
- import { resolve } from "node:path";
3
+ import { isAbsolute, relative, resolve } from "node:path";
4
4
 
5
5
  /**
6
6
  * Turn a path string that came from outside — argv, a flag, an env override, a
@@ -36,6 +36,12 @@ export function namesAPath(input: string): boolean {
36
36
  return input.startsWith("~") || input.includes("/") || input.includes("\\");
37
37
  }
38
38
 
39
+ /** Compared as paths, not strings — a separator differs by platform. */
40
+ export function isInside(parent: string, target: string): boolean {
41
+ const rel = relative(parent, target);
42
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
43
+ }
44
+
39
45
  /** An env override names a path the same way a flag does, so it gets the same treatment. */
40
46
  function envPath(name: string, fallback: string): string {
41
47
  const override = process.env[name];
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Renders one merged subagent definition into the file each agent reads:
3
+ * Markdown with that platform's frontmatter, or a Codex agent-role TOML.
4
+ */
5
+
6
+ import {
7
+ type AgentDefinition,
8
+ type AgentPlatform,
9
+ parseAgentDefinition,
10
+ } from "../hooks/lib/agent-definition";
11
+
12
+ const CODEX_OWNED_KEYS = new Set(["name", "description", "developer_instructions"]);
13
+
14
+ export function agentFileName(stem: string, platform: AgentPlatform): string {
15
+ return `${stem}.${platform === "codex" ? "toml" : "md"}`;
16
+ }
17
+
18
+ export function renderAgentForPlatform(content: string, platform: AgentPlatform): string {
19
+ const definition = parseAgentDefinition(content);
20
+ if (platform === "codex") return renderCodexAgent(definition);
21
+ if (!definition.hasFrontmatter) return content;
22
+ const frontmatter = [...definition.global, ...(definition.platforms[platform] ?? [])];
23
+ return `---\n${frontmatter.join("\n")}\n---${definition.body}`;
24
+ }
25
+
26
+ function renderCodexAgent(definition: AgentDefinition): string {
27
+ if (!definition.hasFrontmatter)
28
+ throw new Error("no frontmatter to build a Codex agent from");
29
+ const { name, description, ...unmapped } = yamlMapping(
30
+ definition.global,
31
+ "frontmatter"
32
+ );
33
+ rejectUnmappedGlobals(Object.keys(unmapped));
34
+ const overrides = Object.entries(
35
+ yamlMapping(definition.platforms.codex ?? [], "codex block")
36
+ );
37
+ const instructions = `${definition.body.trim()}\n`;
38
+ const lines = [
39
+ `name = ${tomlString(requiredText(name, "name"))}`,
40
+ `description = ${tomlString(requiredText(description, "description"))}`,
41
+ ...overrides.map(
42
+ ([key, value]) => `${tomlKey(key)} = ${codexOverrideValue(key, value)}`
43
+ ),
44
+ `developer_instructions = ${tomlText(instructions)}`,
45
+ ];
46
+ return `${lines.join("\n")}\n`;
47
+ }
48
+
49
+ function yamlMapping(lines: string[], where: string): Record<string, unknown> {
50
+ if (lines.length === 0) return {};
51
+ const parsed = Bun.YAML.parse(lines.join("\n"));
52
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
53
+ throw new Error(`the ${where} is not a YAML mapping`);
54
+ }
55
+ return parsed as Record<string, unknown>;
56
+ }
57
+
58
+ function rejectUnmappedGlobals(keys: string[]): void {
59
+ if (keys.length === 0) return;
60
+ throw new Error(
61
+ `Codex has no field for global ${keys.join(", ")}; move it into a platform block`
62
+ );
63
+ }
64
+
65
+ function requiredText(value: unknown, field: string): string {
66
+ if (typeof value === "string" && value.trim()) return value;
67
+ throw new Error(`Codex needs a non-empty ${field}`);
68
+ }
69
+
70
+ function codexOverrideValue(key: string, value: unknown): string {
71
+ if (CODEX_OWNED_KEYS.has(key)) {
72
+ throw new Error(
73
+ `codex.${key} is built from the shared definition and cannot be overridden`
74
+ );
75
+ }
76
+ if (typeof value === "string") return tomlString(value);
77
+ if (typeof value === "boolean") return String(value);
78
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
79
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
80
+ return `[${value.map(tomlString).join(", ")}]`;
81
+ }
82
+ throw new Error(`codex.${key} must be a string, number, boolean or list of strings`);
83
+ }
84
+
85
+ function tomlKey(key: string): string {
86
+ return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key);
87
+ }
88
+
89
+ function tomlString(value: string): string {
90
+ return JSON.stringify(value).replaceAll("\u007f", String.raw`\u007f`);
91
+ }
92
+
93
+ function tomlText(text: string): string {
94
+ return fitsLiteralString(text) ? `'''\n${text}'''` : tomlString(text);
95
+ }
96
+
97
+ function fitsLiteralString(text: string): boolean {
98
+ if (text.includes("'''")) return false;
99
+ return Array.from(text).every((char) => {
100
+ const code = char.charCodeAt(0);
101
+ return char === "\t" || char === "\n" || (code >= 0x20 && code !== 0x7f);
102
+ });
103
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * PAL — Codex target installer
3
3
  * Merges PAL hooks into ~/.codex/hooks.json (never overwrites user hooks).
4
- * Symlinks skills. Ensures AGENTS.md symlink via regenerateIfNeeded().
4
+ * Symlinks skills, renders agents to TOML. Ensures AGENTS.md symlink via regenerateIfNeeded().
5
5
  */
6
6
 
7
7
  import {
@@ -15,6 +15,7 @@ import { resolve } from "node:path";
15
15
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
16
16
  import {
17
17
  addCodexStatuslineConfig,
18
+ copyAgentsForCodex,
18
19
  copySkills,
19
20
  countSkills,
20
21
  loadCodexHooksTemplate,
@@ -101,6 +102,10 @@ const codexSkillsDir = resolve(CODEX_DIR, "skills");
101
102
  copySkills(codexSkillsDir);
102
103
  log.success(`${countSkills()} skills → ~/.codex/skills/`);
103
104
 
105
+ // --- Render agents to ~/.codex/agents/ ---
106
+ const agentCount = copyAgentsForCodex(resolve(CODEX_DIR, "agents"));
107
+ log.success(`${agentCount} agents → ~/.codex/agents/`);
108
+
104
109
  // --- Enable hooks in config.toml ---
105
110
  const CONFIG_FILE = resolve(CODEX_DIR, "config.toml");
106
111
  enableCodexHooks(CONFIG_FILE);
@@ -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
- }