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.
- package/README.md +7 -1
- package/assets/agents/gemini-researcher.md +2 -2
- package/assets/agents/grok-researcher.md +2 -2
- package/assets/agents/perplexity-researcher.md +1 -1
- package/assets/agents/subagent-author.md +1 -1
- package/assets/skills/analyze-youtube/SKILL.md +1 -1
- package/assets/skills/analyze-youtube/tools/youtube-analyze.ts +1 -0
- package/assets/skills/create-subagent/SKILL.md +2 -2
- package/assets/skills/create-subagent/authoring-guide.md +13 -1
- package/assets/skills/deep-research/SKILL.md +1 -0
- package/assets/skills/deep-research/tools/gemini-search.ts +1 -0
- package/assets/skills/deep-research/tools/grok-search.ts +1 -0
- package/assets/skills/deep-research/tools/perplexity-search.ts +1 -1
- package/assets/skills/fyzz-chat-api/SKILL.md +2 -2
- package/assets/skills/fyzz-chat-api/tools/fyzz-api.ts +1 -1
- package/package.json +1 -1
- package/src/cli/doctor/agents.ts +16 -1
- package/src/cli/doctor/health.ts +7 -6
- package/src/cli/doctor/inference.ts +136 -15
- package/src/cli/index.ts +1 -1
- package/src/cli/skill.ts +10 -2
- package/src/hooks/handlers/agenda.ts +4 -4
- package/src/hooks/lib/agent-definition.ts +57 -0
- package/src/hooks/lib/frontmatter.ts +21 -8
- package/src/hooks/lib/inference.ts +100 -52
- package/src/hooks/lib/log.ts +10 -4
- package/src/hooks/lib/models.ts +34 -0
- package/src/hooks/lib/opencode-config.ts +16 -5
- package/src/hooks/lib/pal-env.ts +51 -0
- package/src/hooks/lib/paths.ts +7 -1
- package/src/targets/agent-render.ts +103 -0
- package/src/targets/codex/install.ts +6 -1
- package/src/targets/codex/uninstall.ts +8 -1
- package/src/targets/lib.ts +72 -89
- package/src/tools/control-room/static.ts +3 -8
- package/src/tools/lib/skill-doctor.ts +4 -4
- package/src/tools/self-model.ts +1 -1
- 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 =
|
|
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
|
|
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 =
|
|
29
|
-
const body =
|
|
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 {
|
|
36
|
-
|
|
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 !!
|
|
47
|
+
return !!withPalEnv().PAL_ANTHROPIC_API_KEY;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export function hasOpenAiKey(): boolean {
|
|
45
|
-
return !!
|
|
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
|
-
|
|
54
|
-
route
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
361
|
-
|
|
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 =
|
|
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 =
|
|
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
|
};
|
package/src/hooks/lib/log.ts
CHANGED
|
@@ -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 =
|
|
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.
|
|
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) =>
|
|
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(
|
|
137
|
+
message: line.replace(ERROR_LINE, ""),
|
|
132
138
|
}));
|
|
133
139
|
return groupBySource(recent);
|
|
134
140
|
} catch {
|
package/src/hooks/lib/models.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -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);
|