portable-agent-layer 0.84.3 → 0.84.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
- package/package.json +1 -1
- package/src/hooks/CompactRecover.ts +2 -0
- package/src/hooks/LedgerCommit.ts +3 -0
- package/src/hooks/LedgerSnapshot.ts +3 -0
- package/src/hooks/LedgerUnapplied.ts +3 -0
- package/src/hooks/LoadContext.ts +2 -1
- package/src/hooks/PreCompactPersist.ts +2 -0
- package/src/hooks/RtkWrap.ts +3 -0
- package/src/hooks/SecurityValidator.ts +3 -0
- package/src/hooks/SessionClose.ts +2 -0
- package/src/hooks/SkillGuard.ts +3 -0
- package/src/hooks/StopOrchestrator.ts +2 -0
- package/src/hooks/UserPromptOrchestrator.ts +2 -0
- package/src/hooks/lib/agent.ts +2 -2
- package/src/hooks/lib/cursor-shadow.ts +31 -0
- package/src/hooks/lib/transcript.ts +32 -2
|
@@ -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
|
@@ -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);
|
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -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;
|
package/src/hooks/RtkWrap.ts
CHANGED
|
@@ -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 }>();
|
package/src/hooks/SkillGuard.ts
CHANGED
|
@@ -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;
|
package/src/hooks/lib/agent.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -31,15 +31,44 @@ function claudeCodeEntryText(msg: { content?: unknown }): string {
|
|
|
31
31
|
return "";
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
interface CodexPayload {
|
|
35
|
+
role?: string;
|
|
36
|
+
content?: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CODEX_INJECTED_CONTEXT = ["# AGENTS.md instructions", "<environment_context>"];
|
|
40
|
+
|
|
41
|
+
function isCodexInjectedContext(text: string): boolean {
|
|
42
|
+
return CODEX_INJECTED_CONTEXT.some((prefix) => text.startsWith(prefix));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function codexTexts(content: unknown): string[] {
|
|
46
|
+
if (!Array.isArray(content)) return [];
|
|
47
|
+
return content
|
|
48
|
+
.map((part) => part?.text)
|
|
49
|
+
.filter((text): text is string => typeof text === "string" && text.length > 0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function codexMessage(payload: CodexPayload | undefined): Message | null {
|
|
53
|
+
const role = payload?.role;
|
|
54
|
+
if (role !== "user" && role !== "assistant") return null;
|
|
55
|
+
const texts = codexTexts(payload?.content).filter(
|
|
56
|
+
(text) => !isCodexInjectedContext(text)
|
|
57
|
+
);
|
|
58
|
+
return texts.length > 0 ? { role, content: texts.join(" ") } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
34
61
|
// Claude Code tags transcript lines `type: "user"|"assistant"` with the text
|
|
35
62
|
// under `message.content`. VS Code Copilot's own event log instead uses
|
|
36
63
|
// `type: "user.message"|"assistant.message"` with a flat `data.content`
|
|
37
|
-
// string
|
|
64
|
+
// string. Codex nests each message as a `response_item` payload.
|
|
38
65
|
function parseTranscriptEntry(entry: {
|
|
39
66
|
type?: string;
|
|
40
67
|
message?: { content?: unknown };
|
|
41
68
|
data?: { content?: unknown };
|
|
69
|
+
payload?: CodexPayload;
|
|
42
70
|
}): Message | null {
|
|
71
|
+
if (entry.type === "response_item") return codexMessage(entry.payload);
|
|
43
72
|
if (entry.type === "user" || entry.type === "assistant") {
|
|
44
73
|
const text = claudeCodeEntryText(entry.message ?? {});
|
|
45
74
|
return text ? { role: entry.type, content: text } : null;
|
|
@@ -55,7 +84,8 @@ function parseTranscriptEntry(entry: {
|
|
|
55
84
|
/**
|
|
56
85
|
* Read an agent transcript JSONL file and extract user/assistant messages.
|
|
57
86
|
* Supports Claude Code's `{type:"user"|"assistant", message:{content}}` shape
|
|
58
|
-
*
|
|
87
|
+
* VS Code Copilot's `{type:"user.message"|"assistant.message", data:{content}}` shape,
|
|
88
|
+
* and Codex's `{type:"response_item", payload:{type:"message", role, content}}` shape.
|
|
59
89
|
*/
|
|
60
90
|
export function readTranscriptFile(path: string): Message[] {
|
|
61
91
|
try {
|