portable-agent-layer 0.85.23 → 0.85.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.23",
3
+ "version": "0.85.24",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { resolve } from "node:path";
2
3
  import { previewInferenceRoute } from "../../hooks/lib/inference";
3
4
  import { opencodeBackgroundModel } from "../../hooks/lib/opencode-config";
@@ -51,8 +52,13 @@ export function apiKeyFindings(env: Env): Finding[] {
51
52
  );
52
53
  }
53
54
 
54
- function routeFinding(): Finding | null {
55
- const preview = previewInferenceRoute();
55
+ type Preview = ReturnType<typeof previewInferenceRoute>;
56
+
57
+ function modelSuffix(preview: Preview): string {
58
+ return preview.model ? ` on ${preview.model}` : "";
59
+ }
60
+
61
+ export function routeFinding(preview: Preview = previewInferenceRoute()): Finding | null {
56
62
  if (preview.route === "disabled") return null;
57
63
  if (preview.route === "none")
58
64
  return failing(
@@ -63,10 +69,13 @@ function routeFinding(): Finding | null {
63
69
  if (preview.route.endsWith("-api"))
64
70
  return warning(
65
71
  "inference.api",
66
- `Background inference goes through ${preview.route} (${preview.reason}) — it bills the API key`,
72
+ `Background inference goes through ${preview.route}${modelSuffix(preview)} (${preview.reason}) — it bills the API key`,
67
73
  { say: "Put the agent's CLI on PATH to use your subscription instead" }
68
74
  );
69
- return passed("inference", `Inference: ${preview.route} (${preview.reason})`);
75
+ return passed(
76
+ "inference",
77
+ `Inference: ${preview.route}${modelSuffix(preview)} (${preview.reason})`
78
+ );
70
79
  }
71
80
 
72
81
  function opencodeModelFinding(): Finding {
@@ -81,11 +90,45 @@ function opencodeModelFinding(): Finding {
81
90
  );
82
91
  }
83
92
 
93
+ function aboutField(about: string, field: string): string | null {
94
+ const line = about.split("\n").find((l) => l.startsWith(`${field} `));
95
+ return line ? line.slice(field.length).trim() || null : null;
96
+ }
97
+
98
+ /** Cursor's free plan refuses every named model, so a pinned one fails all background inference. */
99
+ export function cursorPlanFinding(about: string): Finding | null {
100
+ const model = aboutField(about, "Model");
101
+ const tier = aboutField(about, "Subscription Tier");
102
+ if (!model || !tier) return null;
103
+ if (tier === "Free" && model !== "Auto")
104
+ return warning(
105
+ "cursor.model",
106
+ `Cursor is set to ${model}, but its free plan only runs Auto — background inference fails`,
107
+ { say: "Switch Cursor's model to Auto, or upgrade the plan" }
108
+ );
109
+ return passed("cursor.model", `Cursor model ${model} runs on the ${tier} plan`);
110
+ }
111
+
112
+ function cursorAbout(): string {
113
+ const result = spawnSync("cursor-agent", ["about"], {
114
+ encoding: "utf-8",
115
+ shell: true,
116
+ timeout: 15_000,
117
+ });
118
+ return result.status === 0 ? result.stdout : "";
119
+ }
120
+
121
+ function cursorFindings(): Finding[] {
122
+ const finding = cursorPlanFinding(cursorAbout());
123
+ return finding ? [finding] : [];
124
+ }
125
+
84
126
  export function inferenceFindings(agents: AgentName[]): Finding[] {
85
127
  const route = routeFinding();
86
128
  return [
87
129
  ...(route ? [route] : []),
88
130
  ...(agents.includes("opencode") ? [opencodeModelFinding()] : []),
131
+ ...(agents.includes("cursor") ? cursorFindings() : []),
89
132
  ...leakedEnvFindings(process.env, process.platform),
90
133
  ...apiKeyFindings(process.env),
91
134
  ];
package/src/cli/index.ts CHANGED
@@ -457,7 +457,7 @@ async function probeInference(): Promise<void> {
457
457
  );
458
458
  } else {
459
459
  console.log(
460
- ` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/debug/debug.log${reset}`
460
+ ` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}${r.error ?? "failed — see ~/.pal/debug/debug.log"}${reset}`
461
461
  );
462
462
  }
463
463
  }
@@ -178,7 +178,7 @@ async function guessMissingServes(sessionId?: string): Promise<number> {
178
178
  maxTokens: 700,
179
179
  timeout: 90000,
180
180
  jsonSchema: SERVES_SCHEMA,
181
- model: SONNET_MODEL,
181
+ tier: "medium",
182
182
  caller: "agenda-serves",
183
183
  sessionId,
184
184
  });
@@ -252,7 +252,7 @@ async function refreshGoalLinks(
252
252
  maxTokens: 600,
253
253
  timeout: 90000,
254
254
  jsonSchema: LINKS_SCHEMA,
255
- model: SONNET_MODEL,
255
+ tier: "medium",
256
256
  caller: "agenda-goal-links",
257
257
  sessionId,
258
258
  });
