portable-agent-layer 0.78.0 → 0.78.2

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
@@ -63,7 +63,7 @@ alias pal="bun run ~/path/to/portable-agent-layer/src/cli/index.ts"
63
63
 
64
64
  ```bash
65
65
  pal cli init # scaffold home, install hooks for all targets
66
- pal # start a Claude session (with session summary on exit)
66
+ pal # start the first installed agent in the terminal
67
67
  pal cli status # check your setup
68
68
  ```
69
69
 
@@ -73,7 +73,7 @@ pal cli status # check your setup
73
73
 
74
74
  | Command | Description |
75
75
  |---------|-------------|
76
- | `pal` | Start a Claude session with session summary on exit |
76
+ | `pal` | Start the first installed agent, in order: Claude Code, Codex, Cursor CLI, Copilot CLI, opencode. Claude sessions print a summary on exit |
77
77
  | `pal cli init` | Scaffold PAL home directory and install hooks |
78
78
  | `pal cli install` | Register hooks/skills for targets |
79
79
  | `pal cli uninstall` | Remove hooks/skills for targets |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.78.0",
3
+ "version": "0.78.2",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * PAL CLI — Portable Agent Layer
4
4
  *
5
5
  * Usage:
6
- * pal [claude-args...] Start a Claude session with session summary on exit
6
+ * pal [agent-args...] Start the first installed agent: claude, codex, cursor-agent, copilot, opencode
7
7
  * pal cli <command> [options] Admin commands
8
8
  *
9
9
  * Admin commands (pal cli ...):
@@ -56,6 +56,7 @@ import { findBinaryOnPath } from "../hooks/lib/which";
56
56
  import { log } from "../targets/lib";
57
57
  import { builtinToolVerbs, runBuiltinTool } from "./builtin-tools";
58
58
  import { checkPendingMigrations } from "./migrate";
59
+ import { findSessionAgent, NO_SESSION_AGENT_MESSAGE } from "./session-agent";
59
60
 
60
61
  const allArgs = process.argv.slice(2);
61
62
 
@@ -109,16 +110,10 @@ function checkCopilot(): ToolCheck {
109
110
  return cli;
110
111
  }
111
112
 
