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.
|
|
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
|
|
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",
|
package/src/cli/install-flow.ts
CHANGED
|
@@ -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 {
|
|
202
|
-
const rebuilt = isRepoMode()
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
38
|
+
return existsSync(UI_INDEX);
|
|
18
39
|
}
|
|
19
40
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
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:
|
|
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>` +
|