portable-agent-layer 0.63.0 → 0.63.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.
@@ -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}`);
@@ -7,11 +7,11 @@
7
7
  import { copyFileSync, existsSync, lstatSync, readlinkSync, unlinkSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { platform } from "../../hooks/lib/paths";
10
- import { copilotFilename, getSemiStaticSources } from "../../hooks/lib/semi-static";
11
10
  import {
12
11
  log,
13
12
  readJson,
14
13
  removeAgentsFromCopilot,
14
+ removePalContextFiles,
15
15
  removePalDocs,
16
16
  removeSkills,
17
17
  vscodeSettingsFile,
@@ -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);
@@ -49,20 +55,13 @@ if (removedAgents.length > 0) {
49
55
  removePalDocs();
50
56
 
51
57
  // --- Remove ~/.copilot/instructions/pal-*.instructions.md ---
52
- for (const src of getSemiStaticSources()) {
53
- try {
54
- unlinkSync(resolve(COPILOT_DIR, "instructions", copilotFilename(src)));
55
- } catch {
56
- /* gone */
57
- }
58
- }
59
- // pal-session.instructions.md is written by LoadContext (not a semi-static source)
60
- try {
61
- unlinkSync(resolve(COPILOT_DIR, "instructions", "pal-session.instructions.md"));
62
- } catch {
63
- /* gone */
64
- }
65
- log.success("Removed ~/.copilot/instructions/pal-*.instructions.md");
58
+ const removedInstructions = removePalContextFiles(
59
+ resolve(COPILOT_DIR, "instructions"),
60
+ ".instructions.md"
61
+ );
62
+ log.success(
63
+ `Removed ${removedInstructions.length} ~/.copilot/instructions/pal-*.instructions.md`
64
+ );
66
65
 
67
66
  // --- Backward compat: remove old copilot-instructions.md symlink if present ---
68
67
  const legacyPath = resolve(COPILOT_DIR, "copilot-instructions.md");
@@ -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
  );