112
- function detectAgent(): string | null {
113
- if (checkTool("claude").available) return "claude";
114
- if (checkTool("opencode").available) return "opencode";
115
- return null;
116
- }
117
-
118
113
  async function session(sessionArgs: string[]) {
119
- const agent = detectAgent();
114
+ const agent = findSessionAgent();
120
115
  if (!agent) {
121
- log.error("No supported agent found. Install Claude Code or opencode.");
116
+ log.error(NO_SESSION_AGENT_MESSAGE);
122
117
  process.exit(1);
123
118
  }
124
119
 
@@ -328,7 +323,7 @@ function pointAtOnboarding(): void {
328
323
  function showHelp() {
329
324
  console.log(`
330
325
  Usage:
331
- pal [claude-args...] Start a Claude session
326
+ pal [agent-args...] Start the first installed agent
332
327
  pal cli <command> [options] Admin commands
333
328
 
334
329
  Admin commands:
@@ -1159,7 +1154,7 @@ function doctor(silent = false): DoctorResult {
1159
1154
 
1160
1155
  if (!hasAgent) {
1161
1156
  console.log("");
1162
- log.error("No supported agent found. Install Claude Code or opencode.");
1157
+ log.error(NO_SESSION_AGENT_MESSAGE);
1163
1158
  }
1164
1159
  console.log("");
1165
1160
  }
@@ -0,0 +1,20 @@
1
+ import { findBinaryOnPath } from "../hooks/lib/which";
2
+
3
+ const SESSION_AGENT_BINARIES = [
4
+ "claude",
5
+ "codex",
6
+ "cursor-agent",
7
+ "copilot",
8
+ "opencode",
9
+ ] as const;
10
+
11
+ export type SessionAgent = (typeof SESSION_AGENT_BINARIES)[number];
12
+
13
+ export function findSessionAgent(): SessionAgent | null {
14
+ return (
15
+ SESSION_AGENT_BINARIES.find((binary) => findBinaryOnPath(binary) !== null) ?? null
16
+ );
17
+ }
18
+
19
+ export const NO_SESSION_AGENT_MESSAGE =
20
+ "No supported agent found. Install Claude Code, Codex, Cursor CLI, Copilot CLI or opencode.";
@@ -349,10 +349,11 @@ export function resolveProjectFromCwd(
349
349
 
350
350
  export function isStale(
351
351
  p: ProjectProgress,
352
- thresholdDays = PROJECT_STALE_DAYS_DEFAULT
352
+ thresholdDays = PROJECT_STALE_DAYS_DEFAULT,
353
+ now: Date = new Date()
353
354
  ): boolean {
354
355
  if (!p.updated) return false;
355
- const age = Date.now() - new Date(p.updated).getTime();
356
+ const age = now.getTime() - new Date(p.updated).getTime();
356
357
  if (!Number.isFinite(age) || age < 0) return false;
357
358
  return age > thresholdDays * 86_400_000;
358
359
  }
@@ -4,6 +4,9 @@
4
4
  */
5
5
 
6
6
  import { lstatSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { resolve } from "node:path";
9
+ import { palHome, platform } from "./paths";
7
10
 
8
11
  // PowerShell aliases rm, rmdir, del, erase, rd and ri all to Remove-Item, and
9
12
  // cmd ships its own rd and del — so the verb alone never says which shell ran it.
@@ -137,13 +140,8 @@ const HOOK_MANAGED_DIRS = [
137
140
  "debug",
138
141
  ];
139
142
 
140
- /** Escape a string for use in a RegExp */
141
- function escapeRegExp(s: string): string {
142
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
143
- }
144
-
145
143
  /** PAL-deployed dirs — engine-managed, overwritten on every `pal install` */
146
- const PAL_INSTALLED_DIRS_RE = /[/\\]\.pal[/\\](?:docs|skills|tools)[/\\]/;
144
+ const PAL_INSTALLED_DIRS = ["docs", "skills", "tools"];
147
145
 
148
146
  /** Paths that should never be written to */
149
147
  const PROTECTED_PATHS: RegExp[] = [
@@ -154,25 +152,80 @@ const PROTECTED_PATHS: RegExp[] = [
154
152
  /\.gnupg\//,
155
153
  // Claude Code auto-memory — PAL owns memory; writes here indicate wrong system is being used
156
154
  /\.claude\/projects\/[^/]+\/memory\//,
157
- // PAL_INSTALLED_DIRS_RE is enforced by the dedicated branch in checkFilePath
158
- // (which exempts personal skill dirs); keeping it here would re-block them.
159
- // Derived from HOOK_MANAGED_FILES — scoped to managed roots only
160
- ...HOOK_MANAGED_FILES.map(
161
- (name) =>
162
- new RegExp(
163
- String.raw`[/\\]\.(?:pal|claude|agents|cursor)[/\\].*${escapeRegExp(name)}$`
164
- )
165
- ),
166
155
  ];
167
156
 
168
- /** Roots where managed files/dirs are protected (user state, not repo templates) */
169
- const MANAGED_ROOTS = [".pal/", ".claude/", ".agents/", ".config/opencode/", ".cursor/"];
157
+ function comparable(path: string): string {
158
+ const forward = path.replaceAll("\\", "/").replace(/\/+$/, "");
159
+ return process.platform === "win32" ? forward.toLowerCase() : forward;
160
+ }
161
+
162
+ function absoluteComparable(path: string): string {
163
+ return comparable(resolve(path.replaceAll("\\", "/")));
164
+ }
165
+
166
+ /** The part of `path` below `root`, or null when `path` is outside it. */
167
+ function pathBelow(root: string, path: string): string | null {
168
+ const base = absoluteComparable(root);
169
+ const target = absoluteComparable(path);
170
+ return target.startsWith(`${base}/`) ? target.slice(base.length + 1) : null;
171
+ }
172
+
173
+ /** The real directories PAL writes user state into, not any folder that shares their name. */
174
+ function managedRoots(): string[] {
175
+ return [
176
+ palHome(),
177
+ platform.claudeDir(),
178
+ platform.agentsDir(),
179
+ platform.cursorDir(),
180
+ platform.opencodeDir(),
181
+ ];
182
+ }
183
+
184
+ function pathInsideManagedRoot(path: string): string | null {
185
+ for (const root of managedRoots()) {
186
+ const below = pathBelow(root, path);
187
+ if (below !== null) return below;
188
+ }
189
+ return null;
190
+ }
170
191
 
171
- function isUnderManagedRoot(path: string): boolean {
172
- const normalized = path.replaceAll("\\", "/");
173
- return MANAGED_ROOTS.some(
174
- (root) => normalized.includes(`/${root}`) || normalized.includes(`\\.${root}`)
175
- );
192
+ function managedFileAt(below: string): string | undefined {
193
+ return HOOK_MANAGED_FILES.find((name) => {
194
+ const file = comparable(name);
195
+ return below === file || below.endsWith(`/${file}`);
196
+ });
197
+ }
198
+
199
+ function managedDirAt(below: string): string | undefined {
200
+ return HOOK_MANAGED_DIRS.find((dir) => `/${below}/`.includes(`/${comparable(dir)}/`));
201
+ }
202
+
203
+ function managedPathReason(path: string): string | null {
204
+ const below = pathInsideManagedRoot(path);
205
+ if (below === null) return null;
206
+ const file = managedFileAt(below);
207
+ if (file) return `${file} is managed automatically by hooks — do not edit directly`;
208
+ const dir = managedDirAt(below);
209
+ if (dir) return `${dir}/ is managed automatically by hooks — do not edit directly`;
210
+ return null;
211
+ }
212
+
213
+ const HOME_PREFIX =
214
+ /^(?:~|\$HOME|\$\{HOME\}|\$env:USERPROFILE|%USERPROFILE%)(?=[/\\]|$)/i;
215
+ const COMMAND_TOKEN = /[^\s'"`<>|;&()=]+/g;
216
+
217
+ function pathsNamedIn(segment: string): string[] {
218
+ return Array.from(segment.matchAll(COMMAND_TOKEN), (m) => m[0])
219
+ .filter((token) => HOME_PREFIX.test(token) || /[/\\]/.test(token))
220
+ .map((token) => token.replace(HOME_PREFIX, () => homedir()));
221
+ }
222
+
223
+ function managedReasonInSegment(segment: string): string | null {
224
+ for (const path of pathsNamedIn(segment)) {
225
+ const reason = managedPathReason(path);
226
+ if (reason) return reason;
227
+ }
228
+ return null;
176
229
  }
177
230
 
178
231
  /** Read-only commands allowed to reference protected files */
@@ -189,26 +242,10 @@ export function checkBashCommand(cmd: string): string | null {
189
242
  if (pattern.test(cmd)) return reason;
190
243
  }
191
244
  }
192
- // If command references a managed file in a managed root path, block unless read-only.
193
- // The filename must appear IN the same path as the managed root (e.g. .pal/.../file.json).
194
- const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
195
- for (const name of HOOK_MANAGED_FILES) {
196
- const pattern = new RegExp(
197
- String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(name)}`
198
- );
199
- const managed = segments.filter((s) => pattern.test(s));
200
- if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
201
- return `${name} is managed automatically by hooks — do not edit directly`;
202
- }
203
- }
204
- for (const dir of HOOK_MANAGED_DIRS) {
205
- const pattern = new RegExp(
206
- String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(dir)}`
207
- );
208
- const managed = segments.filter((s) => pattern.test(s));
209
- if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
210
- return `${dir} is managed automatically by hooks — do not edit directly`;
211
- }
245
+ const segments = cmd.split(/[|;&]/).map((s) => s.trim());
246
+ for (const segment of segments) {
247
+ const reason = managedReasonInSegment(segment);
248
+ if (reason && !READ_ONLY_COMMANDS.test(segment)) return reason;
212
249
  }
213
250
  return null;
214
251
  }
