portable-agent-layer 0.84.2 → 0.84.4

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.
@@ -263,7 +263,7 @@ Every agent's turns are measured and its replies filed under the session id the
263
263
  | Cursor | `beforeSubmitPrompt` → `additional_context`, undocumented | `afterAgentResponse` `text` (`AgentResponse.ts`) |
264
264
  | Copilot CLI | `userPromptTransformed` → `modifiedTransformedPrompt` (`PromptTransformed.ts`) | `agentStop` transcript |
265
265
 
266
- The Copilot CLI drops what its `userPromptSubmitted` hook returns, so that hook only measures the turn and parks the context; `userPromptTransformed` fires next and appends it to the prompt the model receives. It also writes the reply to the `agentStop` transcript only after that hook returns, so a stop whose transcript still ends on the user's message hands off to a detached `StopDeferred.ts`, which waits for the reply before running the stop handlers. Cursor documents no context field on `beforeSubmitPrompt`, so PAL offers `additional_context` there but logs no interaction hint as sent until it is shown to reach the model; its documented context channel is session start. `test/agent-turns.test.ts` holds each agent to this table.
266
+ The Copilot CLI drops what its `userPromptSubmitted` hook returns, so that hook only measures the turn and parks the context; `userPromptTransformed` fires next and appends it to the prompt the model receives. It also writes the reply to the `agentStop` transcript only after that hook returns, so a stop whose transcript still ends on the user's message hands off to a detached `StopDeferred.ts`, which waits for the reply before running the stop handlers. Cursor documents no context field on `beforeSubmitPrompt`, so PAL offers `additional_context` there but logs no interaction hint as sent until it is shown to reach the model; its documented context channel is session start. cursor-agent also runs every hook in `~/.claude/settings.json`, so when PAL's own Cursor hooks are installed, the copy registered with `--agent=claude` exits at once (`lib/cursor-shadow.ts`) and each Cursor event runs PAL once. `test/agent-turns.test.ts` holds each agent to this table.
267
267
 
268
268
  ### Design Principles
269
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.84.2",
3
+ "version": "0.84.4",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,12 +14,14 @@ import {
14
14
  isConsumable,
15
15
  type SavedExchange,
16
16
  } from "./lib/compact-recall";
17
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
17
18
  import { logDebug, logError } from "./lib/log";
18
19
  import { isPalSpawnedInference } from "./lib/spawn-guard";
19
20
  import { readStdinJSON } from "./lib/stdin";
20
21
 
21
22
  // Recursion guard — spawned subprocesses don't compact, so nothing to recover.
22
23
  if (isPalSpawnedInference()) process.exit(0);
24
+ if (duplicatesCursorHooks()) process.exit(0);
23
25
 
