portable-agent-layer 0.85.12 → 0.85.14

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 CHANGED
@@ -51,10 +51,10 @@ bun install
51
51
  bun run install:all
52
52
  ```
53
53
 
54
- In repo mode, add an alias to your shell profile:
54
+ Then put `pal` on your PATH from the checkout. An alias is not enough: agents run skills in non-interactive shells, which never load it.
55
55
 
56
56
  ```bash
57
- alias pal="bun run ~/path/to/portable-agent-layer/src/cli/index.ts"
57
+ bun link
58
58
  ```
59
59
 
60
60
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.85.12",
3
+ "version": "0.85.14",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, resolve } from "node:path";
4
+ import { isRepoMode } from "../../hooks/handlers/update-check";
4
5
  import { palPkg } from "../../hooks/lib/paths";
5
6
  import { findBinaryOnPath } from "../../hooks/lib/which";
6
7
  import type { ToolCheck } from "./agents";
@@ -82,14 +83,29 @@ export function playwrightFinding(host: BrowserHost): Finding {
82
83
  );
83
84
  }
84
85
 
85
- function palOnPathFinding(): Finding {
86
- const pal = findBinaryOnPath("pal");
87
- return pal
88
- ? passed("pal.path", `pal on PATH — ${pal}`)
89
- : failing("pal.path", "pal is not on PATH — skills that run 'pal cli …' fail", {
90
- say: "Install PAL globally",
91
- command: "bun add -g portable-agent-layer",
92
- });
86
+ interface PalInstall {
87
+ pal: string | null;
88
+ pkg: string;
89
+ repoMode: boolean;
90
+ }
91
+
92
+ function putPalOnPath(install: PalInstall): Fix {
93
+ return install.repoMode
94
+ ? {
95
+ say: "Link the checkout — a shell alias is invisible to agents",
96
+ command: `cd ${install.pkg} && bun link`,
97
+ }
98
+ : { say: "Install PAL globally", command: "bun add -g portable-agent-layer" };
99
+ }
100
+
101
+ export function palOnPathFinding(install: PalInstall): Finding {
102
+ return install.pal
103
+ ? passed("pal.path", `pal on PATH — ${install.pal}`)
104
+ : failing(
105
+ "pal.path",
106
+ "pal is not on PATH — skills that run 'pal cli …' fail",
107
+ putPalOnPath(install)
108
+ );
93
109
  }
94
110
 
95
111
  function rtkInstall(): Fix {
@@ -115,7 +131,11 @@ function rtkFinding(rtk: ToolCheck): Finding {
115
131
  export function environmentFindings(rtk: ToolCheck): Finding[] {
116
132
  return [
117
133
  passed("bun", `Bun ${Bun.version}`),
118
- palOnPathFinding(),
134
+ palOnPathFinding({
135
+ pal: findBinaryOnPath("pal"),
136
+ pkg: palPkg(),
137
+ repoMode: isRepoMode(),
138
+ }),
119
139
  playwrightFinding({
120
140
  browsersPath: playwrightBrowsersPath(),
121
141
  playwright: installedPlaywright(),
@@ -3,7 +3,7 @@ import { cachedStatus, type UpdateCache } from "../../hooks/handlers/update-chec
3
3
  import { type HookErrorGroup, recentHookErrors } from "../../hooks/lib/log";
4
4
  import { palHome } from "../../hooks/lib/paths";
5
5
  import { checkPendingMigrations } from "../migrate";
6
- import { type Finding, passed, warning } from "./finding";
6
+ import { type Finding, failing, passed, warning } from "./finding";
7
7
 
8
8
  interface PendingMigration {
9
9
  id: string;
@@ -11,15 +11,49 @@ interface PendingMigration {
11
11
  detail?: string;
12
12
  }
13
13
 
14
- export function hookErrorFindings(groups: HookErrorGroup[]): Finding[] {
14
+ interface ErrorContext {
15
+ now: number;
16
+ env: Record<string, string | undefined>;
17
+ }
18
+
19
+ const CLAUDE_LOGIN_EXPIRED = /Failed to authenticate/;
20
+ const MINUTE_MS = 60_000;
21
+
22
+ function ago(at: number, now: number): string {
23
+ const minutes = Math.max(0, Math.round((now - at) / MINUTE_MS));
24
+ return minutes < 60 ? `${minutes}m ago` : `${Math.floor(minutes / 60)}h ago`;
25
+ }
26
+
27
+ function claudeLoginExpiredFinding(
28
+ group: HookErrorGroup,
29
+ context: ErrorContext
30
+ ): Finding {
31
+ 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
+ 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
+ command: "claude setup-token",
36
+ });
37
+ return failing(`hook-errors.${group.source}`, title, {
38
+ say: "Create a year-long token, then export it as CLAUDE_CODE_OAUTH_TOKEN in your shell profile",
39
+ command: "claude setup-token",
40
+ });
41
+ }
42
+
43
+ export function hookErrorFindings(
44
+ groups: HookErrorGroup[],
45
+ context: ErrorContext = { now: Date.now(), env: process.env }
46
+ ): Finding[] {
15
47
  if (groups.length === 0)
16
48
  return [passed("hook-errors", "No hook errors in the last 24h")];
17
49
  const log = resolve(palHome(), "debug", "debug.log");
18
50
  return groups.map((group) => {
51
+ if (CLAUDE_LOGIN_EXPIRED.test(group.last))
52
+ return claudeLoginExpiredFinding(group, context);
19
53
  const times = group.count === 1 ? "once" : `${group.count} times`;
20
54
  return warning(
21
55
  `hook-errors.${group.source}`,
22
- `${group.source} failed ${times} in the last 24h — last: ${group.last}`,
56
+ `${group.source} failed ${times} in the last 24h, last ${ago(group.lastAt, context.now)}: ${group.last}`,
23
57
  { say: `Full trace in ${log}` }
24
58
  );
25
59
  });
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import { palHome, palPkg } from "../../hooks/lib/paths";
4
4
  import { auditBindings, type BindingIssue } from "../../hooks/lib/projects";
5
5
  import { telosStatus } from "../../hooks/lib/telos-topics";
6
+ import { retiredKeysIn } from "../migrate";
6
7
  import { type Finding, failing, optional, passed, warning } from "./finding";
7
8
 
8
9
  interface SchemaNode {
@@ -142,7 +143,12 @@ function settingsFindings(): Finding[] {
142
143
  }),
143
144
  ];
144
145
  const schema = readSettingsSchema();
145
- const unknown = schema ? unknownSettingsKeys(settings, schema) : [];
146
+ const leftToMigration = new Set(
147
+ retiredKeysIn(settings as Record<string, Record<string, unknown>>)
148
+ );
149
+ const unknown = schema
150
+ ? unknownSettingsKeys(settings, schema).filter((k) => !leftToMigration.has(k.path))
151
+ : [];
146
152
  return [identityFinding(settings), unknownKeysFinding(unknown)];
147
153
  }
148
154
 
@@ -642,6 +642,79 @@ const v6HistorySlugs: Migration = {
642
642
  },
643
643
  };
644
644
 
645
+ // ── v7-retired-settings-keys: keys older settings templates wrote ──
646
+
647
+ const RETIRED_SETTINGS_KEYS: Record<string, string[]> = {
648
+ loadAtStartup: ["_docs"],
649
+ steering: ["_docs"],
650
+ dynamicContext: [
651
+ "_docs",
652
+ "learningDigest",
653
+ "projectHistory",
654
+ "sessionIntelligence",
655
+ "synthesis",
656
+ "signalTrends",
657
+ "activeWork",
658
+ ],
659
+ };
660
+
661
+ function settingsFile(): string {
662
+ return resolve(paths.memory(), "pal-settings.json");
663
+ }
664
+
665
+ function readSettingsObject(): Record<string, Record<string, unknown>> | null {
666
+ try {
667
+ const settings = JSON.parse(readFileSync(settingsFile(), "utf-8"));
668
+ return typeof settings === "object" && settings !== null ? settings : null;
669
+ } catch {
670
+ return null;
671
+ }
672
+ }
673
+
674
+ export function retiredKeysIn(
675
+ settings: Record<string, Record<string, unknown>>
676
+ ): string[] {
677
+ return Object.entries(RETIRED_SETTINGS_KEYS).flatMap(([section, keys]) => {
678
+ const value = settings[section];
679
+ if (typeof value !== "object" || value === null) return [];
680
+ return keys.filter((key) => key in value).map((key) => `${section}.${key}`);
681
+ });
682
+ }
683
+
684
+ const v7RetiredSettingsKeys: Migration = {
685
+ id: "v7-retired-settings-keys",
686
+ description: "Remove settings older templates wrote that PAL no longer reads",
687
+
688
+ check() {
689
+ const settings = readSettingsObject();
690
+ const retired = settings ? retiredKeysIn(settings) : [];
691
+ return {
692
+ pending: retired.length > 0,
693
+ detail: retired.length > 0 ? retired.join(", ") : undefined,
694
+ };
695
+ },
696
+
697
+ run(dryRun = false): MigrationResult {
698
+ const settings = readSettingsObject();
699
+ const retired = settings ? retiredKeysIn(settings) : [];
700
+ if (!settings || retired.length === 0)
701
+ return { migrated: 0, skipped: 0, results: [] };
702
+ if (!dryRun) {
703
+ for (const path of retired) {
704
+ const [section, key] = path.split(".");
705
+ delete settings[section][key];
706
+ }
707
+ writeFileSync(settingsFile(), `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
708
+ }
709
+ const verb = dryRun ? "would remove" : "removed";
710
+ return {
711
+ migrated: retired.length,
712
+ skipped: 0,
713
+ results: [`pal-settings.json: ${verb} ${retired.join(", ")}`],
714
+ };
715
+ },
716
+ };
717
+
645
718
  const MIGRATIONS: Migration[] = [
646
719
  v1Projects,
647
720
  v2ThreadsToIsc,
@@ -649,6 +722,7 @@ const MIGRATIONS: Migration[] = [
649
722
  v4PathsToBindings,
650
723
  v5AttributionKeys,
651
724
  v6HistorySlugs,
725
+ v7RetiredSettingsKeys,
652
726
  ];
653
727
 
654
728
  // ── Public API ────────────────────────────────────────────────────
@@ -83,6 +83,12 @@ export interface HookErrorGroup {
83
83
  source: string;
84
84
  count: number;
85
85
  last: string;
86
+ lastAt: number;
87
+ }
88
+
89
+ interface LoggedError {
90
+ at: number;
91
+ message: string;
86
92
  }
87
93
 
88
94
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -108,16 +114,20 @@ function loggedAt(line: string): number {
108
114
  return match ? Date.parse(`${match[1]}T${match[2]}Z`) : 0;
109
115
  }
110
116
 
111
- function groupBySource(errors: string[]): HookErrorGroup[] {
117
+ function whatTheAgentSaid(message: string): string {
118
+ const output = / stderr\(\d+\)=(.*?) stdout\(\d+\)=(.*)$/.exec(message);
119
+ if (!output) return message;
120
+ return output[1].trim() || output[2].trim() || message;
121
+ }
122
+
123
+ function groupBySource(errors: LoggedError[]): HookErrorGroup[] {
112
124
  const groups = new Map<string, HookErrorGroup>();
113
- for (const error of errors) {
114
- const source = /^([^:]+):/.exec(error)?.[1] ?? "unknown";
115
- const group = groups.get(source) ?? { source, count: 0, last: "" };
125
+ for (const { at, message } of errors) {
126
+ const source = /^([^:]+):/.exec(message)?.[1] ?? "unknown";
127
+ const group = groups.get(source) ?? { source, count: 0, last: "", lastAt: 0 };
116
128
  group.count++;
117
- group.last = error
118
- .slice(source.length + 1)
119
- .trim()
120
- .slice(0, 120);
129
+ group.last = whatTheAgentSaid(message.slice(source.length + 1).trim()).slice(0, 120);
130
+ group.lastAt = at;
121
131
  groups.set(source, group);
122
132
  }
123
133
  return [...groups.values()].sort((a, b) => b.count - a.count);
@@ -128,7 +138,10 @@ export function recentHookErrors(now: number = Date.now()): HookErrorGroup[] {
128
138
  const recent = readAllLogs()
129
139
  .split("\n")
130
140
  .filter((line) => line.includes("] ERROR ") && loggedAt(line) > now - DAY_MS)
131
- .map((line) => line.replace(/^\[.*?\] ERROR /, ""));
141
+ .map((line) => ({
142
+ at: loggedAt(line),
143
+ message: line.replace(/^\[.*?\] ERROR /, ""),
144
+ }));
132
145
  return groupBySource(recent);
133
146
  } catch {
134
147
  return [];
@@ -17,7 +17,7 @@ import {
17
17
  writeFileSync,
18
18
  } from "node:fs";
19
19
  import { homedir } from "node:os";
20
- import { dirname, resolve, sep } from "node:path";
20
+ import { dirname, isAbsolute, resolve, sep } from "node:path";
21
21
  import { assets, palHome, platform } from "../hooks/lib/paths";
22
22
  import { declaredTriggers } from "../hooks/lib/skill-triggers";
23
23
 
@@ -186,7 +186,7 @@ export function mergeSettings(existing: Settings, template: Settings): Settings
186
186
  for (const [event, entries] of Object.entries(result.hooks)) {
187
187
  result.hooks[event] = entries.filter((e) => {
188
188
  const cmd = e.hooks?.[0]?.command;
189
- return !cmd || !palCanonical.has(canonicalPalCmd(cmd));
189
+ return !cmd || !isOutdatedPalHook(cmd, palCanonical);
190
190
  });
191
191
  if (result.hooks[event].length === 0) delete result.hooks[event];
192
192
  }
@@ -390,7 +390,7 @@ export function mergeCursorHooks(
390
390
  // Strip existing PAL hooks that match canonically (removes old-path duplicates)
391
391
  for (const [event, entries] of Object.entries(result.hooks)) {
392
392
  result.hooks[event] = entries.filter(
393
- (e) => !palCanonical.has(canonicalPalCmd(e.command))
393
+ (e) => !isOutdatedPalHook(e.command, palCanonical)
394
394
  );
395
395
  if (result.hooks[event].length === 0) delete result.hooks[event];
396
396
  }
@@ -467,6 +467,15 @@ function canonicalPalCmd(cmd: string): string {
467
467
  return withoutEnv;
468
468
  }
469
469
 
470
+ function runsRemovedPalHook(cmd: string): boolean {
471
+ const script = /bun\s+run\s+(\S+\/src\/hooks\/\S+\.ts)/.exec(cmd)?.[1];
472
+ return script !== undefined && isAbsolute(script) && !existsSync(script);
473
+ }
474
+
475
+ function isOutdatedPalHook(cmd: string, palCanonical: Set<string>): boolean {
476
+ return palCanonical.has(canonicalPalCmd(cmd)) || runsRemovedPalHook(cmd);
477
+ }
478
+
470
479
  /**
471
480
  * True for path-scoped Grep()/Glob() allow rules, which Claude Code cannot honor —
472
481
  * it resolves Grep/Glob permission through Read(...) rules, so these never match and
@@ -507,11 +516,11 @@ function stripPalHooks(
507
516
  hooks[event] = (hooks[event] ?? [])
508
517
  .map((g) => {
509
518
  const flat = g as unknown as CodexHookCommand;
510
- if (!g.hooks && flat.command && palCanonical.has(canonicalPalCmd(flat.command))) {
519
+ if (!g.hooks && flat.command && isOutdatedPalHook(flat.command, palCanonical)) {
511
520
  return null;
512
521
  }
513
522
  const filtered = (g.hooks ?? []).filter(
514
- (h) => !palCanonical.has(canonicalPalCmd(h.command))
523
+ (h) => !isOutdatedPalHook(h.command, palCanonical)
515
524
  );
516
525
  return filtered.length > 0 ? { ...g, hooks: filtered } : null;
517
526
  })