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
package/README.md CHANGED
@@ -137,6 +137,12 @@ PAL routes inference through the host agent's subscription CLI by default. API k
137
137
  | `PAL_GEMINI_API_KEY` | For YouTube video analysis and web search skill |
138
138
  | `PAL_XAI_API_KEY` | For Grok real-time research skill (X/web search) |
139
139
  | `PAL_PERPLEXITY_API_KEY` | For Perplexity deep research skill |
140
+ | `PAL_FYZZ_API_KEY` | For the Fyzz Chat skill |
141
+ | `PAL_FYZZ_BASE_URL` | Fyzz Chat server for that skill (default: `http://localhost:3000`) |
142
+
143
+ ### `~/.pal/.env`
144
+
145
+ Agents launched from a desktop app never read your shell profile, so the hooks they spawn cannot see variables exported there. PAL's background inference and the skills shipped with PAL also read `~/.pal/.env`, one `KEY=value` per line (`export KEY=value` works too); a variable already set in the environment wins. Put `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`) and the API keys above there; `pal cli doctor` warns about any of the API keys above still set in your shell. Personal skills made with `create-skill` never receive the file. It stays on this machine: `pal cli export` never includes it.
140
146
 
141
147
  ### Path overrides
142
148
 
@@ -225,7 +231,7 @@ Your setup should be able to travel with you.
225
231
  ## Features
226
232
 
227
233
  - **Cross-platform**: works on Windows, macOS, and Linux
228
- - **Cross-agent**: full support for Claude Code, opencode, Cursor, GitHub Copilot, and Codex (Codex still lacks subagents)
234
+ - **Cross-agent**: full support for Claude Code, opencode, Cursor, GitHub Copilot, and Codex
229
235
  - **Subscription-first inference**: background inference routes through whichever subscription CLI is active — no API key needed by default
230
236
  - **Portable knowledge**: export and import accumulated knowledge
231
237
  - **A morning screen, not a dashboard**: `pal cli server` ranks your projects *and* your written goals in one urgent/important grid. Urgency is read off the files — blockers, an unfinished handoff, a date in a next step, an important thing gone quiet. Importance comes from one fact per project: whether it serves a goal you wrote down, is a way the work could pay, or is kept for its own sake. PAL guesses that once at session stop; you correct it on the page or with `project.ts serves <name> goal|revenue|fun`, and the correction survives every later guess
@@ -29,7 +29,7 @@ pal cli skill run deep-research gemini-search -- "<query>"
29
29
  ```
30
30
 
31
31
  - If it returns results → **continue using Gemini Search** for remaining queries
32
- - If it errors about `PAL_GEMINI_API_KEY` → **fall back to WebSearch/WebFetch** for all queries using the fallback methodology below
32
+ - If it errors about `PAL_GEMINI_API_KEY` → **fall back to WebSearch/WebFetch** for all queries using the fallback methodology below, and **set a flag** to include the fallback footnote in your output
33
33
 
34
34
  The tool has a built-in academic system prompt that prioritizes scholarly sources, but you should still craft queries to target academic content:
35
35
  - Include author names, paper titles, or venue names when known
@@ -83,5 +83,5 @@ If you fell back to WebSearch because the Gemini API was unavailable, you MUST a
83
83
 
84
84
  ```markdown
85
85
  ---
