portable-agent-layer 0.63.1 → 0.63.3

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.
@@ -0,0 +1,37 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "type": "command",
7
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts --agent=vscode"
8
+ }
9
+ ],
10
+ "UserPromptSubmit": [
11
+ {
12
+ "type": "command",
13
+ "command": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=vscode"
14
+ }
15
+ ],
16
+ "PreToolUse": [
17
+ {
18
+ "type": "command",
19
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=vscode"
20
+ },
21
+ {
22
+ "type": "command",
23
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts --agent=vscode"
24
+ },
25
+ {
26
+ "type": "command",
27
+ "command": "bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts --agent=vscode"
28
+ }
29
+ ],
30
+ "Stop": [
31
+ {
32
+ "type": "command",
33
+ "command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=vscode"
34
+ }
35
+ ]
36
+ }
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.63.1",
3
+ "version": "0.63.3",
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
@@ -37,7 +37,7 @@ import { homedir } from "node:os";
37
37
  import { resolve } from "node:path";
38
38
  import { inference, previewInferenceRoute } from "../hooks/lib/inference";
39
39
  import { DEBUG_LOG_MAX_ROTATED, logDebug } from "../hooks/lib/log";
40
- import { palHome, palPkg, platform } from "../hooks/lib/paths";
40
+ import { palHome, palPkg, paths, platform } from "../hooks/lib/paths";
41
41
  import { hasRealContent, SETUP_STEPS, STEP_ORDER } from "../hooks/lib/setup";
42
42
  import { log } from "../targets/lib";
43
43
  import { checkPendingMigrations } from "./migrate";
@@ -485,21 +485,27 @@ interface HookPrefixCheck {
485
485
  firstMissing?: string;
486
486
  }
487
487
 
