portable-agent-layer 0.84.1 → 0.84.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.
@@ -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. 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. `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.1",
3
+ "version": "0.84.3",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Detached child of a stop hook that returned before the agent wrote its reply
3
+ * to the transcript. argv: the deferred payload's path.
4
+ */
5
+
6
+ import { isPalSpawnedInference } from "./lib/spawn-guard";
7
+ import { finishDeferredStop } from "./lib/stop";
8
+
9
+ if (isPalSpawnedInference()) process.exit(0);
10
+
11
+ await finishDeferredStop(process.argv[2] ?? "");
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { randomUUID } from "node:crypto";
7
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { mkdtemp, rename, writeFile } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
10
  import { resolve } from "node:path";
@@ -42,10 +42,89 @@ export interface StopTurnPayload extends HookTurnPayload {
42
42
  transcriptPath?: string | null;
43
43
  }
44
44
 
45
+ type Transcript = ReturnType<typeof readTranscriptFile>;
46
+
47
+ interface ReplyWait {
48
+ timeoutMs: number;
49
+ intervalMs: number;
50
+ }
51
+
52
+ const REPLY_WAIT: ReplyWait = { timeoutMs: 15_000, intervalMs: 200 };
53
+
54
+ function transcriptOf(payload: StopTurnPayload | null): Transcript {
55
+ const transcriptPath = payload?.transcript_path ?? payload?.transcriptPath;
56
+ return transcriptPath ? readTranscriptFile(transcriptPath) : [];
57
+ }
58
+
59
+ /** The Copilot CLI writes the reply to its transcript only after agentStop returns. */
60
+ function replyNotWrittenYet(payload: StopTurnPayload | null, messages: Transcript) {
61
+ return messages.at(-1)?.role === "user" && !hookFinalReply(payload);
62
+ }
63
+
64
+ async function transcriptOnceReplied(
65
+ payload: StopTurnPayload,
66
+ wait: ReplyWait
67
+ ): Promise<Transcript | null> {
68
+ const deadline = Date.now() + wait.timeoutMs;
69
+ for (;;) {
70
+ const messages = transcriptOf(payload);
71
+ if (!replyNotWrittenYet(payload, messages)) return messages;
72
+ if (Date.now() >= deadline) return null;
73
+ await Bun.sleep(wait.intervalMs);
74
+ }
75
+ }
76
+
77
+ function deferStopTurn(payload: StopTurnPayload): void {
78
+ try {
79
+ const deferId: string = randomUUID();
80
+ const payloadPath = resolve(paths.state(), `stop-deferred.${deferId}.json`);
81
+ writeFileSync(payloadPath, JSON.stringify(payload), "utf-8");
82
+ const scriptPath = resolve(assets.hooks(), "StopDeferred.ts");
83
+ spawnDetachedInference(scriptPath, [payloadPath], "stop-deferred");
84
+ } catch (err) {
85
+ logError("deferStopTurn", err);
86
+ }
87
+ }
88
+
89
+ function takeDeferredPayload(payloadPath: string): StopTurnPayload | null {
90
+ try {
91
+ return JSON.parse(readFileSync(payloadPath, "utf-8"));
92
+ } catch {
93
+ return null;
94
+ } finally {
95
+ rmSync(payloadPath, { force: true });
96
+ }
97
+ }
98
+
99
+ /** Runs the stop a hook deferred, once the reply it was waiting for is in the transcript. */
100
+ export async function finishDeferredStop(
101
+ payloadPath: string,
102
+ wait: ReplyWait = REPLY_WAIT
103
+ ): Promise<void> {
104
+ const payload = takeDeferredPayload(payloadPath);
105
+ if (!payload) return;
106
+ const messages = await transcriptOnceReplied(payload, wait);
107
+ if (!messages) {
108
+ logDebug("finishDeferredStop", "The reply never reached the transcript");
109
+ return;
110
+ }
111
+ await runTurnStop(payload, messages);
112
+ }
113
+
45
114
  /** Everything a stop hook does once the agent's payload is read. */
46
115
  export async function stopTurn(payload: StopTurnPayload | null): Promise<void> {
47
- const transcriptPath = payload?.transcript_path ?? payload?.transcriptPath;
48
- const messages = transcriptPath ? readTranscriptFile(transcriptPath) : [];
116
+ const messages = transcriptOf(payload);
117
+ if (payload && replyNotWrittenYet(payload, messages)) {
118
+ deferStopTurn(payload);
119
+ return;
120
+ }
121
+ await runTurnStop(payload, messages);
122
+ }
123
+
124
+ async function runTurnStop(
125
+ payload: StopTurnPayload | null,
126
+ messages: Transcript
127
+ ): Promise<void> {
49
128
  if (messages.length >= 2) {
50
129
  await runStopHandlers(JSON.stringify(messages), {
51
130
  lastAssistantMessage: hookFinalReply(payload),
@@ -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
+ }