86
- > **Note:** This research used WebSearch fallback instead of Gemini Search. The `PAL_GEMINI_API_KEY` environment variable is not set. To enable Gemini-grounded search, set the key: `export PAL_GEMINI_API_KEY=...` (get one at https://aistudio.google.com/apikey)
86
+ > **Tell the user:** This research used WebSearch fallback instead of Gemini Search. The `PAL_GEMINI_API_KEY` environment variable is not set. To enable Gemini-grounded search, add `PAL_GEMINI_API_KEY=...` to `~/.pal/.env` (get one at https://aistudio.google.com/apikey)
87
87
  ```
@@ -52,7 +52,7 @@ If the grok-search tool fails (missing `PAL_XAI_API_KEY` or API error), fall bac
52
52
  1. **Search** using WebSearch with time-sensitive queries — prepend "2026" or "latest" or "today" to queries
53
53
  2. **Prioritize** news sources, social media aggregators, and live blogs
54
54
  3. **Fetch** the most recent results with WebFetch to extract detail
55
- 4. **Note** in your output that you used the fallback path (no Grok API access)
55
+ 4. **Set a flag** to include the fallback footnote in your output
56
56
 
57
57
  ## Methodology
58
58
 
@@ -105,5 +105,5 @@ If you fell back to WebSearch because the Grok API was unavailable, you MUST app
105
105
 
106
106
  ```markdown
107
107
  ---
108
- > **Note:** This research used WebSearch fallback instead of Grok Search. The `PAL_XAI_API_KEY` environment variable is not set. To enable Grok real-time search, set the key: `export PAL_XAI_API_KEY=...` (get one at https://console.x.ai/)
108
+ > **Tell the user:** This research used WebSearch fallback instead of Grok Search. The `PAL_XAI_API_KEY` environment variable is not set. To enable Grok real-time search, add `PAL_XAI_API_KEY=...` to `~/.pal/.env` (get one at https://console.x.ai/)
109
109
  ```
@@ -77,5 +77,5 @@ If you fell back to WebSearch because the Perplexity API was unavailable, you MU
77
77
 
78
78
  ```markdown
79
79
  ---
80
- > **Note:** This research used WebSearch fallback instead of Perplexity Search. The `PAL_PERPLEXITY_API_KEY` environment variable is not set. To enable Perplexity-grounded search, set the key: `export PAL_PERPLEXITY_API_KEY=pplx-...` (get one at https://www.perplexity.ai/settings/api)
80
+ > **Tell the user:** This research used WebSearch fallback instead of Perplexity Search. The `PAL_PERPLEXITY_API_KEY` environment variable is not set. To enable Perplexity-grounded search, add `PAL_PERPLEXITY_API_KEY=pplx-...` to `~/.pal/.env` (get one at https://www.perplexity.ai/settings/api)
81
81
  ```
@@ -29,7 +29,7 @@ You author a single new personal subagent for this user. You are handed a **suba
29
29
  cat ~/.pal/skills/create-subagent/authoring-guide.md
30
30
  ```
31
31
  2. Validate the name: lowercase-kebab, no spaces. It must not collide with an existing personal subagent (`pal cli subagent list`) or a shipped one. If it collides or is malformed, stop and report the conflict instead of overwriting.
32
- 3. Write `~/.pal/agents/<name>.md` (create `~/.pal/agents/` if missing), populated per the guide's merged schema: global `name` + `description`, then only the platform blocks that matter (`claude:`, `opencode:`, `cursor:`, `copilot:`), then the system prompt body. Remember: `skills:` is Claude-only and preloads — for other agents, name intended skills in the body.
32
+ 3. Write `~/.pal/agents/<name>.md` (create `~/.pal/agents/` if missing), populated per the guide's merged schema: global `name` + `description`, then only the platform blocks that matter (`claude:`, `opencode:`, `cursor:`, `copilot:`, `codex:`), then the system prompt body. Remember: `skills:` is Claude-only and preloads — for other agents, name intended skills in the body.
33
33
  4. Install it into every installed agent:
34
34
  ```bash
35
35
  pal cli subagent link <name>
@@ -44,4 +44,4 @@ Follow the user's request. Common tasks:
44
44
  - For long videos, consider asking a focused question via `--prompt` rather than a full analysis
45
45
  - Gemini sees both visuals and audio — mention on-screen content (slides, code, diagrams) when relevant
46
46
  - Quote speakers verbatim when the user asks about specific statements
47
- - If the tool reports a missing API key, tell the user to get one at https://aistudio.google.com/apikey and set `PAL_GEMINI_API_KEY` in their shell profile or PAL settings
47
+ - If the tool reports a missing API key, tell the user to get one at https://aistudio.google.com/apikey and add `PAL_GEMINI_API_KEY=...` to `~/.pal/.env`
@@ -29,6 +29,7 @@ function loadApiKey(): string {
29
29
  if (!key) {
30
30
  console.error("Error: PAL_GEMINI_API_KEY environment variable is not set.");
31
31
  console.error("Get a free key at https://aistudio.google.com/apikey");
32
+ console.error("and add it to ~/.pal/.env as PAL_GEMINI_API_KEY=...");
32
33
  process.exit(1);
33
34
  }
34
35
  return key;
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: create-subagent
3
3
  license: MIT
4
- description: "Create a user-scoped subagent for every installed agent (Claude Code, opencode, Cursor, Copilot) from one merged definition, then install and run the doctor on it. Use when the user asks to create a subagent, add a custom agent, delegate a role to a specialized agent, or \"make a subagent that…\"."
4
+ description: "Create a user-scoped subagent for every installed agent (Claude Code, opencode, Cursor, Copilot, Codex) from one merged definition, then install and run the doctor on it. Use when the user asks to create a subagent, add a custom agent, delegate a role to a specialized agent, or \"make a subagent that…\"."
5
5
  argument-hint: <subagent name> <what it does + when to delegate>
6
6
  metadata:
7
7
  source: portable-agent-layer
@@ -38,7 +38,7 @@ cat ~/.pal/skills/create-subagent/authoring-guide.md
38
38
  cannot reasonably infer, then confirm your draft:
39
39
  - **model** — a flagship model or `inherit` the caller's model (can differ per agent).
40
40
  - **tools** — which tools it may use (a comma list for Claude/Copilot; a permission
41
- map for opencode). Default: inherit all tools.
41
+ map for opencode; a `sandbox_mode` for Codex). Default: inherit all tools.
42
42
  - **skills** — skills to PRELOAD. Claude Code only; the other agents have no such
43
43
  field (see the guide). Record intended skills in the body for the others.
44
44
  - **system prompt** — the role and behavior. Draft it and confirm with the user.
@@ -18,7 +18,9 @@ A personal subagent is stored once as `~/.pal/agents/<name>.md` with **merged
18
18
  multi-platform frontmatter**. On `pal cli subagent link <name>`, PAL splits it per
19
19
  platform and writes a native file into each installed agent's agents directory. Global
20
20
  fields (`name`, `description`) always ship; each platform block is un-indented into that
21
- agent's frontmatter and the other blocks are stripped.
21
+ agent's frontmatter and the other blocks are stripped. Codex is the exception in format
22
+ only: it gets `~/.codex/agents/<name>.toml`, with the body as `developer_instructions`
23
+ and the `codex:` block's keys at the top level.
22
24
 
23
25
  ```markdown
24
26
  ---
@@ -44,6 +46,10 @@ cursor:
44
46
  copilot:
45
47
  model: inherit
46
48
  tools: read, edit # comma list
49
+ codex:
50
+ model: gpt-6-sol # omit to inherit the parent session's model
51
+ model_reasoning_effort: high
52
+ sandbox_mode: read-only # read-only | workspace-write | danger-full-access
47
53
  ---
48
54
 
49
55
  <system prompt — second-person instructions to the subagent>
@@ -67,6 +73,12 @@ with just `name` + `description` (a valid, minimal subagent).
67
73
  field — emitting one there is ignored. If a subagent should lean on specific skills on
68
74
  those agents, name them in the system prompt body instead.
69
75
  - **cursor** — `readonly` and `is_background` are booleans (`true`/`false`).
76
+ - **codex** — every key is optional and inherits from the parent session when omitted.
77
+ Values must be strings, numbers, booleans or lists of strings. Codex has **no** `tools`
78
+ allow-list or `skills` list (both are config tables there, and a list makes Codex skip
79
+ the agent); restrict access with `sandbox_mode` and name skills in the body. The
80
+ global frontmatter may hold only `name` and `description`, because nothing else maps
81
+ to a Codex key.
70
82
 
71
83
  ## What makes a good subagent
72
84
 
@@ -92,6 +92,7 @@ After collecting agent results, synthesize into:
92
92
  4. **Conflicts** — where agents disagree, with both sides presented
93
93
  5. **Gaps** — what remains unknown or needs further investigation
94
94
  6. **Sources** — deduplicated list of verified URLs from all agents
95
+ 7. **Missing API keys** — only when a researcher ended with a WebSearch fallback footnote: tell the user which researcher ran without its API, which key it needs, where to get one, and to add it to `~/.pal/.env`. One line per missing key, never dropped from the synthesis
95
96
 
96
97
  Keep total output under 1500 words unless the user asks for more.
97
98
 
@@ -53,6 +53,7 @@ function loadApiKey(): string {
53
53
  if (!key) {
54
54
  console.error("Error: PAL_GEMINI_API_KEY environment variable is not set.");
55
55
  console.error("Get an API key at https://aistudio.google.com/apikey");
56
+ console.error("and add it to ~/.pal/.env as PAL_GEMINI_API_KEY=...");
56
57
  process.exit(1);
57
58
  }
58
59
  return key;
@@ -49,6 +49,7 @@ function loadApiKey(): string {
49
49
  if (!key) {
50
50
  console.error("Error: PAL_XAI_API_KEY environment variable is not set.");
51
51
  console.error("Get an API key at https://console.x.ai/");
52
+ console.error("and add it to ~/.pal/.env as PAL_XAI_API_KEY=...");
52
53
  process.exit(1);
53
54
  }
54
55
  return key;
@@ -39,7 +39,7 @@ function loadApiKey(): string {
39
39
  "Error: PAL_PERPLEXITY_API_KEY environment variable is not set.\n" +
40
40
  "The Perplexity API could not be reached. The researcher agent should fall back to WebSearch.\n" +
41
41
  "To enable Perplexity search, get an API key at https://www.perplexity.ai/settings/api\n" +
42
- "and set it: export PAL_PERPLEXITY_API_KEY=pplx-..."
42
+ "and add it to ~/.pal/.env as PAL_PERPLEXITY_API_KEY=pplx-..."
43
43
  );
44
44
  process.exit(1);
45
45
  }
@@ -41,8 +41,8 @@ pal cli skill run fyzz-chat-api fyzz-api -- projects
41
41
  If the tool reports a missing API key:
42
42
 
43
43
  1. Ask the user to create one in Fyzz Chat → Settings → API Keys
44
- 2. They should set `PAL_FYZZ_API_KEY` in their shell profile or in PAL's `settings.json` env section
45
- 3. Optionally set `PAL_FYZZ_BASE_URL` (defaults to `http://localhost:3000`)
44
+ 2. They should add `PAL_FYZZ_API_KEY=...` to `~/.pal/.env`
45
+ 3. Optionally add `PAL_FYZZ_BASE_URL=...` there too (defaults to `http://localhost:3000`)
46
46
 
47
47
  ## Guidelines
48
48
 
@@ -17,7 +17,7 @@ function loadApiKey(): string {
17
17
  const key = process.env.PAL_FYZZ_API_KEY;
18
18
  if (!key) {
19
19
  console.error("Error: PAL_FYZZ_API_KEY environment variable is not set.");
20
- console.error("Set it in your shell profile or PAL settings.json env section.");
20
+ console.error("Add it to ~/.pal/.env as PAL_FYZZ_API_KEY=...");
21
21
  process.exit(1);
22
22
  }
23
23
  return key;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.23",
3
+ "version": "0.86.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { isAbsolute, resolve } from "node:path";
4
+ import type { AgentPlatform } from "../../hooks/lib/agent-definition";
4
5
  import { palPkg, platform } from "../../hooks/lib/paths";
6
+ import { nativeAgentsDir, staleShippedAgents } from "../../targets/lib";
5
7
  import { NO_SESSION_AGENT_MESSAGE } from "../session-agent";
6
8
  import { type Finding, type Fix, failing, optional, passed, warning } from "./finding";
7
9
 
@@ -274,6 +276,18 @@ function opencodePluginFindings(): Finding[] {
274
276
  return [passed("opencode.hooks", "opencode plugin installed and current")];
275
277
  }
276
278
 
279
+ function subagentFinding(agent: AgentPlatform): Finding {
280
+ const label = LAYOUT[agent].label;
281
+ const stale = staleShippedAgents(nativeAgentsDir(agent), agent);
282
+ if (stale.length === 0)
283
+ return passed(`${agent}.subagents`, `${label}: subagents match this PAL version`);
284
+ return warning(
285
+ `${agent}.subagents`,
286
+ `${label}: installed subagents differ from this PAL version — ${stale.join(", ")}`,
287
+ reinstall(agent)
288
+ );
289
+ }
290
+
277
291
  function countSkills(dir: string): number {
278
292
  if (!existsSync(dir)) return 0;
279
293
  return readdirSync(dir).filter((f) => existsSync(resolve(dir, f, "SKILL.md"))).length;
@@ -290,7 +304,8 @@ function oneAgentFindings(agent: AgentName): Finding[] {
290
304
  findings.push(
291
305
  ...(layout.hookFile
292
306
  ? hookFindings(agent, layout.hookFile())
293
- : opencodePluginFindings())
307
+ : opencodePluginFindings()),
308
+ subagentFinding(agent)
294
309
  );
295
310
  if (layout.instructions) {
296
311
  const { file, name } = layout.instructions;
@@ -1,6 +1,8 @@
1
1
  import { resolve } from "node:path";
2
2
  import { cachedStatus, type UpdateCache } from "../../hooks/handlers/update-check";
3
+ import { loggedClaudeAuthMode } from "../../hooks/lib/inference";
3
4
  import { type HookErrorGroup, recentHookErrors } from "../../hooks/lib/log";
5
+ import { palEnvPath } from "../../hooks/lib/pal-env";
4
6
  import { palHome } from "../../hooks/lib/paths";
5
7
  import { checkPendingMigrations } from "../migrate";
6
8
  import { type Finding, failing, passed, warning } from "./finding";
@@ -13,7 +15,6 @@ interface PendingMigration {
13
15
 
14
16
  interface ErrorContext {
15
17
  now: number;
16
- env: Record<string, string | undefined>;
17
18
  }
18
19
 
19
20
  const CLAUDE_LOGIN_EXPIRED = /Failed to authenticate/;
@@ -29,22 +30,22 @@ function claudeLoginExpiredFinding(
29
30
  context: ErrorContext
30
31
  ): Finding {
31
32
  const title = `Background calls to Claude could not log in — ${group.count} failed in the last 24h, last ${ago(group.lastAt, context.now)}: ${group.last}`;
32
- if (context.env.CLAUDE_CODE_OAUTH_TOKEN)
33
+ if (loggedClaudeAuthMode(group.lastMessage ?? "") === "token")
33
34
  return warning(`hook-errors.${group.source}`, title, {
34
- say: "CLAUDE_CODE_OAUTH_TOKEN is set — if these failures predate it, they clear 24h after the last one; if not, the token is invalid, so create a new one",
35
+ say: `CLAUDE_CODE_OAUTH_TOKEN was rejected — it expired or is invalid. Create a new one and replace it in ${palEnvPath()}`,
35
36
  command: "claude setup-token",
36
37
  external: true,
37
38
  });
38
39
  return failing(`hook-errors.${group.source}`, title, {
39
- say: "Create a year-long token, then export it as CLAUDE_CODE_OAUTH_TOKEN in your shell profile",
40
- command: "claude setup-token",
40
+ say: "Log the Claude CLI in again",
41
+ command: "claude auth login",
41
42
  external: true,
42
43
  });
43
44
  }
44
45
 
45
46
  export function hookErrorFindings(
46
47
  groups: HookErrorGroup[],
47
- context: ErrorContext = { now: Date.now(), env: process.env }
48
+ context: ErrorContext = { now: Date.now() }
48
49
  ): Finding[] {
49
50
  if (groups.length === 0)
50
51
  return [passed("hook-errors", "No hook errors in the last 24h")];
@@ -1,6 +1,8 @@
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";
5
+ import { palEnvPath, readPalEnvFile } from "../../hooks/lib/pal-env";
4
6
  import { platform } from "../../hooks/lib/paths";
5
7
  import type { AgentName } from "./agents";
6
8
  import { type Finding, failing, optional, passed, warning } from "./finding";
@@ -13,14 +15,19 @@ const SUBPROCESS_ONLY = {
13
15
  PAL_INFERENCE_DISABLED: "every inference call fails",
14
16
  } as const;
15
17
 
16
- const API_KEYS = {
18
+ const INFERENCE_KEYS = {
17
19
  PAL_ANTHROPIC_API_KEY: "fallback when the claude CLI cannot answer",
18
20
  PAL_OPENAI_API_KEY: "fallback when the codex CLI cannot answer",
19
- PAL_GEMINI_API_KEY: "YouTube analysis and Gemini research",
20
- PAL_XAI_API_KEY: "the Grok researcher",
21
- PAL_PERPLEXITY_API_KEY: "the Perplexity researcher",
22
21
  } as const;
23
22
 
23
+ const SKILL_KEYS = [
24
+ "PAL_GEMINI_API_KEY",
25
+ "PAL_XAI_API_KEY",
26
+ "PAL_PERPLEXITY_API_KEY",
27
+ "PAL_FYZZ_API_KEY",
28
+ "PAL_FYZZ_BASE_URL",
29
+ ] as const;
30
+
24
31
  function unsetCommand(name: string, os: NodeJS.Platform): string {
25
32
  return os === "win32" ? `Remove-Item Env:${name}` : `unset ${name}`;
26
33
  }
@@ -41,18 +48,94 @@ export function leakedEnvFindings(env: Env, os: NodeJS.Platform): Finding[] {
41
48
  );
42
49
  }
43
50
 
44
- export function apiKeyFindings(env: Env): Finding[] {
45
- return Object.entries(API_KEYS).map(([name, unlocks]) =>
46
- env[name]
47
- ? passed(`key.${name}`, `${name} set`)
48
- : optional(`key.${name}`, `${name} — ${unlocks}`, {
49
- say: "set it in your shell profile",
50
- })
51
+ function shellKeyConsequence(shellValue: string, fileValue: string | undefined): string {
52
+ if (fileValue === undefined)
53
+ return "so agents launched outside a terminal never see it";
54
+ if (fileValue === shellValue) return `and duplicated in ${palEnvPath()}`;
55
+ return `and overrides the different value in ${palEnvPath()}`;
56
+ }
57
+
58
+ function shellKeyFinding(name: string, shellValue: string, fileValue?: string): Finding {
59
+ return warning(
60
+ `key.${name}`,
61
+ `${name} is set in your shell, ${shellKeyConsequence(shellValue, fileValue)}`,
62
+ {
63
+ say:
64
+ fileValue === undefined
65
+ ? `Move it to ${palEnvPath()} and remove it from your shell profile`
66
+ : "Remove it from your shell profile",
67
+ }
68
+ );
69
+ }
70
+
71
+ function keyFinding(
72
+ name: string,
73
+ shell: Env,
74
+ file: Record<string, string>,
75
+ ifUnset: () => Finding | null
76
+ ): Finding | null {
77
+ const shellValue = shell[name];
78
+ if (shellValue) return shellKeyFinding(name, shellValue, file[name]);
79
+ if (file[name]) return passed(`key.${name}`, `${name} set`);
80
+ return ifUnset();
81
+ }
82
+
83
+ export function apiKeyFindings(
84
+ shell: Env,
85
+ file: Record<string, string> = readPalEnvFile()
86
+ ): Finding[] {
87
+ const inference = Object.entries(INFERENCE_KEYS).map(([name, unlocks]) =>
88
+ keyFinding(name, shell, file, () =>
89
+ optional(`key.${name}`, `${name} — ${unlocks}`, {
90
+ say: `add it to ${palEnvPath()}`,
91
+ })
92
+ )
51
93
  );
94
+ const skills = SKILL_KEYS.map((name) => keyFinding(name, shell, file, () => null));
95
+ return [...inference, ...skills].filter((f): f is Finding => f !== null);
96
+ }
97
+
98
+ const OAUTH_TOKEN = "CLAUDE_CODE_OAUTH_TOKEN";
99
+
100
+ function isMalformedToken(value: string): boolean {
101
+ return value === "" || /[\s"']/.test(value);
102
+ }
103
+
104
+ /** Silent unless a token is set; it is optional, and the native login needs none of this. */
105
+ export function oauthTokenFindings(
106
+ shell: Env,
107
+ file: Record<string, string> = readPalEnvFile()
108
+ ): Finding[] {
109
+ const shellToken = shell[OAUTH_TOKEN];
110
+ const fileToken = file[OAUTH_TOKEN];
111
+ if (shellToken === undefined && fileToken === undefined) return [];
112
+ const findings: Finding[] = [];
113
+ if ([shellToken, fileToken].some((t) => t !== undefined && isMalformedToken(t)))
114
+ findings.push(
115
+ warning("oauth.malformed", `${OAUTH_TOKEN} is empty or contains spaces or quotes`, {
116
+ say: `Paste the token from 'claude setup-token' as a bare value in ${palEnvPath()}`,
117
+ })
118
+ );
119
+ if (shellToken && fileToken && shellToken !== fileToken)
120
+ findings.push(
121
+ warning(
122
+ "oauth.shadowed",
123
+ `Your shell's ${OAUTH_TOKEN} differs from the one in ${palEnvPath()}, and the shell's wins`,
124
+ {
125
+ say: "Keep one copy: drop it from your shell, or have your shell read it from the file",
126
+ }
127
+ )
128
+ );
129
+ return findings.length > 0 ? findings : [passed("oauth", `${OAUTH_TOKEN} set`)];
130
+ }
131
+
132
+ type Preview = ReturnType<typeof previewInferenceRoute>;
133
+
134
+ function modelSuffix(preview: Preview): string {
135
+ return preview.model ? ` on ${preview.model}` : "";
52
136
  }
53
137
 
54
- function routeFinding(): Finding | null {
55
- const preview = previewInferenceRoute();
138
+ export function routeFinding(preview: Preview = previewInferenceRoute()): Finding | null {
56
139
  if (preview.route === "disabled") return null;
57
140
  if (preview.route === "none")
58
141
  return failing(
@@ -63,10 +146,13 @@ function routeFinding(): Finding | null {
63
146
  if (preview.route.endsWith("-api"))
64
147
  return warning(
65
148
  "inference.api",
66
- `Background inference goes through ${preview.route} (${preview.reason}) — it bills the API key`,
149
+ `Background inference goes through ${preview.route}${modelSuffix(preview)} (${preview.reason}) — it bills the API key`,
67
150
  { say: "Put the agent's CLI on PATH to use your subscription instead" }
68
151
  );
69
- return passed("inference", `Inference: ${preview.route} (${preview.reason})`);
152
+ return passed(
153
+ "inference",
154
+ `Inference: ${preview.route}${modelSuffix(preview)} (${preview.reason})`
155
+ );
70
156
  }
71
157
 
72
158
  function opencodeModelFinding(): Finding {
@@ -81,12 +167,47 @@ function opencodeModelFinding(): Finding {
81
167
  );
82
168
  }
83
169
 
170
+ function aboutField(about: string, field: string): string | null {
171
+ const line = about.split("\n").find((l) => l.startsWith(`${field} `));
172
+ return line ? line.slice(field.length).trim() || null : null;
173
+ }
174
+
175
+ /** Cursor's free plan refuses every named model, so a pinned one fails all background inference. */
176
+ export function cursorPlanFinding(about: string): Finding | null {
177
+ const model = aboutField(about, "Model");
178
+ const tier = aboutField(about, "Subscription Tier");
179
+ if (!model || !tier) return null;
180
+ if (tier === "Free" && model !== "Auto")
181
+ return warning(
182
+ "cursor.model",
183
+ `Cursor is set to ${model}, but its free plan only runs Auto — background inference fails`,
184
+ { say: "Switch Cursor's model to Auto, or upgrade the plan" }
185
+ );
186
+ return passed("cursor.model", `Cursor model ${model} runs on the ${tier} plan`);
187
+ }
188
+
189
+ function cursorAbout(): string {
190
+ const result = spawnSync("cursor-agent", ["about"], {
191
+ encoding: "utf-8",
192
+ shell: true,
193
+ timeout: 15_000,
194
+ });
195
+ return result.status === 0 ? result.stdout : "";
196
+ }
197
+
198
+ function cursorFindings(): Finding[] {
199
+ const finding = cursorPlanFinding(cursorAbout());
200
+ return finding ? [finding] : [];
201
+ }
202
+
84
203
  export function inferenceFindings(agents: AgentName[]): Finding[] {
85
204
  const route = routeFinding();
86
205
  return [
87
206
  ...(route ? [route] : []),
88
207
  ...(agents.includes("opencode") ? [opencodeModelFinding()] : []),
208
+ ...(agents.includes("cursor") ? cursorFindings() : []),
89
209
  ...leakedEnvFindings(process.env, process.platform),
90
210
  ...apiKeyFindings(process.env),
211
+ ...oauthTokenFindings(process.env),
91
212
  ];
92
213
  }
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
  }
package/src/cli/skill.ts CHANGED
@@ -15,11 +15,12 @@
15
15
  */
16
16
 
17
17
  import { spawnSync } from "node:child_process";
18
- import { existsSync, readdirSync, statSync } from "node:fs";
18
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
19
19
  import { resolve } from "node:path";
20
20
  import { getActiveAgent } from "../hooks/lib/agent";
21
21
  import { flagshipAuthorModel } from "../hooks/lib/models";
22
- import { palHome } from "../hooks/lib/paths";
22
+ import { withPalEnv } from "../hooks/lib/pal-env";
23
+ import { isInside, palHome, palPkg } from "../hooks/lib/paths";
23
24
  import { linkPersonalSkill, log } from "../targets/lib";
24
25
  import {
25
26
  formatReport,
@@ -93,10 +94,17 @@ function runSkillTool(skill: string, tool: string, toolArgs: string[]): number {
93
94
  }
94
95
  const { status } = spawnSync("bun", [path, ...toolArgs], {
95
96
  stdio: "inherit",
97
+ env: isShippedTool(path) ? withPalEnv(process.env) : process.env,
96
98
  });
97
99
  return status ?? 1;
98
100
  }
99
101
 
102
+ /** Real paths, so a personal skill symlinked from elsewhere never passes as shipped. */
103
+ function isShippedTool(path: string): boolean {
104
+ const shipped = resolve(palPkg(), "assets", "skills");
105
+ return existsSync(shipped) && isInside(realpathSync(shipped), realpathSync(path));
106
+ }
107
+
100
108
  export async function runSkill(args: string[]): Promise<number> {
101
109
  const [sub, name] = args;
102
110
 
@@ -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
  });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The merged multi-platform subagent format shared by assets/agents/*.md and
3
+ * ~/.pal/agents/*.md: global frontmatter lines, one indented block per platform,
4
+ * and a body that becomes the subagent's system prompt.
5
+ */
6
+
7
+ import { splitFrontmatter } from "./frontmatter";
8
+
9
+ export const AGENT_PLATFORMS = [
10
+ "claude",
11
+ "opencode",
12
+ "cursor",
13
+ "copilot",
14
+ "codex",
15
+ ] as const;
16
+ export type AgentPlatform = (typeof AGENT_PLATFORMS)[number];
17
+
18
+ export interface AgentDefinition {
19
+ hasFrontmatter: boolean;
20
+ global: string[];
21
+ platforms: Partial<Record<AgentPlatform, string[]>>;
22
+ body: string;
23
+ }
24
+
25
+ const PLATFORM_BLOCK_HEADER = new RegExp(
26
+ String.raw`^(${AGENT_PLATFORMS.join("|")}):\s*$`
27
+ );
28
+ const BLOCK_INDENT = " ";
29
+
30
+ export function parseAgentDefinition(content: string): AgentDefinition {
31
+ const split = splitFrontmatter(content);
32
+ if (!split) {
33
+ return { hasFrontmatter: false, global: [], platforms: {}, body: content };
34
+ }
35
+
36
+ const global: string[] = [];
37
+ const platforms: Partial<Record<AgentPlatform, string[]>> = {};
38
+ let current: AgentPlatform | null = null;
39
+
40
+ for (const line of split.frontmatter.split("\n")) {
41
+ if (!line.trim()) continue;
42
+ const header = PLATFORM_BLOCK_HEADER.exec(line);
43
+ if (header) {
44
+ current = header[1] as AgentPlatform;
45
+ platforms[current] ??= [];
46
+ continue;
47
+ }
48
+ if (current && line.startsWith(BLOCK_INDENT)) {
49
+ platforms[current]?.push(line.slice(BLOCK_INDENT.length));
50
+ continue;
51
+ }
52
+ current = null;
53
+ global.push(line);
54
+ }
55
+
56
+ return { hasFrontmatter: true, global, platforms, body: split.body };
57
+ }