@@ -218,44 +255,25 @@ export function checkBashCommand(cmd: string): string | null {
218
255
  * skills are real dirs authored in place. A not-yet-created skill dir is also
219
256
  * personal (scaffolding). So: symlink → shipped/protected, otherwise → personal.
220
257
  */
221
- function isShippedSkillPath(normalized: string): boolean {
222
- const m = /\.pal\/skills\/([^/]+)/.exec(normalized);
223
- if (!m) return false;
224
- const skillRoot = normalized.slice(0, m.index + m[0].length);
258
+ function isShippedSkill(name: string): boolean {
225
259
  try {
226
- return lstatSync(skillRoot).isSymbolicLink();
260
+ return lstatSync(resolve(palHome(), "skills", name)).isSymbolicLink();
227
261
  } catch {
228
262
  return false;
229
263
  }
230
264
  }
231
265
 
266
+ function palInstalledReason(path: string): string | null {
267
+ const [dir, entry] = pathBelow(palHome(), path)?.split("/") ?? [];
268
+ if (!dir || !entry || !PAL_INSTALLED_DIRS.includes(dir)) return null;
269
+ if (dir === "skills" && !isShippedSkill(entry)) return null;
270
+ return `~/.pal/${dir}/ is managed by 'pal install' — edit the source in the PAL repo instead`;
271
+ }
272
+
232
273
  /** Check a file path against protected patterns. Returns a reason string or null. */
233
274
  export function checkFilePath(filePath: string): string | null {
234
- const normalized = filePath.replaceAll("\\", "/");
235
- // Check hook-managed files — only under managed roots (not repo templates)
236
- if (isUnderManagedRoot(normalized)) {
237
- const matchedFile = HOOK_MANAGED_FILES.find((name) =>
238
- normalized.endsWith(`/${name}`)
239
- );
240
- if (matchedFile) {
241
- return `${matchedFile} is managed automatically by hooks — do not edit directly`;
242
- }
243
- }
244
- // Check hook-managed directories
245
- const matchedDir = HOOK_MANAGED_DIRS.find((dir) => normalized.includes(`/${dir}/`));
246
- if (matchedDir) {
247
- return `${matchedDir}/ is managed automatically by hooks — do not edit directly`;
248
- }
249
- // PAL-deployed dirs — edit source in the PAL repo, not the installed copy
250
- if (PAL_INSTALLED_DIRS_RE.test(normalized)) {
251
- const match = new RegExp(/\.pal[/\\](docs|skills|tools)/).exec(normalized);
252
- const dir = match ? match[1] : "docs/skills/tools";
253
- const isPersonalSkill = dir === "skills" && !isShippedSkillPath(normalized);
254
- if (!isPersonalSkill) {
255
- return `~/.pal/${dir}/ is managed by 'pal install' — edit the source in the PAL repo instead`;
256
- }
257
- }
258
- // Check remaining system-protected paths
275
+ const reason = managedPathReason(filePath) ?? palInstalledReason(filePath);
276
+ if (reason) return reason;
259
277
  if (PROTECTED_PATHS.some((pattern) => pattern.test(filePath))) {
260
278
  return `Protected path: ${filePath}`;
261
279
  }
@@ -16,6 +16,7 @@ import { loadAnalyzeNudge } from "../../hooks/lib/analyze-nudge";
16
16
  import { paths } from "../../hooks/lib/paths";
17
17
  import {
18
18
  isStale,
19
+ PROJECT_STALE_DAYS_DEFAULT,
19
20
  type ProjectProgress,
20
21
  readAllProjects,
21
22
  type ServesAuthority,
@@ -188,7 +189,7 @@ function toCard(
188
189
  snoozes: Record<string, string>
189
190
  ): ProjectCard {
190
191
  const path = p.path ?? null;
191
- const stale = isStale(p);
192
+ const stale = isStale(p, PROJECT_STALE_DAYS_DEFAULT, now);
192
193
  const history = path ? readProjectHistory(path, 1) : [];
193
194
  const last = history.at(-1);
194
195
  return {