488
- /** True when a hook command sets PAL_AGENT up front, in POSIX or PowerShell syntax. */
489
- function setsAgentEnvPrefix(cmd: string, agentName: string): boolean {
488
+ /**
489
+ * True when a hook command names its agent, by env prefix or by argv flag.
490
+ *
491
+ * An env prefix only parses in one shell family, so hook configs whose host
492
+ * shell is unknown declare the agent with a shell-agnostic `--agent=` flag.
493
+ */
494
+ function declaresAgent(cmd: string, agentName: string): boolean {
490
495
  return (
491
496
  cmd.startsWith(`PAL_AGENT=${agentName} `) ||
492
- cmd.startsWith(`$env:PAL_AGENT='${agentName}'; `)
497
+ cmd.startsWith(`$env:PAL_AGENT='${agentName}'; `) ||
498
+ cmd.includes(`--agent=${agentName}`)
493
499
  );
494
500
  }
495
501
 
496
- /** Verify every command in an installed hook file sets `PAL_AGENT=<agent>` first. */
502
+ /** Verify every command in an installed hook file names `<agent>` as its agent. */
497
503
  function checkAgentHookPrefix(filePath: string, agentName: string): HookPrefixCheck {
498
504
  if (!existsSync(filePath)) return { ok: false, total: 0, missing: 0 };
499
505
  try {
500
506
  const data = JSON.parse(readFileSync(filePath, "utf-8"));
501
507
  const commands = extractAllHookCommands(data.hooks ?? data);
502
- const missing = commands.filter((c) => !setsAgentEnvPrefix(c, agentName));
508
+ const missing = commands.filter((c) => !declaresAgent(c, agentName));
503
509
  return {
504
510
  ok: commands.length > 0 && missing.length === 0,
505
511
  total: commands.length,
@@ -928,12 +934,12 @@ function doctor(silent = false): DoctorResult {
928
934
  ): void => {
929
935
  const r = checkAgentHookPrefix(filePath, agentName);
930
936
  if (r.ok) {
931
- ok(`${agentName}: PAL_AGENT=${agentName} on all ${r.total} hook commands`);
937
+ ok(`${agentName}: declared on all ${r.total} hook commands`);
932
938
  } else if (r.total === 0) {
933
939
  fail(`${agentName}: hook file missing or unreadable at ${filePath}`);
934
940
  } else {
935
941
  fail(
936
- `${agentName}: ${r.missing}/${r.total} hook commands missing PAL_AGENT=${agentName} prefix (run '${installCmd}')`
942
+ `${agentName}: ${r.missing}/${r.total} hook commands do not declare ${agentName} (run '${installCmd}')`
937
943
  );
938
944
  if (r.firstMissing) {
939
945
  log.warn(` First offender: ${r.firstMissing}…`);
@@ -1084,6 +1090,10 @@ async function init(args: string[]) {
1084
1090
  log.info(`Creating PAL home at ${home}`);
1085
1091
  mkdirSync(resolve(home, "telos"), { recursive: true });
1086
1092
  mkdirSync(resolve(home, "memory"), { recursive: true });
1093
+ // Scaffolded here, not left to generateSkillIndex: that returns early when
1094
+ // ~/.pal/skills is absent, so an init that installs no skills would leave
1095
+ // every writer of memory/state with nowhere to write.
1096
+ mkdirSync(resolve(home, "memory", "state"), { recursive: true });
1087
1097
 
1088
1098
  scaffoldTelos();
1089
1099
 
@@ -1092,16 +1102,23 @@ async function init(args: string[]) {
1092
1102
  await install(targets);
1093
1103
  }
1094
1104
 
1105
+ /**
1106
+ * Run a setup subprocess, showing its output only when it fails.
1107
+ *
1108
+ * These are idempotent and usually report "no changes", so their banners are
1109
+ * pure noise on a re-install — but the moment one fails, the reason it gives
1110
+ * is the only thing that explains the warning.
1111
+ */
1112
+ function runQuietly(cmd: string, args: string[], cwd: string): number | null {
1113
+ const r = spawnSync(cmd, args, { cwd, encoding: "utf-8", shell: true });
1114
+ if (r.status !== 0) process.stderr.write((r.stdout ?? "") + (r.stderr ?? ""));
1115
+ return r.status;
1116
+ }
1117
+
1095
1118
  async function install(targets: Targets) {
1096
1119
  // Ensure dependencies are installed
1097
1120
  const pkg = palPkg();
1098
- log.info("Installing dependencies...");
1099
- const deps = spawnSync("bun", ["install", "--frozen-lockfile"], {
1100
- cwd: pkg,
1101
- stdio: "inherit",
1102
- shell: true,
1103
- });
1104
- if (deps.status !== 0) {
1121
+ if (runQuietly("bun", ["install", "--frozen-lockfile"], pkg) !== 0) {
1105
1122
  log.warn("bun install failed — continuing anyway, but hooks may not work");
1106
1123
  }
1107
1124
 
@@ -1110,15 +1127,10 @@ async function install(targets: Targets) {
1110
1127
  // (used by tests to avoid a ~150MB download on every run).
1111
1128
  // Uses `bun x` (not `bunx`) for Windows compatibility — bunx resolves unreliably under cmd.exe.
1112
1129
  if (process.env.PAL_SKIP_BROWSER_INSTALL !== "1") {
1113
- log.info("Installing Playwright Chromium...");
1114
- const pw = spawnSync("bun", ["x", "playwright", "install", "chromium"], {
1115
- cwd: pkg,
1116
- stdio: "inherit",
1117
- shell: true,
1118
- });
1119
- if (pw.status !== 0) {
1130
+ const pw = runQuietly("bun", ["x", "playwright", "install", "chromium"], pkg);
1131
+ if (pw !== 0) {
1120
1132
  log.warn(
1121
- `playwright install chromium failed (exit ${pw.status}) — create-pdf and consulting-report skills won't work. Retry manually: bun x playwright install chromium`
1133
+ `playwright install chromium failed (exit ${pw}) — create-pdf and consulting-report skills won't work. Retry manually: bun x playwright install chromium`
1122
1134
  );
1123
1135
  }
1124
1136
  }
@@ -1138,7 +1150,8 @@ async function install(targets: Targets) {
1138
1150
  }
1139
1151
 
1140
1152
  // Scaffold TELOS + PAL settings, then prompt for missing identity
1141
- const { scaffoldTelos, scaffoldPalSettings } = await import("../targets/lib");
1153
+ const { scaffoldTelos, scaffoldPalSettings, copyPalDocs, generateSkillIndex } =
1154
+ await import("../targets/lib");
1142
1155
  const { promptIdentity } = await import("./setup-identity");
1143
1156
  const { promptTelos } = await import("./setup-telos");
1144
1157
  const { promptAttribution } = await import("./setup-attribution");
@@ -1148,6 +1161,13 @@ async function install(targets: Targets) {
1148
1161
  await promptTelos();
1149
1162
  await promptAttribution();
1150
1163
 
1164
+ // Shared, target-independent state. Every target installer used to repeat these
1165
+ // identical calls; AGENTS.md in particular must exist before any target symlinks
1166
+ // to it, so it runs once here rather than once per target.
1167
+ const { regenerateIfNeeded } = await import("../hooks/lib/claude-md");
1168
+ const palDocsCount = copyPalDocs();
1169
+ regenerateIfNeeded();
1170
+
1151
1171
  if (targets.claude) {
1152
1172
  console.log("━━━ Claude Code ━━━");
1153
1173
  await import("../targets/claude/install");
@@ -1178,6 +1198,16 @@ async function install(targets: Targets) {
1178
1198
  console.log("");
1179
1199
  }
1180
1200
 
1201
+ // The rest of the shared work reads what the installers just wrote: the index
1202
+ // walks ~/.pal/skills, and the digests land in ~/.cursor/rules and
1203
+ // ~/.copilot/instructions, which are skipped when the agent's home is absent.
1204
+ const { writeContextDigests } = await import("../hooks/handlers/context-digests");
1205
+ const indexedSkills = generateSkillIndex();
1206
+ writeContextDigests();
1207
+ log.success(
1208
+ `Shared: ${indexedSkills} skills indexed · ${palDocsCount} docs → ~/.pal/docs/ · AGENTS.md + context digests written`
1209
+ );
1210
+
1181
1211
  log.success("Done. Existing config was preserved — only new entries were added.");
1182
1212
  }
1183
1213
 
@@ -1403,7 +1433,9 @@ async function update() {
1403
1433
  function cliDebug(args: string[]) {
1404
1434
  const stateDir = resolve(palHome(), "memory", "state");
1405
1435
  const flagFile = resolve(stateDir, "debug-enabled");
1406
- const logFile = resolve(stateDir, "debug.log");
1436
+ // Must match log.ts's logFile() — reporting a different path sends anyone
1437
+ // debugging a hook to an empty file.
1438
+ const logFile = resolve(paths.debug(), "debug.log");
1407
1439
  const sub = args[0];
1408
1440
  if (sub === "on") {
1409
1441
  mkdirSync(stateDir, { recursive: true });
@@ -1,34 +1,81 @@
1
1
  /**
2
2
  * Hook: PreToolUse — Guards against dangerous commands.
3
- * Returns JSON { decision: "block", reason: "..." } to block, or exits silently to allow.
3
+ * Emits the current agent's deny response to block, or exits silently to allow.
4
4
  *
5
5
  * Fail-open design: if anything goes wrong, the command is allowed through.
6
6
  */
7
7
 
8
- import { blockResponse } from "./lib/agent";
8
+ import { blockResponse, normalizeToolUse } from "./lib/agent";
9
+ import { logDebug } from "./lib/log";
9
10
  import { checkBashCommand, checkFilePath } from "./lib/security";
10
11
  import { readStdinJSON } from "./lib/stdin";
11
12
 
12
- // preToolUse shape (Claude Code + Cursor + Codex)
13
- interface ToolUseInput {
14
- tool_name: string;
15
- hook_event_name?: string; // Codex includes this in all hook inputs
16
- tool_input: {
17
- command?: string;
18
- file_path?: string;
19
- };
20
- }
21
-
22
- // beforeShellExecution shape (Cursor only) — flat, no tool_name wrapper
13
+ // beforeShellExecution shape (Cursor only) flat, no tool-name wrapper
23
14
  interface ShellExecInput {
24
15
  command: string;
25
16
  sandbox?: boolean;
26
17
  }
27
18
 
28
- type SecurityInput = ToolUseInput | ShellExecInput;
19
+ type SecurityInput = Record<string, unknown> | ShellExecInput;
29
20
 
30
21
  function isShellExec(input: SecurityInput): input is ShellExecInput {
31
- return !("tool_name" in input) && "command" in input;
22
+ return !("tool_name" in input) && !("toolName" in input) && "command" in input;
23
+ }
24
+
25
+ // A name this list misses is a command this hook waves through, so both sets mirror
26
+ // the tool names VS Code's own Copilot build ships in its shell and edit tool sets.
27
+ const SHELL_TOOLS = [
28
+ "bash",
29
+ "shell",
30
+ "powershell",
31
+ "local_shell",
32
+ "runinterminal",
33
+ "run_in_terminal",
34
+ "terminal",
35
+ "execute_command",
36
+ ];
37
+
38
+ const FILE_WRITE_TOOLS = [
39
+ "write",
40
+ "edit",
41
+ "multiedit",
42
+ "write_file",
43
+ "apply_patch",
44
+ "applypatch",
45
+ "create",
46
+ "create_file",
47
+ "createfile",
48
+ "str_replace",
49
+ "str_replace_editor",
50
+ "insert",
51
+ "insert_edit_into_file",
52
+ "replace_string_in_file",
53
+ "multi_replace_string_in_file",
54
+ "replacestring",
55
+ "edit_notebook_file",
56
+ "notebookedit",
57
+ ];
58
+
59
+ /** First of `keys` present as a non-empty string — agents disagree on argument spelling. */
60
+ function firstStringArg(
61
+ args: Record<string, unknown>,
62
+ keys: string[]
63
+ ): string | undefined {
64
+ for (const key of keys) {
65
+ const value = args[key];
66
+ if (typeof value === "string" && value.length > 0) return value;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /** Tool names that run a shell command, across every agent's naming. */
72
+ function runsShellCommand(toolName: string): boolean {
73
+ return SHELL_TOOLS.includes(toolName.toLowerCase());
74
+ }
75
+
76
+ /** Tool names that write to a file, across every agent's naming. */
77
+ function writesFile(toolName: string): boolean {
78
+ return FILE_WRITE_TOOLS.includes(toolName.toLowerCase());
32
79
  }
33
80
 
34
81
  try {
@@ -44,31 +91,35 @@ try {
44
91
  process.exit(0);
45
92
  }
46
93
 
47
- const hookEventName = input.hook_event_name;
94
+ const toolUse = normalizeToolUse(input);
95
+ if (!toolUse) process.exit(0);
48
96
 
49
- // preToolUse Claude: "Bash", Cursor: "Shell", Codex: "shell"
50
- const isBash =
51
- input.tool_name === "Bash" ||
52
- input.tool_name === "Shell" ||
53
- input.tool_name === "shell";
54
- const isFileWrite =
55
- input.tool_name === "Write" ||
56
- input.tool_name === "Edit" ||
57
- input.tool_name === "write_file" ||
58
- input.tool_name === "apply_patch";
97
+ // Each agent names its shell/write tools differently; log the real name so an
98
+ // unrecognized one shows up here instead of silently skipping the check.
99
+ logDebug(
100
+ "SecurityValidator",
101
+ `toolName=${toolUse.toolName} args=${Object.keys(toolUse.toolInput).join(",")}`
102
+ );
59
103
 
60
- if (isBash && input.tool_input.command) {
61
- const reason = checkBashCommand(input.tool_input.command);
104
+ const command = firstStringArg(toolUse.toolInput, ["command", "commandLine", "script"]);
105
+ if (runsShellCommand(toolUse.toolName) && typeof command === "string") {
106
+ const reason = checkBashCommand(command);
107
+ const verdict = reason ? `BLOCK(${reason})` : "ALLOW";
108
+ // "No output" from a downstream tool is indistinguishable between "denied,
109
+ // never ran" and "ran, produced nothing" — logging the verdict here, next
110
+ // to the literal command, is what actually tells the two apart.
111
+ logDebug("SecurityValidator", `bashVerdict=${verdict} command=${command}`);
62
112
  if (reason) {
63
- process.stdout.write(blockResponse(`Blocked: ${reason}`, hookEventName));
113
+ process.stdout.write(blockResponse(`Blocked: ${reason}`, toolUse.hookEventName));
64
114
  process.exit(0);
65
115
  }
66
116
  }
67
117
 
68
- if (isFileWrite && input.tool_input.file_path) {
69
- const reason = checkFilePath(input.tool_input.file_path);
118
+ const filePath = firstStringArg(toolUse.toolInput, ["file_path", "filePath", "path"]);
119
+ if (writesFile(toolUse.toolName) && typeof filePath === "string") {
120
+ const reason = checkFilePath(filePath);
70
121
  if (reason) {
71
- process.stdout.write(blockResponse(reason, hookEventName));
122
+ process.stdout.write(blockResponse(reason, toolUse.hookEventName));
72
123
  process.exit(0);
73
124
  }
74
125
  }
@@ -9,29 +9,25 @@
9
9
  * Fail-open: on any error, the skill is allowed through.
10
10
  */
11
11
 
12
- import { blockResponse } from "./lib/agent";
12
+ import { blockResponse, normalizeToolUse } from "./lib/agent";
13
13
  import { readStdinJSON } from "./lib/stdin";
14
14
 
15
15
  const BLOCKED_SKILLS = ["keybindings-help"];
16
16
 
17
- interface SkillInput {
18
- tool_name: string;
19
- tool_input: {
20
- skill?: string;
21
- };
22
- }
23
-
24
17
  try {
25
- const input = await readStdinJSON<SkillInput>();
26
- if (!input) process.exit(0);
18
+ const toolUse = normalizeToolUse(await readStdinJSON());
19
+ if (!toolUse) process.exit(0);
27
20
 
28
- const skill = (input.tool_input?.skill || "").toLowerCase().trim();
21
+ const skill = String(toolUse.toolInput.skill ?? "")
22
+ .toLowerCase()
23
+ .trim();
29
24
 
30
25
  if (BLOCKED_SKILLS.includes(skill)) {
31
26
  process.stdout.write(
32
27
  blockResponse(
33
28
  'BLOCKED: "keybindings-help" is a known false-positive triggered by position bias. ' +
34
- "The user did NOT ask about keybindings. Continue with their ACTUAL request."
29
+ "The user did NOT ask about keybindings. Continue with their ACTUAL request.",
30
+ toolUse.hookEventName
35
31
  )
36
32
  );
37
33
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { checkReadmeSync } from "./handlers/readme-sync";
10
- import { isCodex, isCursor } from "./lib/agent";
10
+ import { blockResponse, isCodex, isCursor } from "./lib/agent";
11
11
  import { logError } from "./lib/log";
12
12
  import { isPalSpawnedInference } from "./lib/spawn-guard";
13
13
  import { readStdinJSON } from "./lib/stdin";
@@ -29,8 +29,10 @@ interface StopHookInput {
29
29
 
30
30
  // Check README sync before anything else — may block the session
31
31
  try {
32
+ // A block carrying no reason stops the turn without telling the model why, so it
33
+ // is worth less than not blocking at all — require the reason to raise one.
32
34
  const decision = checkReadmeSync();
33
- if (decision.decision === "block") {
35
+ if (decision.decision === "block" && decision.reason) {
34
36
  if (isCursor()) {
35
37
  // Cursor stop hook: followup_message auto-sends to the agent
36
38
  process.stdout.write(JSON.stringify({ followup_message: decision.reason }));
@@ -38,8 +40,9 @@ try {
38
40
  // Codex stop hook: additionalContext re-queues as next prompt
39
41
  process.stdout.write(JSON.stringify({ additionalContext: decision.reason }));
40
42
  } else {
41
- // Claude Code: block decision
42
- process.stdout.write(JSON.stringify(decision));
43
+ // Claude Code, the Copilot CLI and VS Code's own Copilot each read a
44
+ // different stop-block shape; VS Code ignores the top-level keys entirely.
45
+ process.stdout.write(blockResponse(decision.reason, "Stop"));
43
46
  }
44
47
  process.exit(0);
45
48
  }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Called from UserPromptOrchestrator. Reads the retrieval index, ranks the prompt
5
5
  * against the corpus, prints a `<system-reminder>` block to stdout (Claude Code
6
- * prepends UserPromptSubmit hook stdout to the prompt). Fail-closed: any error or
7
- * timeout produces empty output, never blocks the prompt.
6
+ * prepends UserPromptSubmit hook stdout to the prompt). Fail-closed: any error
7
+ * produces empty output, never blocks the prompt.
8
8
  */
9
9
 
10
10
  import { isCodex, isCursor } from "../lib/agent";
@@ -14,21 +14,25 @@ import { ensureIndex } from "../lib/retrieval-index";
14
14
  import { isEnabled } from "../lib/settings";
15
15
  import { getSteeringReminder } from "../lib/steering";
16
16
 
17
- const TIMEOUT_MS = 250;
17
+ const BUDGET_MS = 250;
18
18
 
19
- function withTimeout<T>(work: () => T, ms: number): Promise<T | null> {
20
- return new Promise((resolve) => {
21
- const timer = setTimeout(() => resolve(null), ms);
22
- try {
23
- const result = work();
24
- clearTimeout(timer);
25
- resolve(result);
26
- } catch (err) {
27
- clearTimeout(timer);
28
- logError("inject-retrieval", err);
29
- resolve(null);
19
+ /** Run sync work on the prompt path, containing any throw. A synchronous call cannot
20
+ * be preempted on a single thread, so the budget is measured and logged, never
21
+ * enforced an overrun still returns its result rather than being discarded.
22
+ * @lintignore exported for test/inject-retrieval.test.ts */
23
+ export function withinBudget<T>(work: () => T, ms: number): T | null {
24
+ const started = performance.now();
25
+ try {
26
+ return work();
27
+ } catch (err) {
28
+ logError("inject-retrieval", err);
29
+ return null;
30
+ } finally {
31
+ const elapsed = performance.now() - started;
32
+ if (elapsed > ms) {
33
+ logDebug("inject-retrieval", `over budget: ${elapsed.toFixed(0)}ms > ${ms}ms`);
30
34
  }
31
- });
35
+ }
32
36
  }
33
37
 
34
38
  /** Returns the retrieval reminder string, or null if nothing to inject. @lintignore dynamically imported by opencode plugin */
@@ -36,11 +40,11 @@ export async function getRetrievalReminder(prompt: string): Promise<string | nul
36
40
  if (!prompt?.trim()) return null;
37
41
  if (!isEnabled("learningInjection")) return null;
38
42
 
39
- const result = await withTimeout(() => {
43
+ const result = withinBudget(() => {
40
44
  const index = ensureIndex();
41
45
  if (index.corpusSize === 0) return null;
42
46
  return runRetrieval(prompt, index, process.cwd());
43
- }, TIMEOUT_MS);
47
+ }, BUDGET_MS);
44
48
 
45
49
  if (!result?.reminder) return null;
46
50
 
@@ -11,7 +11,7 @@
11
11
  * vars are used as secondary fallbacks for environments that forward them.
12
12
  */
13
13
 
14
- export type AgentType = "claude" | "cursor" | "codex" | "copilot" | "opencode";
14
+ export type AgentType = "claude" | "cursor" | "codex" | "copilot" | "opencode" | "vscode";
15
15
 
16
16
  const KNOWN_AGENTS: ReadonlySet<AgentType> = new Set([
17
17
  "claude",
@@ -19,14 +19,33 @@ const KNOWN_AGENTS: ReadonlySet<AgentType> = new Set([
19
19
  "codex",
20
20
  "copilot",
21
21
  "opencode",
22
+ "vscode",
22
23
  ]);
23
24
 
25
+ function agentFromEnv(): AgentType | undefined {
26
+ const explicit = process.env.PAL_AGENT;
27
+ return explicit && KNOWN_AGENTS.has(explicit as AgentType)
28
+ ? (explicit as AgentType)
29
+ : undefined;
30
+ }
31
+
32
+ /**
33
+ * `--agent=<name>` on the hook's own command line.
34
+ *
35
+ * An `PAL_AGENT=x cmd` prefix is POSIX-only and an `$env:PAL_AGENT='x'; cmd`
36
+ * prefix is PowerShell-only, so a hook config that guesses the host's shell
37
+ * wrong fails before the hook ever runs. An argv flag is shell-agnostic.
38
+ */
39
+ function agentFromArgv(): AgentType | undefined {
40
+ const flag = process.argv.find((a) => a.startsWith("--agent="));
41
+ const value = flag?.slice("--agent=".length);
42
+ return value && KNOWN_AGENTS.has(value as AgentType) ? (value as AgentType) : undefined;
43
+ }
44
+
24
45
  /** Detect which agent is currently running PAL. Defaults to "claude". */
25
46
  export function getActiveAgent(): AgentType {
26
- const explicit = process.env.PAL_AGENT;
27
- if (explicit && KNOWN_AGENTS.has(explicit as AgentType)) {
28
- return explicit as AgentType;
29
- }
47
+ const declared = agentFromArgv() ?? agentFromEnv();
48
+ if (declared) return declared;
30
49
  if (process.env.CURSOR_VERSION) return "cursor";
31
50
  if (process.env.CODEX_CLI_VERSION ?? process.env.OPENAI_CODEX) return "codex";
32
51
  return "claude";
@@ -37,17 +56,96 @@ export const isCursor = () => getActiveAgent() === "cursor";
37
56
  export const isCodex = () => getActiveAgent() === "codex";
38
57
  export const isCopilot = () => getActiveAgent() === "copilot";
39
58
  export const isOpencode = () => getActiveAgent() === "opencode";
59
+ const isVscode = () => getActiveAgent() === "vscode";
60
+
61
+ /** Normalized preToolUse request — one shape for every agent's payload. */
62
+ export interface ToolUseRequest {
63
+ toolName: string;
64
+ toolInput: Record<string, unknown>;
65
+ hookEventName?: string;
66
+ }
67
+
68
+ function firstString(...values: unknown[]): string | undefined {
69
+ return values.find((v): v is string => typeof v === "string" && v.length > 0);
70
+ }
71
+
72
+ function firstObject(...values: unknown[]): Record<string, unknown> | undefined {
73
+ return values.find(
74
+ (v): v is Record<string, unknown> => typeof v === "object" && v !== null
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Normalize a preToolUse payload across agents.
80
+ *
81
+ * Claude Code, Cursor, Codex — and Copilot's VS Code-compatible mode — send
82
+ * snake_case `tool_name` + `tool_input`. Copilot's native CLI payload sends
83
+ * camelCase `toolName` + `toolArgs`. A hook reading only one shape matches
84
+ * nothing on the other, which for a security hook silently means "allow".
85
+ */
86
+ export function normalizeToolUse(raw: unknown): ToolUseRequest | null {
87
+ const payload = firstObject(raw);
88
+ if (!payload) return null;
89
+ const toolName = firstString(payload.tool_name, payload.toolName);
90
+ if (!toolName) return null;
91
+ return {
92
+ toolName,
93
+ toolInput: firstObject(payload.tool_input, payload.toolArgs, payload.toolInput) ?? {},
94
+ hookEventName: firstString(payload.hook_event_name, payload.hookEventName),
95
+ };
96
+ }
40
97
 
41
98
  /**
42
99
  * Format a "block this action" response for the current agent.
43
- * Claude Code: { decision: "block", reason }
44
- * Cursor preToolUse: { permission: "deny", user_message }
45
- * Codex PreToolUse: { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } }
100
+ * Claude Code / VS Code: both spellings at once — see claudeBlock below
101
+ * Cursor preToolUse: { permission: "deny", user_message }
102
+ * Copilot preToolUse: { permissionDecision: "deny", permissionDecisionReason }
103
+ * Copilot agentStop: { decision: "block", reason }
104
+ * Codex PreToolUse: { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } }
105
+ *
106
+ * A stop event denies the whole turn, not one tool call, so it carries a
107
+ * decision rather than a permission — callers must name the event to get it.
46
108
  */
109
+ function isStopEvent(hookEventName?: string): boolean {
110
+ return hookEventName === "Stop" || hookEventName === "agentStop";
111
+ }
112
+
113
+ /**
114
+ * One payload both Claude Code and VS Code's own Copilot build accept.
115
+ *
116
+ * VS Code reads every decision from inside hookSpecificOutput and ignores the
117
+ * top-level keys; Claude Code reads the top-level keys and ignores the extra
118
+ * object (verified against `claude -p`: a turn carrying both is still blocked).
119
+ * Since VS Code also executes the hooks registered in ~/.claude/settings.json,
120
+ * carrying both spellings here is what lets one registration serve both — a
121
+ * second VS Code-specific hooks file made every event run twice.
122
+ */
123
+ function claudeBlock(reason: string, hookEventName?: string): string {
124
+ return JSON.stringify({
125
+ decision: "block",
126
+ reason,
127
+ hookSpecificOutput: isStopEvent(hookEventName)
128
+ ? { hookEventName: "Stop", decision: "block", reason }
129
+ : {
130
+ hookEventName: "PreToolUse",
131
+ permissionDecision: "deny",
132
+ permissionDecisionReason: reason,
133
+ },
134
+ });
135
+ }
136
+
47
137
  export function blockResponse(reason: string, hookEventName?: string): string {
48
138
  if (isCursor()) {
49
139
  return JSON.stringify({ permission: "deny", user_message: reason });
50
140
  }
141
+ if (isCopilot()) {
142
+ return isStopEvent(hookEventName)
143
+ ? JSON.stringify({ decision: "block", reason })
144
+ : JSON.stringify({
145
+ permissionDecision: "deny",
146
+ permissionDecisionReason: reason,
147
+ });
148
+ }
51
149
  if (isCodex() && hookEventName === "PreToolUse") {
52
150
  return JSON.stringify({
53
151
  hookSpecificOutput: {
@@ -57,5 +155,11 @@ export function blockResponse(reason: string, hookEventName?: string): string {
57
155
  },
58
156
  });
59
157
  }
158
+ // Only the surfaces that share ~/.claude/settings.json need both spellings.
159
+ // Handing the extra key to codex or opencode would be a shape they never
160
+ // asked to parse, for a duplication problem they don't have.
161
+ if (isClaude() || isVscode()) {
162
+ return claudeBlock(reason, hookEventName);
163
+ }
60
164
  return JSON.stringify({ decision: "block", reason });
61
165
  }
@@ -5,6 +5,77 @@
5
5
 
6
6
  import { lstatSync } from "node:fs";
7
7
 
8
+ // PowerShell aliases rm, rmdir, del, erase, rd and ri all to Remove-Item, and
9
+ // cmd ships its own rd and del — so the verb alone never says which shell ran it.
10
+ const WIN_DELETE_VERB = "(?:remove-item|rmdir|erase|del|rd|rm|ri)";
11
+
12
+ // -r through -Recurse all bind in PowerShell; cmd's rd/del spell it /s.
13
+ const WIN_RECURSE_FLAG = String.raw`(?:-(?:r(?:e(?:c(?:u(?:r(?:se?)?)?)?)?)?f?|fr)|/s)\b`;
14
+
15
+ /**
16
+ * A whole root, not a directory inside one. The trailing lookahead is the part
17
+ * that matters: without it `C:\` prefix-matches `C:\Users\rico\dist` and every
18
+ * ordinary recursive delete on Windows gets blocked.
19
+ */
20
+ const WIN_ROOT_TARGET = String.raw`["']?(?:[a-z]:[\\/]?\*?|\\\\|~|\$home|\$env:userprofile|\$env:systemdrive)["']?(?=["'\s;,)]|$)`;
21
+
22
+ const WIN_DOWNLOAD = "(?:iwr|irm|curl|wget|invoke-webrequest|invoke-restmethod)";
23
+ const WIN_EVAL = "(?:iex|invoke-expression)";
24
+
25
+ /**
26
+ * Something is about to be run, rather than merely named. `format` and
27
+ * `diskpart` are bare enough to collide with ordinary text — a PR title reading
28
+ * `fix: format C: handling` or `rg 'diskpart' docs/` are not disk operations.
29
+ * The optional wrapper keeps `powershell -c "format C:"` in scope.
30
+ */
31
+ const SHELL_WRAPPER = String.raw`(?:(?:sudo|powershell(?:\.exe)?|pwsh|cmd(?:\.exe)?)\s+(?:[-/]\w+\s+)*)?`;
32
+ const COMMAND_POSITION = String.raw`(?:^|[|;&\n({])\s*${SHELL_WRAPPER}["']?`;
33
+
34
+ /**
35
+ * Start-Process/runas/gsudo hand the target to a flag (-FilePath, -ArgumentList)
36
+ * or a positional slot after other flags, in either order — COMMAND_POSITION's
37
+ * fixed wrapper-then-verb shape can't follow that. Since nobody launches a
38
+ * process via Start-Process to hold a PR title, an elevation wrapper anywhere in
39
+ * the command is itself enough license to drop the position anchor entirely.
40
+ */
41
+ /**
42
+ * Both lookaheads stop at |, ; and & so they cannot reach across a command
43
+ * boundary — otherwise `rm -r build; echo C:\` reads as a root delete.
44
+ */
45
+ const WIN_ROOT_DELETE = new RegExp(
46
+ String.raw`${COMMAND_POSITION}${WIN_DELETE_VERB}\b(?=[^|;&\n]*\s${WIN_RECURSE_FLAG})(?=[^|;&\n]*\s${WIN_ROOT_TARGET})`,
47
+ "i"
48
+ );
49
+
50
+ const WIN_FORMAT_COMMAND = new RegExp(
51
+ String.raw`${COMMAND_POSITION}format(?:\s+["']?[a-z]:|-volume\b)`,
52
+ "i"
53
+ );
54
+ const WIN_DISKPART_COMMAND = new RegExp(String.raw`${COMMAND_POSITION}diskpart\b`, "i");
55
+
56
+ /**
57
+ * Start-Process/runas/gsudo hand the target to a flag (-FilePath, -ArgumentList)
58
+ * or a positional slot after other flags, in either order — COMMAND_POSITION's
59
+ * fixed wrapper-then-verb shape can't follow that, and a single combined regex
60
+ * can't either: a lookahead only sees forward from the verb, so it misses
61
+ * `Start-Process -Verb RunAs -FilePath diskpart` where the wrapper comes first.
62
+ * Two independent whole-string checks (wrapper present, threat present anywhere)
63
+ * sidestep the ordering problem entirely. Nobody launches a process via
64
+ * Start-Process to hold a PR title, so no position anchor is needed here.
65
+ */
66
+ const WIN_ELEVATION_WRAPPER = /\b(?:start-process|runas|gsudo)\b/i;
67
+ const WIN_ELEVATED_THREATS: [RegExp, string][] = [
68
+ [
69
+ new RegExp(
70
+ String.raw`\b${WIN_DELETE_VERB}\b(?=[^|;&\n]*\s${WIN_RECURSE_FLAG})(?=[^|;&\n]*\s${WIN_ROOT_TARGET})`,
71
+ "i"
72
+ ),
73
+ "Recursive delete of a drive root or home",
74
+ ],
75
+ [/\bformat(?:\s+["']?[a-z]:|-volume\b)/i, "Disk format"],
76
+ [/\bdiskpart\b/i, "Disk partitioning"],
77
+ ];
78
+
8
79
  /** Dangerous command patterns — always blocked */
9
80
  const BLOCKED_COMMANDS: [RegExp, string][] = [
10
81
  [/rm\s+-rf\s+[/~]/, "Recursive delete of root or home"],
@@ -15,6 +86,20 @@ const BLOCKED_COMMANDS: [RegExp, string][] = [
15
86
  [/:\(\)\{\s*:\|:&\s*\};:/, "Fork bomb"],
16
87
  [/curl.*\|\s*(?:ba)?sh/, "Pipe to shell"],
17
88
  [/wget.*\|\s*(?:ba)?sh/, "Pipe to shell"],
89
+ [WIN_ROOT_DELETE, "Recursive delete of a drive root or home"],
90
+ [WIN_FORMAT_COMMAND, "Disk format"],
91
+ [WIN_DISKPART_COMMAND, "Disk partitioning"],
92
+ [
93
+ new RegExp(String.raw`\b${WIN_DOWNLOAD}\b[^|\n]*\|\s*${WIN_EVAL}\b`, "i"),
94
+ "Pipe to shell",
95
+ ],
96
+ [
97
+ new RegExp(
98
+ String.raw`\b${WIN_EVAL}\b[^|\n]*(?:downloadstring|downloadfile|new-object\s+(?:system\.)?net\.webclient|\b${WIN_DOWNLOAD}\b)`,
99
+ "i"
100
+ ),
101
+ "Download and execute",
102
+ ],
18
103
  ];
19
104
 
20
105
  /** Hook-managed files — single source of truth */
@@ -99,6 +184,11 @@ export function checkBashCommand(cmd: string): string | null {
99
184
  for (const [pattern, reason] of BLOCKED_COMMANDS) {
100
185
  if (pattern.test(cmd)) return reason;
101
186
  }
187
+ if (WIN_ELEVATION_WRAPPER.test(cmd)) {
188
+ for (const [pattern, reason] of WIN_ELEVATED_THREATS) {
189
+ if (pattern.test(cmd)) return reason;
190
+ }
191
+ }
102
192
  // If command references a managed file in a managed root path, block unless read-only.
103
193
  // The filename must appear IN the same path as the managed root (e.g. .pal/.../file.json).
104
194
  const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
@@ -20,9 +20,42 @@ export function parseMessages(raw: string): Message[] {
20
20
  }
21
21
  }
22
22
 
23
+ function claudeCodeEntryText(msg: { content?: unknown }): string {
24
+ if (typeof msg.content === "string") return msg.content;
25
+ if (Array.isArray(msg.content)) {
26
+ return msg.content
27
+ .filter((c: { type: string }) => c.type === "text")
28
+ .map((c: { text: string }) => c.text)
29
+ .join(" ");
30
+ }
31
+ return "";
32
+ }
33
+
34
+ // Claude Code tags transcript lines `type: "user"|"assistant"` with the text
35
+ // under `message.content`. VS Code Copilot's own event log instead uses
36
+ // `type: "user.message"|"assistant.message"` with a flat `data.content`
37
+ // string — two shapes sharing one transcript_path contract across agents.
38
+ function parseTranscriptEntry(entry: {
39
+ type?: string;
40
+ message?: { content?: unknown };
41
+ data?: { content?: unknown };
42
+ }): Message | null {
43
+ if (entry.type === "user" || entry.type === "assistant") {
44
+ const text = claudeCodeEntryText(entry.message ?? {});
45
+ return text ? { role: entry.type, content: text } : null;
46
+ }
47
+ if (entry.type === "user.message" || entry.type === "assistant.message") {
48
+ const text = entry.data?.content;
49
+ const role = entry.type === "user.message" ? "user" : "assistant";
50
+ return typeof text === "string" && text ? { role, content: text } : null;
51
+ }
52
+ return null;
53
+ }
54
+
23
55
  /**
24
- * Read a Claude Code transcript JSONL file and extract user/assistant messages.
25
- * Each line is a JSON object; we extract entries with type "user" or "assistant".
56
+ * Read an agent transcript JSONL file and extract user/assistant messages.
57
+ * Supports Claude Code's `{type:"user"|"assistant", message:{content}}` shape
58
+ * and VS Code Copilot's `{type:"user.message"|"assistant.message", data:{content}}` shape.
26
59
  */
27
60
  export function readTranscriptFile(path: string): Message[] {
28
61
  try {
@@ -32,22 +65,8 @@ export function readTranscriptFile(path: string): Message[] {
32
65
  for (const line of content.split("\n")) {
33
66
  if (!line.trim()) continue;
34
67
  try {
35
- const entry = JSON.parse(line);
36
- if (entry.type === "user" || entry.type === "assistant") {
37
- const msg = entry.message ?? {};
38
- let text = "";
39
- if (typeof msg.content === "string") {
40
- text = msg.content;
41
- } else if (Array.isArray(msg.content)) {
42
- text = msg.content
43
- .filter((c: { type: string }) => c.type === "text")
44
- .map((c: { text: string }) => c.text)
45
- .join(" ");
46
- }
47
- if (text) {
48
- messages.push({ role: entry.type, content: text });
49
- }
50
- }
68
+ const parsed = parseTranscriptEntry(JSON.parse(line));
69
+ if (parsed) messages.push(parsed);
51
70
  } catch {
52
71
  /* skip malformed lines */
53
72
  }
@@ -6,25 +6,21 @@
6
6
 
7
7
  import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
- import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
10
9
  import { assets, palHome, palPkg, platform } from "../../hooks/lib/paths";
11
10
  import { identity, raw as readPalSettings } from "../../hooks/lib/settings";
12
11
  import {
13
12
  addStatuslineConfig,
14
13
  applyAttribution,
15
14
  copyAgents,
16
- copyPalDocs,
17
15
  copySkills,
18
16
  copyStatusline,
19
17
  countAgents,
20
18
  countMd,
21
19
  countSkills,
22
- generateSkillIndex,
23
20
  loadSettingsTemplate,
24
21
  log,
25
22
  mergeSettings,
26
23
  readJson,
27
- scaffoldPalSettings,
28
24
  writeJson,
29
25
  } from "../lib";
30
26
 
@@ -68,7 +64,6 @@ log.success("Merged PAL settings into settings.json");
68
64
  // --- Copy skills ---
69
65
  const skillsDir = resolve(CLAUDE_DIR, "skills");
70
66
  copySkills(skillsDir);
71
- generateSkillIndex();
72
67
 
73
68
  // --- Copy agents ---
74
69
  copyAgents();
@@ -76,19 +71,6 @@ copyAgents();
76
71
  // --- Copy statusline script ---
77
72
  copyStatusline();
78
73
 
79
- // --- Copy PAL system docs ---
80
- const palDocsCount = copyPalDocs();
81
- log.success(`Installed ${palDocsCount} PAL docs to ~/.pal/docs/`);
82
-
83
- // --- Scaffold PAL settings ---
84
- scaffoldPalSettings();
85
-
86
- // --- Generate ~/.claude/AGENTS.md and symlink ~/.claude/CLAUDE.md → AGENTS.md ---
87
- regenerateIfNeeded();
88
- log.success("Generated ~/.config/opencode/AGENTS.md (→ ~/.claude/CLAUDE.md symlink)");
89
-
90
- log.success("Claude Code installation complete");
91
- console.log("");
92
- log.info(`Skills: ${countSkills()}`);
93
- log.info(`Agents: ${countAgents()}`);
94
- log.info(`TELOS: ${countMd(resolve(palHome(), "telos"))} files`);
74
+ log.success(
75
+ `${countSkills()} skills · ${countAgents()} agents · ${countMd(resolve(palHome(), "telos"))} TELOS files · CLAUDE.md → AGENTS.md`
76
+ );
@@ -12,20 +12,17 @@ import {
12
12
  writeFileSync,
13
13
  } from "node:fs";
14
14
  import { resolve } from "node:path";
15
- import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
16
15
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
17
16
  import {
18
17
  addCodexStatuslineConfig,
19
18
  copySkills,
20
19
  countSkills,
21
- generateSkillIndex,
22
20
  loadCodexHooksTemplate,
23
21
  loadCodexRulesTemplate,
24
22
  log,
25
23
  mergeCodexHooks,
26
24
  mergeCodexRules,
27
25
  readJson,
28
- scaffoldPalSettings,
29
26
  writeJson,
30
27
  } from "../lib";
31
28
 
@@ -86,7 +83,7 @@ const existing = readJson<Record<string, unknown>>(HOOKS_FILE, {});
86
83
  const merged = mergeCodexHooks(existing, template);
87
84
 
88
85
  writeJson(HOOKS_FILE, merged);
89
- log.success("Merged PAL hooks into ~/.codex/hooks.json");
86
+ log.success(`Merged PAL hooks into ${HOOKS_FILE}`);
90
87
 
91
88
  // --- Merge allowlist rules ---
92
89
  mkdirSync(resolve(CODEX_DIR, "rules"), { recursive: true });
@@ -102,21 +99,9 @@ log.success("Merged PAL allowlist rules into ~/.codex/rules/default.rules");
102
99
  // --- Symlink skills to ~/.codex/skills/ ---
103
100
  const codexSkillsDir = resolve(CODEX_DIR, "skills");
104
101
  copySkills(codexSkillsDir);
105
- generateSkillIndex();
106
-
107
- // --- Scaffold PAL settings ---
108
- scaffoldPalSettings();
109
-
110
- // --- Generate / verify AGENTS.md symlink ---
111
- regenerateIfNeeded();
112
- log.success("Ensured AGENTS.md symlink at ~/.codex/AGENTS.md");
102
+ log.success(`${countSkills()} skills → ~/.codex/skills/`);
113
103
 
114
104
  // --- Enable hooks in config.toml ---
115
105
  const CONFIG_FILE = resolve(CODEX_DIR, "config.toml");
116
106
  enableCodexHooks(CONFIG_FILE);
117
107
  enableCodexStatusline(CONFIG_FILE);
118
-
119
- log.success("Codex installation complete");
120
- console.log("");
121
- log.info(`Skills: ${countSkills()}`);
122
- log.info(`Hooks: ${HOOKS_FILE}`);
@@ -5,21 +5,16 @@
5
5
  * Enables ~/.copilot/instructions in VS Code chat.instructionsFilesLocations.
6
6
  */
7
7
 
8
- import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
- import { writeContextDigests } from "../../hooks/handlers/context-digests";
11
- import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
12
10
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
13
11
  import {
14
12
  copyAgentsForCopilot,
15
- copyPalDocs,
16
13
  copySkills,
17
14
  countSkills,
18
- generateSkillIndex,
19
15
  loadCopilotHooksTemplate,
20
16
  log,
21
17
  readJson,
22
- scaffoldPalSettings,
23
18
  vscodeSettingsFile,
24
19
  writeJson,
25
20
  } from "../lib";
@@ -28,6 +23,7 @@ const PKG_ROOT = palPkg().replaceAll("\\", "/");
28
23
  const COPILOT_DIR = platform.copilotDir();
29
24
  const HOOKS_DIR = resolve(COPILOT_DIR, "hooks");
30
25
  const HOOKS_FILE = resolve(HOOKS_DIR, "pal-hooks.json");
26
+ const VSCODE_HOOKS_FILE = resolve(HOOKS_DIR, "pal-vscode-hooks.json");
31
27
 
32
28
  // --- Ensure dirs ---
33
29
  mkdirSync(HOOKS_DIR, { recursive: true });
@@ -37,34 +33,24 @@ const template = loadCopilotHooksTemplate(assets.copilotHooksTemplate(), PKG_ROO
37
33
  writeFileSync(HOOKS_FILE, `${JSON.stringify(template, null, 2)}\n`, "utf-8");
38
34
  log.success(`Written hooks to ${HOOKS_FILE}`);
39
35
 
36
+ // --- Retire the separate VS Code hooks file ---
37
+ // VS Code's own Copilot build already executes the PascalCase hooks in
38
+ // ~/.claude/settings.json, so registering the same events here too ran every
39
+ // hook twice per turn. One dual-shape block payload (see lib/agent.ts) now
40
+ // serves both surfaces from that single registration.
41
+ if (existsSync(VSCODE_HOOKS_FILE)) {
42
+ unlinkSync(VSCODE_HOOKS_FILE);
43
+ log.success("Removed pal-vscode-hooks.json (VS Code runs the Claude hooks)");
44
+ }
45
+
40
46
  // --- Install skills ---
41
47
  const copilotSkillsDir = resolve(COPILOT_DIR, "skills");
42
48
  copySkills(copilotSkillsDir);
43
- generateSkillIndex();
44
- log.success("Installed skills to ~/.copilot/skills/");
45
49
 
46
50
  // --- Install agents ---
47
51
  const copilotAgentsDir = resolve(COPILOT_DIR, "agents");
48
52
  const agentCount = copyAgentsForCopilot(copilotAgentsDir);
49
- if (agentCount > 0) log.success(`Installed ${agentCount} agents to ~/.copilot/agents/`);
50
-
51
- // --- Copy PAL docs ---
52
- const palDocsCount = copyPalDocs();
53
- log.success(`Installed ${palDocsCount} PAL docs to ~/.pal/docs/`);
54
-
55
- // --- Scaffold PAL settings ---
56
- scaffoldPalSettings();
57
-
58
- // --- Generate AGENTS.md ---
59
- regenerateIfNeeded();
60
- log.success("Generated AGENTS.md");
61
-
62
- // --- Write ~/.copilot/instructions/pal-*.instructions.md ---
63
- mkdirSync(resolve(COPILOT_DIR, "instructions"), { recursive: true });
64
- writeContextDigests();
65
- log.success(
66
- "Written ~/.copilot/instructions/pal-self-model + pal-wisdom + pal-opinions.instructions.md"
67
- );
53
+ log.success(`${countSkills()} skills · ${agentCount} agents ~/.copilot/`);
68
54
 
69
55
  // --- Enable ~/.copilot/instructions in VS Code settings ---
70
56
  const vsSettingsPath = vscodeSettingsFile();
@@ -90,8 +76,3 @@ if (vsSettingsPath) {
90
76
  } else {
91
77
  log.warn(`Could not detect VS Code settings path — ${manualHint}`);
92
78
  }
93
-
94
- log.success("Copilot installation complete");
95
- console.log("");
96
- log.info(`Skills: ${countSkills()}`);
97
- log.info(`Hooks: ${HOOKS_FILE}`);
@@ -20,16 +20,22 @@ import {
20
20
 
21
21
  const COPILOT_DIR = platform.copilotDir();
22
22
  const HOOKS_FILE = resolve(COPILOT_DIR, "hooks", "pal-hooks.json");
23
+ const VSCODE_HOOKS_FILE = resolve(COPILOT_DIR, "hooks", "pal-vscode-hooks.json");
23
24
 
24
- // --- Remove hooks file ---
25
- if (existsSync(HOOKS_FILE)) {
26
- copyFileSync(HOOKS_FILE, `${HOOKS_FILE}.bak.${Date.now()}`);
27
- unlinkSync(HOOKS_FILE);
28
- log.success("Removed pal-hooks.json");
29
- } else {
30
- log.info("No pal-hooks.json found, nothing to do");
25
+ // --- Remove hooks files ---
26
+ function removeHooksFile(path: string, label: string): void {
27
+ if (!existsSync(path)) {
28
+ log.info(`No ${label} found, nothing to do`);
29
+ return;
30
+ }
31
+ copyFileSync(path, `${path}.bak.${Date.now()}`);
32
+ unlinkSync(path);
33
+ log.success(`Removed ${label}`);
31
34
  }
32
35
 
36
+ removeHooksFile(HOOKS_FILE, "pal-hooks.json");
37
+ removeHooksFile(VSCODE_HOOKS_FILE, "pal-vscode-hooks.json");
38
+
33
39
  // --- Remove skill symlinks ---
34
40
  const copilotSkillsDir = resolve(COPILOT_DIR, "skills");
35
41
  const removed = removeSkills(copilotSkillsDir);
@@ -6,22 +6,17 @@
6
6
 
7
7
  import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
- import { writeContextDigests } from "../../hooks/handlers/context-digests";
10
- import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
11
9
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
12
10
  import {
13
11
  addStatuslineConfig,
14
12
  copyAgentsForCursor,
15
- copyPalDocs,
16
13
  copySkills,
17
14
  copyStatusline,
18
15
  countSkills,
19
- generateSkillIndex,
20
16
  loadCursorHooksTemplate,
21
17
  log,
22
18
  mergeCursorHooks,
23
19
  readJson,
24
- scaffoldPalSettings,
25
20
  writeJson,
26
21
  } from "../lib";
27
22
 
@@ -45,24 +40,16 @@ const existing = readJson<Record<string, unknown>>(HOOKS_FILE, {});
45
40
  const merged = mergeCursorHooks(existing, template);
46
41
 
47
42
  writeJson(HOOKS_FILE, merged);
48
- log.success("Merged PAL hooks into hooks.json");
43
+ log.success(`Merged PAL hooks into ${HOOKS_FILE}`);
49
44
 
50
45
  // --- Symlink skills to ~/.cursor/skills/ ---
51
46
  const cursorSkillsDir = resolve(CURSOR_DIR, "skills");
52
47
  copySkills(cursorSkillsDir);
53
- generateSkillIndex();
54
48
 
55
49
  // --- Copy agents to ~/.cursor/agents/ ---
56
50
  const cursorAgentsDir = resolve(CURSOR_DIR, "agents");
57
51
  const agentCount = copyAgentsForCursor(cursorAgentsDir);
58
- if (agentCount > 0) log.success(`Installed ${agentCount} agents to ~/.cursor/agents/`);
59
-
60
- // --- Copy PAL system docs ---
61
- const palDocsCount = copyPalDocs();
62
- log.success(`Installed ${palDocsCount} PAL docs to ~/.pal/docs/`);
63
-
64
- // --- Scaffold PAL settings ---
65
- scaffoldPalSettings();
52
+ log.success(`${countSkills()} skills · ${agentCount} agents ~/.cursor/`);
66
53
 
67
54
  // --- Statusline script + cli-config.json statusLine ---
68
55
  copyStatusline("cursor");
@@ -77,21 +64,6 @@ const cliConfig = readJson<Record<string, unknown>>(CLI_CONFIG, {});
77
64
  writeJson(CLI_CONFIG, addStatuslineConfig(cliConfig, "cursor"));
78
65
  log.success("Merged statusLine into cli-config.json");
79
66
 
80
- // --- Generate AGENTS.md ---
81
- regenerateIfNeeded();
82
- log.success("Generated AGENTS.md");
83
-
84
- // --- Write ~/.cursor/rules/pal-*.mdc ---
85
- mkdirSync(resolve(CURSOR_DIR, "rules"), { recursive: true });
86
- writeContextDigests();
87
- log.success(
88
- "Written ~/.cursor/rules/pal-self-model.mdc + pal-wisdom.mdc + pal-opinions.mdc"
89
- );
90
-
91
- log.success("Cursor installation complete");
92
- console.log("");
93
- log.info(`Skills: ${countSkills()}`);
94
- log.info(`Hooks: ${HOOKS_FILE}`);
95
67
  log.info(
96
68
  "Note: Cursor tool matchers may need tuning — verify hook behavior after first use"
97
69
  );
@@ -741,6 +741,23 @@ export function removePalDocs(): void {
741
741
 
742
742
  const PAL_SKILLS_DIR = resolve(palHome(), "skills");
743
743
 
744
+ /**
745
+ * Run one step of a bulk install, naming it only when it fails.
746
+ *
747
+ * Installs handle dozens of skills and agents; a line each buries the paths,
748
+ * backups and warnings that a reader actually has to act on. Returning false
749
+ * instead of throwing also keeps one unlinkable skill from aborting the rest.
750
+ */
751
+ function reportOnlyOnFailure(label: string, install: () => void): boolean {
752
+ try {
753
+ install();
754
+ return true;
755
+ } catch (e) {
756
+ log.warn(`Could not install ${label} — ${(e as Error).message}`);
757
+ return false;
758
+ }
759
+ }
760
+
744
761
  /**
745
762
  * Install PAL skills by symlinking:
746
763
  * ~/.pal/skills/<name> → <repo>/assets/skills/<name> (source of truth)
@@ -762,16 +779,15 @@ export function copySkills(claudeSkillsDir: string): number {
762
779
  const srcDir = resolve(skillsDir, name);
763
780
  if (!existsSync(resolve(srcDir, "SKILL.md"))) continue;
764
781
 
765
- // ~/.pal/skills/<name> → <repo>/assets/skills/<name>
766
782
  const palLink = resolve(PAL_SKILLS_DIR, name);
767
- ensureSymlink(palLink, srcDir, linkType);
768
-
769
- // ~/.claude/skills/<name> → ~/.pal/skills/<name>
770
783
  const claudeLink = resolve(claudeSkillsDir, name);
771
- ensureSymlink(claudeLink, palLink, linkType);
772
-
773
- log.info(`Linked skill: ${name}`);
774
- count++;
784
+ const linked = reportOnlyOnFailure(`skill ${name}`, () => {
785
+ // ~/.pal/skills/<name> → <repo>/assets/skills/<name>
786
+ ensureSymlink(palLink, srcDir, linkType);
787
+ // ~/.claude/skills/<name> → ~/.pal/skills/<name>
788
+ ensureSymlink(claudeLink, palLink, linkType);
789
+ });
790
+ if (linked) count++;
775
791
  }
776
792
 
777
793
  // ~/.agents/skills/ → ~/.pal/skills/
@@ -1000,14 +1016,16 @@ function installAgents(targetDir: string, platform: AgentPlatform): number {
1000
1016
  let count = 0;
1001
1017
 
1002
1018
  for (const file of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
1003
- const content = readFileSync(resolve(agentsDir, file), "utf-8");
1004
- writeFileSync(
1005
- resolve(targetDir, file),
1006
- extractAgentForPlatform(content, platform),
1007
- "utf-8"
1008
- );
1009
- log.info(`Installed ${platform} agent: ${file.replace(/\.md$/, "")}`);
1010
- count++;
1019
+ const name = file.replace(/\.md$/, "");
1020
+ const installed = reportOnlyOnFailure(`${platform} agent ${name}`, () => {
1021
+ const content = readFileSync(resolve(agentsDir, file), "utf-8");
1022
+ writeFileSync(
1023
+ resolve(targetDir, file),
1024
+ extractAgentForPlatform(content, platform),
1025
+ "utf-8"
1026
+ );
1027
+ });
1028
+ if (installed) count++;
1011
1029
  }
1012
1030
  return count;
1013
1031
  }
@@ -1375,7 +1393,6 @@ export function generateSkillIndex(): number {
1375
1393
  const stateDir = resolve(palHome(), "memory", "state");
1376
1394
  mkdirSync(stateDir, { recursive: true });
1377
1395
  writeJson(resolve(stateDir, "skill-index.json"), index);
1378
- log.info(`Skill index: ${index.totalSkills} skills indexed`);
1379
1396
 
1380
1397
  return index.totalSkills;
1381
1398
  }
@@ -12,18 +12,9 @@ import {
12
12
  writeFileSync,
13
13
  } from "node:fs";
14
14
  import { resolve } from "node:path";
15
- import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
16
15
  import { palPkg, platform } from "../../hooks/lib/paths";
17
16
  import { getSemiStaticSources } from "../../hooks/lib/semi-static";
18
- import {
19
- copyAgentsForOpencode,
20
- copyPalDocs,
21
- copySkills,
22
- countSkills,
23
- generateSkillIndex,
24
- log,
25
- writeJson,
26
- } from "../lib";
17
+ import { copyAgentsForOpencode, copySkills, countSkills, log, writeJson } from "../lib";
27
18
 
28
19
  const PKG_ROOT = palPkg();
29
20
  const OC_GLOBAL_DIR = platform.opencodeDir();
@@ -55,7 +46,6 @@ if (!existsSync(pkgPath)) {
55
46
 
56
47
  try {
57
48
  Bun.spawnSync(["bun", "install", "--silent"], { cwd: OC_PLUGINS_DIR });
58
- log.success("Installed plugin dependencies");
59
49
  } catch {
60
50
  log.warn(`Could not install plugin deps — run 'bun install' in ${OC_PLUGINS_DIR}`);
61
51
  }
@@ -63,22 +53,13 @@ try {
63
53
  // --- 3. Install skills into ~/.pal/skills/ ---
64
54
  const claudeSkillsDir = resolve(platform.claudeDir(), "skills");
65
55
  copySkills(claudeSkillsDir);
66
- generateSkillIndex();
67
- log.success("Installed skills to ~/.pal/skills/");
68
56
 
69
57
  // --- 4. Install agents into ~/.config/opencode/agents/ ---
70
58
  const ocAgentsDir = resolve(OC_GLOBAL_DIR, "agents");
71
- copyAgentsForOpencode(ocAgentsDir);
72
-
73
- // --- 5. Copy PAL system docs ---
74
- const palDocsCount = copyPalDocs();
75
- log.success(`Installed ${palDocsCount} PAL docs to ~/.pal/docs/`);
76
-
77
- // --- 6. Generate ~/.config/opencode/AGENTS.md ---
78
- regenerateIfNeeded();
79
- log.success("Generated ~/.config/opencode/AGENTS.md");
59
+ const agentCount = copyAgentsForOpencode(ocAgentsDir);
60
+ log.success(`${countSkills()} skills · ${agentCount} agents → ~/.config/opencode/`);
80
61
 
81
- // --- 7. Add semi-static digest files to instructions[] in config.json ---
62
+ // --- 5. Add semi-static digest files to instructions[] in config.json ---
82
63
  const configPath = resolve(OC_GLOBAL_DIR, "config.json");
83
64
  const staticFiles = getSemiStaticSources().map((s) => s.path);
84
65
  let ocConfig: Record<string, unknown> = {};
@@ -94,11 +75,3 @@ const existingInstructions = Array.isArray(ocConfig.instructions)
94
75
  : [];
95
76
  ocConfig.instructions = [...new Set([...existingInstructions, ...staticFiles])];
96
77
  writeFileSync(configPath, `${JSON.stringify(ocConfig, null, 2)}\n`, "utf-8");
97
- log.success(
98
- `Updated config.json: ${(ocConfig.instructions as string[]).length} instructions`
99
- );
100
-
101
- log.success("opencode installation complete");
102
- console.log("");
103
- log.info(`Plugin: ${pluginDst}`);
104
- log.info(`Skills: ${countSkills()} (native via ~/.pal/skills/)`);