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
@@ -10,23 +10,36 @@ interface Parsed<T = Record<string, string>> {
10
10
  body: string;
11
11
  }
12
12
 
13
- const DELIMITER = /^---\s*$/m;
13
+ const DELIMITER = /^---[ \t]*$/m;
14
+
15
+ interface FrontmatterSplit {
16
+ leading: string;
17
+ frontmatter: string;
18
+ body: string;
19
+ }
20
+
21
+ /**
22
+ * Split text at its first two `---` lines. The body keeps every character after
23
+ * the closing delimiter, so later `---` rules and the lines around them survive.
24
+ */
25
+ export function splitFrontmatter(content: string): FrontmatterSplit | null {
26
+ const parts = content.split(DELIMITER);
27
+ if (parts.length < 3) return null;
28
+ return { leading: parts[0], frontmatter: parts[1], body: parts.slice(2).join("---") };
29
+ }
14
30
 
15
31
  /**
16
32
  * Parse frontmatter from a markdown string.
17
33
  * Returns typed meta + body. If no frontmatter found, meta is empty and body is the full content.
18
34
  */
19
35
  export function parse<T = Record<string, string>>(content: string): Parsed<T> {
20
- const parts = content.split(DELIMITER);
21
-
22
- // Need at least 3 parts: before --- | frontmatter | after ---
23
- // parts[0] should be empty (content starts with ---)
24
- if (parts.length < 3 || parts[0].trim() !== "") {
36
+ const split = splitFrontmatter(content);
37
+ if (!split || split.leading.trim() !== "") {
25
38
  return { meta: {} as T, body: content };
26
39
  }
27
40
 
28
- const rawMeta = parts[1];
29
- const body = parts.slice(2).join("---").trim();
41
+ const rawMeta = split.frontmatter;
42
+ const body = split.body.trim();
30
43
 
31
44
  const meta: Record<string, unknown> = {};
32
45
  for (const line of rawMeta.split("\n")) {
@@ -32,37 +32,58 @@ import {
32
32
  isOpencode,
33
33
  } from "./agent";
34
34
  import { logDebug, logError } from "./log";
35
- import { HAIKU_MODEL } from "./models";
36
- import { writeInstructionFreeConfig } from "./opencode-config";
35
+ import {
36
+ type FixedModelRoute,
37
+ type InferenceTier,
38
+ inferenceModel,
39
+ isFixedModelRoute,
40
+ } from "./models";
41
+ import { opencodeTierModel, writeInstructionFreeConfig } from "./opencode-config";
42
+ import { withPalEnv } from "./pal-env";
37
43
  import { buildSpawnGuardEnv, getInferenceDepth, SPAWN_GUARD_ENV } from "./spawn-guard";
38
44
  import { findBinaryOnPath } from "./which";
39
45
 
40
46
  export function hasApiKey(): boolean {
41
- return !!process.env.PAL_ANTHROPIC_API_KEY;
47
+ return !!withPalEnv().PAL_ANTHROPIC_API_KEY;
42
48
  }
43
49
 
44
50
  export function hasOpenAiKey(): boolean {
45
- return !!process.env.PAL_OPENAI_API_KEY;
51
+ return !!withPalEnv().PAL_OPENAI_API_KEY;
52
+ }
53
+
54
+ type InferenceRoute =
55
+ | FixedModelRoute
56
+ | "opencode-spawn"
57
+ | "copilot-spawn"
58
+ | "cursor-spawn"
59
+ | "disabled"
60
+ | "none";
61
+
62
+ interface RoutePreview {
63
+ agent: string;
64
+ route: InferenceRoute;
65
+ reason: string;
66
+ }
67
+
68
+ function modelFor(route: FixedModelRoute, opts: InferenceOptions): string {
69
+ return inferenceModel(route, opts.tier);
70
+ }
71
+
72
+ function smallModelOf(route: InferenceRoute): string | undefined {
73
+ if (route === "opencode-spawn") return opencodeTierModel("small") ?? undefined;
74
+ return isFixedModelRoute(route) ? inferenceModel(route, "small") : undefined;
46
75
  }
47
76
 
48
77
  /**
49
78
  * Preview what `inference()` would do RIGHT NOW given current env + binaries.
50
79
  * Pure diagnostic — never spawns or fetches. Used by `pal cli doctor`.
51
80
  */
52
- export function previewInferenceRoute(): {
53
- agent: string;
54
- route:
55
- | "claude-spawn"
56
- | "codex-spawn"
57
- | "openai-api"
58
- | "opencode-spawn"
59
- | "copilot-spawn"
60
- | "cursor-spawn"
61
- | "anthropic-api"
62
- | "disabled"
63
- | "none";
64
- reason: string;
65
- } {
81
+ export function previewInferenceRoute(): RoutePreview & { model?: string } {
82
+ const preview = previewRoute();
83
+ return { ...preview, model: smallModelOf(preview.route) };
84
+ }
85
+
86
+ function previewRoute(): RoutePreview {
66
87
  const agent = getActiveAgent();
67
88
  if (process.env.PAL_INFERENCE_DISABLED === "1") {
68
89
  return {
@@ -115,7 +136,8 @@ export function canInfer(): boolean {
115
136
  interface InferenceOptions {
116
137
  system?: string;
117
138
  user: string;
118
- model?: string;
139
+ /** Model size; the active route maps it to that provider's model. Default "small". */
140
+ tier?: InferenceTier;
119
141
  maxTokens?: number;
120
142
  timeout?: number;
121
143
  /** JSON schema for structured output — guarantees valid JSON matching the schema */
@@ -129,6 +151,8 @@ interface InferenceOptions {
129
151
  interface InferenceResult {
130
152
  success: boolean;
131
153
  output?: string;
154
+ /** First line of a failed CLI's error output. */
155
+ error?: string;
132
156
  usage?: { inputTokens: number; outputTokens: number };
133
157
  }
134
158
 
@@ -152,7 +176,7 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
152
176
  if (bin) {
153
177
  logDebug(
154
178
  "inference",
155
- `${tag} route=claude-spawn agent=${agent} model=${opts.model ?? HAIKU_MODEL}`
179
+ `${tag} route=claude-spawn agent=${agent} model=${modelFor("claude-spawn", opts)}`
156
180
  );
157
181
  return inferenceViaClaudeSpawn(bin, opts);
158
182
  }
@@ -269,7 +293,7 @@ export function buildClaudeArgs(
269
293
  const args = [
270
294
  "--print",
271
295
  "--model",
272
- opts.model ?? HAIKU_MODEL,
296
+ modelFor("claude-spawn", opts),
273
297
  "--tools",
274
298
  "",
275
299
  "--output-format",
@@ -281,6 +305,27 @@ export function buildClaudeArgs(
281
305
  return args;
282
306
  }
283
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
+
284
329
  /**
285
330
  * Claude keeps a real system prompt, unlike the other agents, so the system text
286
331
  * reaches it through --system-prompt-file rather than --system-prompt. Only the
@@ -294,16 +339,22 @@ async function inferenceViaClaudeSpawn(
294
339
  bin: string,
295
340
  opts: InferenceOptions
296
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
+ });
297
348
  const system = opts.jsonSchema
298
349
  ? injectJsonSchemaInstruction(opts.system ?? "", opts.jsonSchema)
299
350
  : opts.system;
300
- if (!system) return inferenceViaCliSpawn(bin, buildClaudeArgs(opts), opts.user, opts);
351
+ if (!system) return spawnClaude(buildClaudeArgs(opts));
301
352
 
302
353
  const dir = await mkdtemp(join(tmpdir(), "pal-system-"));
303
354
  try {
304
355
  const file = join(dir, "system-prompt.md");
305
356
  await writeFile(file, system, "utf-8");
306
- return await inferenceViaCliSpawn(bin, buildClaudeArgs(opts, file), opts.user, opts);
357
+ return await spawnClaude(buildClaudeArgs(opts, file));
307
358
  } finally {
308
359
  await rm(dir, { recursive: true, force: true });
309
360
  }
@@ -327,9 +378,11 @@ async function inferenceViaClaudeSpawn(
327
378
  * given, so this costs nothing on POSIX and is the only thing that works on
328
379
  * Windows. Do not move the prompt back into argv.
329
380
  */
330
- export function buildCodexArgs(_opts: InferenceOptions): string[] {
381
+ export function buildCodexArgs(opts: InferenceOptions): string[] {
331
382
  return [
332
383
  "exec",
384
+ "-m",
385
+ modelFor("codex-spawn", opts),
333
386
  "--color",
334
387
  "never",
335
388
  "--skip-git-repo-check",
@@ -357,8 +410,9 @@ export function buildCodexArgs(_opts: InferenceOptions): string[] {
357
410
  * element does not survive cmd.exe on Windows. Provider/model is left unset so
358
411
  * opencode uses the user's configured default.
359
412
  */
360
- export function buildOpencodeArgs(_opts: InferenceOptions): string[] {
361
- return ["run", "--pure", "--format", "json"];
413
+ export function buildOpencodeArgs(opts: InferenceOptions): string[] {
414
+ const model = opencodeTierModel(opts.tier ?? "small");
415
+ return ["run", "--pure", "--format", "json", ...(model ? ["-m", model] : [])];
362
416
  }
363
417
 
364
418
  // Without its own AGENTS.md, opencode falls back to ~/.claude/CLAUDE.md, and it
@@ -520,6 +574,13 @@ export function parseJsonFromOutput(output: string): unknown | null {
520
574
  return null;
521
575
  }
522
576
 
577
+ function firstLine(text: string): string | undefined {
578
+ return text
579
+ .split("\n")
580
+ .find((line) => line.trim())
581
+ ?.trim();
582
+ }
583
+
523
584
  interface RawSpawnResult {
524
585
  code: number | null;
525
586
  stdout: string;
@@ -620,7 +681,8 @@ async function singleCliAttempt(
620
681
 
621
682
  interface SpawnPlace {
622
683
  cwd?: string;
623
- env?: Record<string, string>;
684
+ env?: Record<string, string | undefined>;
685
+ logTag?: string;
624
686
  }
625
687
 
626
688
  /**
@@ -637,11 +699,13 @@ async function inferenceViaCliSpawn(
637
699
  place: SpawnPlace = {}
638
700
  ): Promise<InferenceResult> {
639
701
  const timeout = opts.timeout ?? 15000;
640
- const env = { ...buildSpawnGuardEnv(process.env), ...place.env };
702
+ const env = { ...buildSpawnGuardEnv(withPalEnv(process.env)), ...place.env };
641
703
  const started = Date.now();
642
704
  const caller = opts.caller ?? "anonymous";
643
705
  const session = opts.sessionId ?? "-";
644
- const tag = `caller=${caller} sessionId=${session}`;
706
+ const tag = [`caller=${caller}`, `sessionId=${session}`, place.logTag]
707
+ .filter(Boolean)
708
+ .join(" ");
645
709
  // Friendly name for logs — strip path + extension so cross-platform diffs
646
710
  // (e.g. C:\…\claude.cmd vs /usr/local/bin/claude) read the same in debug.log.
647
711
  const binaryName = basename(binary).replace(/\.(cmd|bat|exe|com)$/i, "");
@@ -685,7 +749,7 @@ async function inferenceViaCliSpawn(
685
749
  "inference:spawn",
686
750
  `${tag} exited=${attempt.code} binary=${binaryName} argv=${JSON.stringify(args)} stderr(${attempt.stderr.length})=${attempt.stderr.slice(0, 300)} stdout(${attempt.stdout.length})=${attempt.stdout.slice(0, 300)}`
687
751
  );
688
- return finish({ success: false });
752
+ return finish({ success: false, error: firstLine(attempt.stderr || attempt.stdout) });
689
753
  }
690
754
  const rawText = attempt.stdout.trim();
691
755
  if (!rawText) return finish({ success: false });
@@ -713,24 +777,17 @@ async function inferenceViaCliSpawn(
713
777
  // ─────────────────────────────────────────────────────────────────────────────
714
778
 
715
779
  async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult> {
716
- const apiKey = process.env.PAL_ANTHROPIC_API_KEY;
780
+ const apiKey = withPalEnv().PAL_ANTHROPIC_API_KEY;
717
781
  if (!apiKey) return { success: false };
718
782
 
719
- const {
720
- system,
721
- user,
722
- model = HAIKU_MODEL,
723
- maxTokens = 200,
724
- timeout = 5000,
725
- jsonSchema,
726
- } = opts;
783
+ const { system, user, maxTokens = 200, timeout = 5000, jsonSchema } = opts;
727
784
 
728
785
  try {
729
786
  const controller = new AbortController();
730
787
  const timer = setTimeout(() => controller.abort(), timeout);
731
788
 
732
789
  const body: Record<string, unknown> = {
733
- model,
790
+ model: modelFor("anthropic-api", opts),
734
791
  max_tokens: maxTokens,
735
792
  messages: [{ role: "user", content: user }],
736
793
  };
@@ -787,20 +844,11 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
787
844
  // structured-output schema for JSON-mode callers.
788
845
  // ─────────────────────────────────────────────────────────────────────────────
789
846
 
790
- const OPENAI_DEFAULT_MODEL = "gpt-5.4-mini";
791
-
792
847
  async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceResult> {
793
- const apiKey = process.env.PAL_OPENAI_API_KEY;
848
+ const apiKey = withPalEnv().PAL_OPENAI_API_KEY;
794
849
  if (!apiKey) return { success: false };
795
850
 
796
- const {
797
- system,
798
- user,
799
- model = OPENAI_DEFAULT_MODEL,
800
- maxTokens = 500,
801
- timeout = 15000,
802
- jsonSchema,
803
- } = opts;
851
+ const { system, user, maxTokens = 500, timeout = 15000, jsonSchema } = opts;
804
852
 
805
853
  try {
806
854
  const controller = new AbortController();
@@ -811,7 +859,7 @@ async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceR
811
859
  messages.push({ role: "user", content: user });
812
860
 
813
861
  const body: Record<string, unknown> = {
814
- model,
862
+ model: modelFor("openai-api", opts),
815
863
  max_tokens: maxTokens,
816
864
  messages,
817
865
  };
@@ -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 {
@@ -8,6 +8,40 @@ export const HAIKU_MODEL = "claude-haiku-4-5-20251001";
8
8
  export const SONNET_MODEL = "claude-sonnet-5";
9
9
  export const FABLE_MODEL = "claude-fable-5";
10
10
 
11
+ export type InferenceTier = "small" | "medium";
12
+
13
+ export type FixedModelRoute =
14
+ | "claude-spawn"
15
+ | "anthropic-api"
16
+ | "codex-spawn"
17
+ | "openai-api";
18
+
19
+ const ANTHROPIC_MODELS = { small: HAIKU_MODEL, medium: SONNET_MODEL };
20
+ const OPENAI_MODELS = { small: "gpt-6-luna", medium: "gpt-6-sol" };
21
+
22
+ /**
23
+ * opencode's models come from the user's own config (opencodeTierModel). Copilot and
24
+ * Cursor are absent on purpose: which named models they accept depends on the user's
25
+ * plan, and a free plan refuses every one but Auto.
26
+ */
27
+ const INFERENCE_MODELS: Record<FixedModelRoute, Record<InferenceTier, string>> = {
28
+ "claude-spawn": ANTHROPIC_MODELS,
29
+ "anthropic-api": ANTHROPIC_MODELS,
30
+ "codex-spawn": OPENAI_MODELS,
31
+ "openai-api": OPENAI_MODELS,
32
+ };
33
+
34
+ export function isFixedModelRoute(route: string): route is FixedModelRoute {
35
+ return Object.hasOwn(INFERENCE_MODELS, route);
36
+ }
37
+
38
+ export function inferenceModel(
39
+ route: FixedModelRoute,
40
+ tier: InferenceTier = "small"
41
+ ): string {
42
+ return INFERENCE_MODELS[route][tier];
43
+ }
44
+
11
45
  /**
12
46
  * Per-agent flagship model used to AUTHOR new skills (via `create-skill`).
13
47
  *
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import type { InferenceTier } from "./models";
3
4
  import { platform } from "./paths";
4
5
 
5
6
  const GLOBAL_CONFIG_FILES = ["config.json", "opencode.json", "opencode.jsonc"];
@@ -17,19 +18,29 @@ function readConfig(name: string): OpencodeConfig | null {
17
18
  }
18
19
  }
19
20
 
20
- function pinnedModel(config: OpencodeConfig | null): string | null {
21
- return typeof config?.model === "string" && config.model ? config.model : null;
21
+ function pinnedModel(config: OpencodeConfig | null, key = "model"): string | null {
22
+ const value = config?.[key];
23
+ return typeof value === "string" && value ? value : null;
22
24
  }
23
25
 
24
- /** Without a pinned model, `opencode run` falls back to whatever was last picked in the TUI. */
25
- export function opencodeBackgroundModel(): string | null {
26
+ function firstPinned(key: string): string | null {
26
27
  for (const name of GLOBAL_CONFIG_FILES) {
27
- const model = pinnedModel(readConfig(name));
28
+ const model = pinnedModel(readConfig(name), key);
28
29
  if (model) return model;
29
30
  }
30
31
  return null;
31
32
  }
32
33
 
34
+ /** Without a pinned model, `opencode run` falls back to whatever was last picked in the TUI. */
35
+ export function opencodeBackgroundModel(): string | null {
36
+ return firstPinned("model");
37
+ }
38
+
39
+ export function opencodeTierModel(tier: InferenceTier): string | null {
40
+ const small = tier === "small" ? firstPinned("small_model") : null;
41
+ return small ?? opencodeBackgroundModel();
42
+ }
43
+
33
44
  function withoutInstructions(config: OpencodeConfig): OpencodeConfig {
34
45
  const { instructions: _, ...rest } = config;
35
46
  return rest;
@@ -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);