portable-agent-layer 0.96.0 → 0.97.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.96.0",
3
+ "version": "0.97.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "scripts": {
39
39
  "type-check": "tsc --noEmit",
40
- "test": "bun test --randomize --isolate",
40
+ "test": "bun .agents/scripts/test.ts",
41
41
  "test:mutate": "stryker run",
42
42
  "test:mutate:diff": "bun .agents/scripts/mutate-diff.ts",
43
43
  "format": "biome format",
@@ -198,8 +198,8 @@ async function finishShared(docs: number): Promise<Done> {
198
198
  */
199
199
  async function refreshControlRoom(): Promise<Done | null> {
200
200
  const { isRepoMode } = await import("../hooks/handlers/update-check");
201
- const { buildPage } = await import("../tools/control-room/static");
202
- const rebuilt = isRepoMode() ? buildPage() : null;
201
+ const { refreshPage } = await import("../tools/control-room/static");
202
+ const rebuilt = refreshPage(isRepoMode());
203
203
  if (rebuilt === false)
204
204
  return {
205
205
  detail: ["page not rebuilt"],
@@ -15,6 +15,7 @@ import { latestUserRequest } from "./antigravity-transcript";
15
15
  import { autoUpdateOnStart } from "./auto-update";
16
16
  import { regenerateIfNeeded } from "./claude-md";
17
17
  import { buildSystemReminder } from "./context";
18
+ import { startHookTiming } from "./hook-timing";
18
19
  import { type HookTurnPayload, hookSessionId } from "./hook-turn";
19
20
  import { logContextSnapshot, logError, logPromptSnapshot } from "./log";
20
21
  import { paths } from "./paths";
@@ -89,11 +90,18 @@ export async function invocationContext(
89
90
  const prompt = latestUserRequest(payload.transcriptPath);
90
91
  if (prompt === null) return null;
91
92
 
93
+ const timing = startHookTiming("prompt");
94
+ const sessionContext = claimConversationStart(conversationId)
95
+ ? await timing.time("session-context", sessionStartReminder)
96
+ : null;
92
97
  const parts = [
93
- claimConversationStart(conversationId) ? sessionStartReminder() : null,
94
- await promptReminder(prompt, conversationId),
98
+ sessionContext,
99
+ await timing.time("inject-context", () => promptReminder(prompt, conversationId)),
95
100
  ].filter((part): part is string => Boolean(part));
96
- await capturePrompt(prompt, conversationId, sentAt, "InvocationContext");
101
+ await timing.time("capture", () =>
102
+ capturePrompt(prompt, conversationId, sentAt, "InvocationContext")
103
+ );
104
+ timing.log(conversationId);
97
105
 
98
106
  return parts.length > 0 ? injectedStep(parts.join("\n\n")) : null;
99
107
  }
@@ -25,7 +25,26 @@ interface OpencodePermissionReply {
25
25
  reply?: string;
26
26
  }
27
27
 
28
+ interface OpencodeToolPart {
29
+ type?: string;
30
+ callID?: string;
31
+ sessionID?: string;
32
+ tool?: string;
33
+ state?: {
34
+ status?: string;
35
+ input?: unknown;
36
+ error?: string;
37
+ time?: { start?: number; end?: number };
38
+ };
39
+ }
40
+
41
+ // opencode's PermissionV1 error messages: RejectedError / CorrectedError for a
42
+ // prompt the user refused, DeniedError for a configured rule.
43
+ const USER_REJECTED = "The user rejected permission";
44
+ const RULE_DENIED = "The user has specified a rule which prevents";
45
+
28
46
  const askedTools = new Map<string, string>();
47
+ const loggedFailures = new Set<string>();
29
48
 
30
49
  export function logOpencodeSessionCreated(session: OpencodeSession | undefined): void {
31
50
  if (!session?.id || session.parentID) return;
@@ -57,3 +76,33 @@ export function logOpencodePermissionReply(reply: OpencodePermissionReply): void
57
76
  session_id: reply.sessionID,
58
77
  });
59
78
  }
79
+
80
+ function durationOf(part: OpencodeToolPart): number | null {
81
+ const { start, end } = part.state?.time ?? {};
82
+ return start !== undefined && end !== undefined ? end - start : null;
83
+ }
84
+
85
+ function firstFailureOf(part: OpencodeToolPart | undefined): part is OpencodeToolPart {
86
+ if (part?.type !== "tool" || part.state?.status !== "error" || !part.callID)
87
+ return false;
88
+ if (loggedFailures.has(part.callID)) return false;
89
+ loggedFailures.add(part.callID);
90
+ return true;
91
+ }
92
+
93
+ /** A tool that threw never reaches tool.execute.after; its part turning to error is the only trace. */
94
+ export function logOpencodeToolFailure(part: OpencodeToolPart | undefined): void {
95
+ if (!firstFailureOf(part)) return;
96
+ const error = part.state?.error ?? "";
97
+ if (error.startsWith(USER_REJECTED)) return;
98
+ logToolCall({
99
+ hook_event_name: error.startsWith(RULE_DENIED)
100
+ ? "PermissionDenied"
101
+ : "PostToolUseFailure",
102
+ tool_name: part.tool,
103
+ tool_input: part.state?.input,
104
+ session_id: part.sessionID,
105
+ error,
106
+ duration_ms: durationOf(part),
107
+ });
108
+ }
@@ -69,6 +69,7 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
69
69
  logOpencodeCompaction,
70
70
  logOpencodePermissionReply,
71
71
  logOpencodeSessionCreated,
72
+ logOpencodeToolFailure,
72
73
  rememberAskedTool,
73
74
  } = await lib<typeof import("../../hooks/lib/opencode-events")>("opencode-events.ts");