@@ -306,7 +306,7 @@ async function extractWaitingOn(sessionId?: string): Promise<number> {
306
306
  maxTokens: 120,
307
307
  timeout: 60000,
308
308
  jsonSchema: WAITING_SCHEMA,
309
- model: SONNET_MODEL,
309
+ tier: "medium",
310
310
  caller: "agenda-waiting-on",
311
311
  sessionId,
312
312
  });
@@ -352,7 +352,7 @@ async function writeMoves(sessionId?: string): Promise<boolean> {
352
352
  maxTokens: 400,
353
353
  timeout: 90000,
354
354
  jsonSchema: MOVES_SCHEMA,
355
- model: SONNET_MODEL,
355
+ tier: "medium",
356
356
  caller: "agenda-moves",
357
357
  sessionId,
358
358
  });
@@ -32,8 +32,13 @@ 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";
37
42
  import { buildSpawnGuardEnv, getInferenceDepth, SPAWN_GUARD_ENV } from "./spawn-guard";
38
43
  import { findBinaryOnPath } from "./which";
39
44
 
@@ -45,24 +50,39 @@ export function hasOpenAiKey(): boolean {
45
50
  return !!process.env.PAL_OPENAI_API_KEY;
46
51
  }
47
52
 
53
+ type InferenceRoute =
54
+ | FixedModelRoute
55
+ | "opencode-spawn"
56
+ | "copilot-spawn"
57
+ | "cursor-spawn"
58
+ | "disabled"
59
+ | "none";
60
+
61
+ interface RoutePreview {
62
+ agent: string;
63
+ route: InferenceRoute;
64
+ reason: string;
65
+ }
66
+
67
+ function modelFor(route: FixedModelRoute, opts: InferenceOptions): string {
68
+ return inferenceModel(route, opts.tier);
69
+ }
70
+
71
+ function smallModelOf(route: InferenceRoute): string | undefined {
72
+ if (route === "opencode-spawn") return opencodeTierModel("small") ?? undefined;
73
+ return isFixedModelRoute(route) ? inferenceModel(route, "small") : undefined;
74
+ }
75
+
48
76
  /**
49
77
  * Preview what `inference()` would do RIGHT NOW given current env + binaries.
50
78
  * Pure diagnostic — never spawns or fetches. Used by `pal cli doctor`.
51
79
  */
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
- } {
80
+ export function previewInferenceRoute(): RoutePreview & { model?: string } {
81
+ const preview = previewRoute();
82
+ return { ...preview, model: smallModelOf(preview.route) };
83
+ }
84
+
85
+ function previewRoute(): RoutePreview {
66
86
  const agent = getActiveAgent();
67
87
  if (process.env.PAL_INFERENCE_DISABLED === "1") {
68
88
  return {
@@ -115,7 +135,8 @@ export function canInfer(): boolean {
115
135
  interface InferenceOptions {
116
136
  system?: string;
117
137
  user: string;
118
- model?: string;
138
+ /** Model size; the active route maps it to that provider's model. Default "small". */
139
+ tier?: InferenceTier;
119
140
  maxTokens?: number;
120
141
  timeout?: number;
121
142
  /** JSON schema for structured output — guarantees valid JSON matching the schema */
@@ -129,6 +150,8 @@ interface InferenceOptions {
129
150
  interface InferenceResult {
130
151
  success: boolean;
131
152
  output?: string;
153
+ /** First line of a failed CLI's error output. */
154
+ error?: string;
132
155
  usage?: { inputTokens: number; outputTokens: number };
133
156
  }
134
157
 
@@ -152,7 +175,7 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
152
175
  if (bin) {
153
176
  logDebug(
154
177
  "inference",
155
- `${tag} route=claude-spawn agent=${agent} model=${opts.model ?? HAIKU_MODEL}`
178
+ `${tag} route=claude-spawn agent=${agent} model=${modelFor("claude-spawn", opts)}`
156
179
  );
157
180
  return inferenceViaClaudeSpawn(bin, opts);
158
181
  }
@@ -269,7 +292,7 @@ export function buildClaudeArgs(
269
292
  const args = [
270
293
  "--print",
271
294
  "--model",
272
- opts.model ?? HAIKU_MODEL,
295
+ modelFor("claude-spawn", opts),
273
296
  "--tools",
274
297
  "",
275
298
  "--output-format",
@@ -327,9 +350,11 @@ async function inferenceViaClaudeSpawn(
327
350
  * given, so this costs nothing on POSIX and is the only thing that works on
328
351
  * Windows. Do not move the prompt back into argv.
329
352
  */
330
- export function buildCodexArgs(_opts: InferenceOptions): string[] {
353
+ export function buildCodexArgs(opts: InferenceOptions): string[] {
331
354
  return [
332
355
  "exec",
356
+ "-m",
357
+ modelFor("codex-spawn", opts),
333
358
  "--color",
334
359
  "never",
335
360
  "--skip-git-repo-check",
@@ -357,8 +382,9 @@ export function buildCodexArgs(_opts: InferenceOptions): string[] {
357
382
  * element does not survive cmd.exe on Windows. Provider/model is left unset so
358
383
  * opencode uses the user's configured default.
359
384
  */
360
- export function buildOpencodeArgs(_opts: InferenceOptions): string[] {
361
- return ["run", "--pure", "--format", "json"];
385
+ export function buildOpencodeArgs(opts: InferenceOptions): string[] {
386
+ const model = opencodeTierModel(opts.tier ?? "small");
387
+ return ["run", "--pure", "--format", "json", ...(model ? ["-m", model] : [])];
362
388
  }
363
389
 
364
390
  // Without its own AGENTS.md, opencode falls back to ~/.claude/CLAUDE.md, and it
@@ -520,6 +546,13 @@ export function parseJsonFromOutput(output: string): unknown | null {
520
546
  return null;
521
547
  }
522
548
 
549
+ function firstLine(text: string): string | undefined {
550
+ return text
551
+ .split("\n")
552
+ .find((line) => line.trim())
553
+ ?.trim();
554
+ }
555
+
523
556
  interface RawSpawnResult {
524
557
  code: number | null;
525
558
  stdout: string;
@@ -685,7 +718,7 @@ async function inferenceViaCliSpawn(
685
718
  "inference:spawn",
686
719
  `${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
720
  );
688
- return finish({ success: false });
721
+ return finish({ success: false, error: firstLine(attempt.stderr || attempt.stdout) });
689
722
  }
690
723
  const rawText = attempt.stdout.trim();
691
724
  if (!rawText) return finish({ success: false });
@@ -716,21 +749,14 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
716
749
  const apiKey = process.env.PAL_ANTHROPIC_API_KEY;
717
750
  if (!apiKey) return { success: false };
718
751
 
719
- const {
720
- system,
721
- user,
722
- model = HAIKU_MODEL,
723
- maxTokens = 200,
724
- timeout = 5000,
725
- jsonSchema,
726
- } = opts;
752
+ const { system, user, maxTokens = 200, timeout = 5000, jsonSchema } = opts;
727
753
 
728
754
  try {
729
755
  const controller = new AbortController();
730
756
  const timer = setTimeout(() => controller.abort(), timeout);
731
757
 
732
758
  const body: Record<string, unknown> = {
733
- model,
759
+ model: modelFor("anthropic-api", opts),
734
760
  max_tokens: maxTokens,
735
761
  messages: [{ role: "user", content: user }],
736
762
  };
@@ -787,20 +813,11 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
787
813
  // structured-output schema for JSON-mode callers.
788
814
  // ─────────────────────────────────────────────────────────────────────────────
789
815
 
790
- const OPENAI_DEFAULT_MODEL = "gpt-5.4-mini";
791
-
792
816
  async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceResult> {
793
817
  const apiKey = process.env.PAL_OPENAI_API_KEY;
794
818
  if (!apiKey) return { success: false };
795
819
 
796
- const {
797
- system,
798
- user,
799
- model = OPENAI_DEFAULT_MODEL,
800
- maxTokens = 500,
801
- timeout = 15000,
802
- jsonSchema,
803
- } = opts;
820
+ const { system, user, maxTokens = 500, timeout = 15000, jsonSchema } = opts;
804
821
 
805
822
  try {
806
823
  const controller = new AbortController();
@@ -811,7 +828,7 @@ async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceR
811
828
  messages.push({ role: "user", content: user });
812
829
 
813
830
  const body: Record<string, unknown> = {
814
- model,
831
+ model: modelFor("openai-api", opts),
815
832
  max_tokens: maxTokens,
816
833
  messages,
817
834
  };
@@ -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;
@@ -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",