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 +2 -2
- package/package.json +1 -1
- package/src/cli/index.ts +6 -11
- package/src/cli/session-agent.ts +20 -0
- package/src/hooks/lib/projects.ts +3 -2
- package/src/hooks/lib/security.ts +90 -72
- package/src/tools/control-room/data.ts +2 -1
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
|
|
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
|
|
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
package/src/cli/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* PAL CLI — Portable Agent Layer
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* pal [
|
|
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 =
|
|
114
|
+
const agent = findSessionAgent();
|
|
120
115
|
if (!agent) {
|
|
121
|
-
log.error(
|
|
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 [
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
169
|
-
const
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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(
|
|
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
|
|
235
|
-
|
|
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 {
|