74
75
  const { captureSessionName } = await lib<
@@ -153,6 +154,8 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
153
154
  logOpencodeCompaction(event.properties.sessionID);
154
155
  if (event.type === "permission.replied")
155
156
  logOpencodePermissionReply(event.properties);
157
+ if (event.type === "message.part.updated")
158
+ logOpencodeToolFailure(event.properties.part);
156
159
 
157
160
  if (event.type === "session.created" || event.type === "session.updated") {
158
161
  const { regenerateIfNeeded } =
@@ -5,35 +5,110 @@
5
5
  */
6
6
 
7
7
  import { spawnSync } from "node:child_process";
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
10
  import { isInside } from "../../hooks/lib/paths";
11
11
 
12
- const UI_DIST = resolve(import.meta.dir, "ui", "dist");
12
+ const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..");
13
+ const UI_ROOT = resolve(import.meta.dir, "ui");
14
+ const UI_DIST = resolve(UI_ROOT, "dist");
15
+ const UI_INDEX = resolve(UI_DIST, "index.html");
16
+ export const ABANDONED_LOCK_MS = 120_000;
13
17
 
14
18
  export const BUILD_COMMAND = "bun run build:ui";
15
19
 
20
+ /** Where the page is built from and to, and how; tests point it at a sandbox. */
21
+ export interface PageBuild {
22
+ sources: string;
23
+ lockfile: string;
24
+ dist: string;
25
+ lock: string;
26
+ build: () => boolean;
27
+ }
28
+
29
+ const THE_PAGE: PageBuild = {
30
+ sources: resolve(REPO_ROOT, "src"),
31
+ lockfile: resolve(REPO_ROOT, "bun.lock"),
32
+ dist: UI_DIST,
33
+ lock: resolve(UI_ROOT, "dist.lock"),
34
+ build: runViteBuild,
35
+ };
36
+
16
37
  export function isBuilt(): boolean {
17
- return existsSync(resolve(UI_DIST, "index.html"));
38
+ return existsSync(UI_INDEX);
18
39
  }
19
40
 
20
- /**
21
- * A clone has vite; a published install has the prepacked dist and never needs
22
- * it. Either way the page is there by the time the server answers.
23
- */
24
- export function buildPage(): boolean {
25
- const repoRoot = resolve(import.meta.dir, "..", "..", "..");
41
+ function newestSourceChange(page: PageBuild): number {
42
+ const sources = readdirSync(page.sources, { recursive: true, withFileTypes: true })
43
+ .filter((entry) => entry.isFile() && !isInside(page.dist, entry.parentPath))
44
+ .map((entry) => resolve(entry.parentPath, entry.name));
45
+ if (existsSync(page.lockfile)) sources.push(page.lockfile);
46
+ return Math.max(...sources.map((file) => statSync(file).mtimeMs));
47
+ }
48
+
49
+ export function isUpToDate(page: PageBuild = THE_PAGE): boolean {
50
+ const index = resolve(page.dist, "index.html");
51
+ return existsSync(index) && statSync(index).mtimeMs >= newestSourceChange(page);
52
+ }
53
+
54
+ function clearAbandonedLock(lock: string): void {
55
+ try {
56
+ if (Date.now() - statSync(lock).mtimeMs > ABANDONED_LOCK_MS)
57
+ rmSync(lock, { recursive: true, force: true });
58
+ } catch {}
59
+ }
60
+
61
+ function tryTakeBuildLock(lock: string): boolean {
62
+ try {
63
+ mkdirSync(lock);
64
+ return true;
65
+ } catch (error) {
66
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ function whileHoldingBuildLock(lock: string, build: () => boolean): boolean {
72
+ while (!tryTakeBuildLock(lock)) {
73
+ clearAbandonedLock(lock);
74
+ Bun.sleepSync(50);
75
+ }
76
+ try {
77
+ return build();
78
+ } finally {
79
+ rmSync(lock, { recursive: true, force: true });
80
+ }
81
+ }
82
+
83
+ /** A clone has vite; a published install has the prepacked dist and never needs it. */
84
+ function runViteBuild(): boolean {
26
85
  // node_modules/.bin/vite is a shell shim that Windows cannot spawn directly;
27
86
  // the package's own JS entry runs the same build under any runtime.
28
- const vite = resolve(repoRoot, "node_modules", "vite", "bin", "vite.js");
87
+ const vite = resolve(REPO_ROOT, "node_modules", "vite", "bin", "vite.js");
29
88
  if (!existsSync(vite)) return false;
30
89
  const built = spawnSync(process.execPath, [vite, "build"], {
31
- cwd: resolve(import.meta.dir, "ui"),
90
+ cwd: UI_ROOT,
32
91
  encoding: "utf-8",
33
92
  });
34
93
  return built.status === 0 && isBuilt();
35
94
  }
36
95
 
96
+ /**
97
+ * vite empties dist before writing it, so one build at a time, and none once
98
+ * another process has already brought it up to date.
99
+ */
100
+ export function buildPage(page: PageBuild = THE_PAGE): boolean {
101
+ return whileHoldingBuildLock(page.lock, () => isUpToDate(page) || page.build());
102
+ }
103
+
104
+ /** A checkout's dist is whatever was built last, so an install rebuilds it if the sources moved on. */
105
+ export function refreshPage(
106
+ inCheckout: boolean,
107
+ page: PageBuild = THE_PAGE
108
+ ): boolean | null {
109
+ return inCheckout && !isUpToDate(page) ? buildPage(page) : null;
110
+ }
111
+
37
112
  function unbuiltPage(): Response {
38
113
  return new Response(
39
114
  `<!doctype html><meta charset="utf-8"><title>PAL control room</title>` +