24
26
  interface SessionStartInput {
25
27
  session_id?: string;
@@ -10,10 +10,13 @@
10
10
  * Silent and fail-open, for the same reason as its other half.
11
11
  */
12
12
 
13
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
13
14
  import { commitApplied, ledgeredCalls } from "./lib/ledger-hook";
14
15
  import { logDebug } from "./lib/log";
15
16
  import { readStdinJSON } from "./lib/stdin";
16
17
 
18
+ if (duplicatesCursorHooks()) process.exit(0);
19
+
17
20
  try {
18
21
  const input = await readStdinJSON<Record<string, unknown>>();
19
22
  if (!input) process.exit(0);
@@ -10,10 +10,13 @@
10
10
  * that could block an edit would be a worse thing than a ledger with a gap.
11
11
  */
12
12
 
13
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
13
14
  import { ledgeredCalls, snapshotCall } from "./lib/ledger-hook";
14
15
  import { logDebug } from "./lib/log";
15
16
  import { readStdinJSON } from "./lib/stdin";
16
17
 
18
+ if (duplicatesCursorHooks()) process.exit(0);
19
+
17
20
  try {
18
21
  const input = await readStdinJSON<Record<string, unknown>>();
19
22
  if (!input) process.exit(0);
@@ -20,11 +20,14 @@
20
20
  * Silent and fail-open, for the same reason as the other halves.
21
21
  */
22
22
 
23
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
23
24
  import { reapStalePending } from "./lib/ledger";
24
25
  import { commitUnapplied, ledgeredCalls, unappliedVerdictOf } from "./lib/ledger-hook";
25
26
  import { logDebug } from "./lib/log";
26
27
  import { readStdinJSON } from "./lib/stdin";
27
28
 
29
+ if (duplicatesCursorHooks()) process.exit(0);
30
+
28
31
  try {
29
32
  const input = await readStdinJSON<Record<string, unknown>>();
30
33
  if (!input) process.exit(0);
@@ -14,6 +14,7 @@ import { getActiveAgent } from "./lib/agent";
14
14
  import { autoUpdateOnStart } from "./lib/auto-update";
15
15
  import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
16
16
  import { type AgentTarget, buildSystemReminder } from "./lib/context";
17
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
17
18
  import { logContextSnapshot, logDebug, logError } from "./lib/log";
18
19
  import { platform } from "./lib/paths";
19
20
  import {
@@ -29,7 +30,7 @@ import { readStdinJSON } from "./lib/stdin";
29
30
  // Recursion guard — when this process is a PAL-spawned inference subprocess,
30
31
  // skip all context loading so we don't trigger another inference call.
31
32
  if (isPalSpawnedInference()) process.exit(0);
32
-
33
+ if (duplicatesCursorHooks()) process.exit(0);
33
34
  if (isSubagentSession(process.env)) {
34
35
  logDebug("LoadContext", "Subagent session — skipping context loading");
35
36
  process.exit(0);
@@ -15,6 +15,7 @@ import { existsSync } from "node:fs";
15
15
  import { readFile } from "node:fs/promises";
16
16
  import { resolve } from "node:path";
17
17
  import { persistLastExchange } from "./handlers/persist-last-exchange";
18
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
18
19
  import { logDebug, logError } from "./lib/log";
19
20
  import { paths } from "./lib/paths";
20
21
  import { sessionDir } from "./lib/session-dir";
@@ -24,6 +25,7 @@ import { readTranscriptFile } from "./lib/transcript";
24
25
 
25
26
  // Recursion guard — spawned subprocesses don't compact, so nothing to persist.
26
27
  if (isPalSpawnedInference()) process.exit(0);
28
+ if (duplicatesCursorHooks()) process.exit(0);
27
29
 
28
30
  interface PreCompactInput {
29
31
  session_id?: string;
@@ -17,8 +17,11 @@
17
17
  */
18
18
 
19
19
  import { getActiveAgent } from "./lib/agent";
20
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
20
21
  import { findBinaryOnPath } from "./lib/which";
21
22
 
23
+ if (duplicatesCursorHooks()) process.exit(0);
24
+
22
25
  const RTK_HOOK_SUBCOMMAND: Partial<Record<ReturnType<typeof getActiveAgent>, string>> = {
23
26
  claude: "claude",
24
27
  cursor: "cursor",
@@ -8,11 +8,14 @@
8
8
  */
9
9
 
10
10
  import { blockResponse } from "./lib/agent";
11
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
11
12
  import { recordBlocked } from "./lib/ledger";
12
13
  import { logError } from "./lib/log";
13
14
  import { decideRefusal, type SecurityInput } from "./lib/security-gate";
14
15
  import { readStdinJSON } from "./lib/stdin";
15
16
 
17
+ if (duplicatesCursorHooks()) process.exit(0);
18
+
16
19
  try {
17
20
  const input = await readStdinJSON<SecurityInput>();
18
21
  if (!input) process.exit(0);
@@ -11,11 +11,13 @@
11
11
  */
12
12
 
13
13
  import { autoUpdateOnClose } from "./lib/auto-update";
14
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
14
15
  import { logError } from "./lib/log";
15
16
  import { isPalSpawnedInference } from "./lib/spawn-guard";
16
17
  import { readStdinJSON } from "./lib/stdin";
17
18
 
18
19
  if (isPalSpawnedInference()) process.exit(0);
20
+ if (duplicatesCursorHooks()) process.exit(0);
19
21
 
20
22
  try {
21
23
  const input = await readStdinJSON<{ reason?: string }>();
@@ -10,8 +10,11 @@
10
10
  */
11
11
 
12
12
  import { blockResponse, normalizeToolUse } from "./lib/agent";
13
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
13
14
  import { readStdinJSON } from "./lib/stdin";
14
15
 
16
+ if (duplicatesCursorHooks()) process.exit(0);
17
+
15
18
  const BLOCKED_SKILLS = ["keybindings-help"];
16
19
 
17
20
  try {
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { checkReadmeSync } from "./handlers/readme-sync";
9
9
  import { blockResponse, isCodex, isCursor } from "./lib/agent";
10
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
10
11
  import { logError } from "./lib/log";
11
12
  import { isPalSpawnedInference } from "./lib/spawn-guard";
12
13
  import { readStdinJSON } from "./lib/stdin";
@@ -15,6 +16,7 @@ import { type StopTurnPayload, stopTurn } from "./lib/stop";
15
16
  // Recursion guard — spawned inference subprocesses must not record session
16
17
  // learning, ratings, or handoffs from their throwaway transcript.
17
18
  if (isPalSpawnedInference()) process.exit(0);
19
+ if (duplicatesCursorHooks()) process.exit(0);
18
20
 
19
21
  // Check README sync before anything else — may block the session
20
22
  try {
@@ -10,6 +10,7 @@
10
10
  import { injectPromptContext } from "./handlers/inject-retrieval";
11
11
  import { captureRating } from "./handlers/rating";
12
12
  import { captureSessionName } from "./handlers/session-name";
13
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
13
14
  import { type HookTurnPayload, hookSessionId } from "./lib/hook-turn";
14
15
  import { logDebug, logError, logPromptSnapshot } from "./lib/log";
15
16
  import { isPalSpawnedInference } from "./lib/spawn-guard";
@@ -18,6 +19,7 @@ import { readStdinJSON } from "./lib/stdin";
18
19
  // Recursion guard — the "prompt" inside a spawned inference is the dispatcher's
19
20
  // payload, not a real user message. Skip rating capture, session naming, etc.
20
21
  if (isPalSpawnedInference()) process.exit(0);
22
+ if (duplicatesCursorHooks()) process.exit(0);
21
23
 
22
24
  interface PromptSubmitInput extends HookTurnPayload {
23
25
  prompt: string;
@@ -41,7 +41,7 @@ function agentFromEnv(): AgentType | undefined {
41
41
  * prefix is PowerShell-only, so a hook config that guesses the host's shell
42
42
  * wrong fails before the hook ever runs. An argv flag is shell-agnostic.
43
43
  */
44
- function agentFromArgv(): AgentType | undefined {
44
+ export function agentFromArgv(): AgentType | undefined {
45
45
  const flag = process.argv.find((a) => a.startsWith("--agent="));
46
46
  const value = flag?.slice("--agent=".length);
47
47
  return value && KNOWN_AGENTS.has(value as AgentType) ? (value as AgentType) : undefined;
@@ -52,7 +52,7 @@ function agentFromArgv(): AgentType | undefined {
52
52
  * nothing and appears on no other surface, but it is not the primary signal —
53
53
  * it is absent from the session env that hook children inherit.
54
54
  */
55
- function inCursorAgent(): boolean {
55
+ export function inCursorAgent(): boolean {
56
56
  return Boolean(
57
57
  process.env.CURSOR_AGENT ??
58
58
  process.env.CURSOR_VERSION ??
@@ -0,0 +1,31 @@
1
+ /**
2
+ * cursor-agent runs every hook in ~/.claude/settings.json next to its own
3
+ * ~/.cursor/hooks.json. With PAL registered in both, each Cursor prompt ran PAL
4
+ * twice, so the copy started from the Claude config stands down.
5
+ */
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { agentFromArgv, inCursorAgent } from "./agent";
10
+ import { platform } from "./paths";
11
+
12
+ const PAL_HOOK = /\/src\/hooks\/\w+\.ts\b/;
13
+
14
+ function cursorRunsPalHooks(): boolean {
15
+ try {
16
+ return PAL_HOOK.test(
17
+ readFileSync(resolve(platform.cursorDir(), "hooks.json"), "utf-8")
18
+ );
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ function isClaudeRegistrationInCursor(): boolean {
25
+ return agentFromArgv() === "claude" && inCursorAgent();
26
+ }
27
+
28
+ /** True for the Claude-config copy of a hook that Cursor's own PAL hooks also run. */
29
+ export function duplicatesCursorHooks(): boolean {
30
+ return isClaudeRegistrationInCursor() && cursorRunsPalHooks();
31
+ }
@@ -14,10 +14,9 @@ import {
14
14
  countSkills,
15
15
  loadCopilotHooksTemplate,
16
16
  log,
17
- readJson,
18
17
  vscodeSettingsFile,
19
- writeJson,
20
18
  } from "../lib";
19
+ import { enableCopilotInstructions } from "../vscode-settings";
21
20
 
22
21
  const PKG_ROOT = palPkg().replaceAll("\\", "/");
23
22
  const COPILOT_DIR = platform.copilotDir();
@@ -58,18 +57,15 @@ const manualHint =
58
57
  'Add manually: { "chat.instructionsFilesLocations": { "~/.copilot/instructions": true } }';
59
58
  if (vsSettingsPath) {
60
59
  try {
61
- const settings = readJson<Record<string, unknown>>(vsSettingsPath, {});
62
- const existing =
63
- typeof settings["chat.instructionsFilesLocations"] === "object" &&
64
- settings["chat.instructionsFilesLocations"] !== null
65
- ? (settings["chat.instructionsFilesLocations"] as Record<string, unknown>)
66
- : {};
67
- settings["chat.instructionsFilesLocations"] = {
68
- ...existing,
69
- "~/.copilot/instructions": true,
70
- };
71
- writeJson(vsSettingsPath, settings);
72
- log.success("Enabled ~/.copilot/instructions in VS Code settings");
60
+ const outcome = enableCopilotInstructions(vsSettingsPath);
61
+ if (outcome === "enabled")
62
+ log.success("Enabled ~/.copilot/instructions in VS Code settings");
63
+ if (outcome === "needs-manual-edit")
64
+ log.warn(
65
+ `VS Code settings aren't plain JSON, so PAL left them untouched — ${manualHint}`
66
+ );
67
+ if (outcome === "vscode-never-launched")
68
+ log.info("Skipped VS Code settings — VS Code has not been launched yet");
73
69
  } catch {
74
70
  log.warn(`Could not update VS Code settings — ${manualHint}`);
75
71
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * VS Code's settings.json is JSONC. PAL rewrites it only when it parses as
3
+ * strict JSON, because a rewrite would drop the user's comments.
4
+ */
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { writeJson } from "./lib";
9
+
10
+ const LOCATIONS_KEY = "chat.instructionsFilesLocations";
11
+ const COPILOT_INSTRUCTIONS = "~/.copilot/instructions";
12
+
13
+ type Settings = Record<string, unknown>;
14
+
15
+ type InstructionsOutcome =
16
+ | "enabled"
17
+ | "already-enabled"
18
+ | "needs-manual-edit"
19
+ | "vscode-never-launched";
20
+
21
+ function parsed(raw: string, parse: (text: string) => unknown): Settings | null {
22
+ try {
23
+ const value = parse(raw);
24
+ return typeof value === "object" && value !== null && !Array.isArray(value)
25
+ ? (value as Settings)
26
+ : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function locationsOf(settings: Settings): Settings {
33
+ const locations = settings[LOCATIONS_KEY];
34
+ return typeof locations === "object" && locations !== null
35
+ ? (locations as Settings)
36
+ : {};
37
+ }
38
+
39
+ function writeEnabled(settingsPath: string, settings: Settings): InstructionsOutcome {
40
+ writeJson(settingsPath, {
41
+ ...settings,
42
+ [LOCATIONS_KEY]: { ...locationsOf(settings), [COPILOT_INSTRUCTIONS]: true },
43
+ });
44
+ return "enabled";
45
+ }
46
+
47
+ export function enableCopilotInstructions(settingsPath: string): InstructionsOutcome {
48
+ if (!existsSync(dirname(settingsPath))) return "vscode-never-launched";
49
+ if (!existsSync(settingsPath)) return writeEnabled(settingsPath, {});
50
+ const raw = readFileSync(settingsPath, "utf-8");
51
+ const settings = parsed(raw, Bun.JSONC.parse);
52
+ if (settings && locationsOf(settings)[COPILOT_INSTRUCTIONS] === true)
53
+ return "already-enabled";
54
+ const strict = parsed(raw, JSON.parse);
55
+ return strict ? writeEnabled(settingsPath, strict) : "needs-manual-edit";
56
+ }