portable-agent-layer 0.40.0 → 0.41.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/README.md +37 -16
- package/assets/templates/PAL/MEMORY_SYSTEM.md +63 -17
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +81 -8
- package/assets/templates/hooks.copilot.json +4 -4
- package/assets/templates/settings.claude.json +7 -7
- package/package.json +8 -5
- package/src/cli/index.ts +282 -22
- package/src/cli/migrate.ts +5 -48
- package/src/hooks/CompactRecover.ts +4 -0
- package/src/hooks/LoadContext.ts +13 -8
- package/src/hooks/PreCompactPersist.ts +4 -0
- package/src/hooks/StopOrchestrator.ts +18 -6
- package/src/hooks/UserPromptOrchestrator.ts +7 -1
- package/src/hooks/handlers/auto-graduate.ts +8 -0
- package/src/hooks/handlers/failure-principle.ts +122 -0
- package/src/hooks/handlers/rating.ts +57 -26
- package/src/hooks/handlers/session-intelligence.ts +26 -6
- package/src/hooks/handlers/session-name.ts +13 -21
- package/src/hooks/lib/agent.ts +28 -13
- package/src/hooks/lib/detached-inference.ts +39 -0
- package/src/hooks/lib/graduation.ts +1 -0
- package/src/hooks/lib/inference.ts +786 -5
- package/src/hooks/lib/log.ts +60 -12
- package/src/hooks/lib/notify.ts +1 -0
- package/src/hooks/lib/projects.ts +52 -0
- package/src/hooks/lib/security.ts +5 -0
- package/src/hooks/lib/spawn-guard.ts +68 -0
- package/src/hooks/lib/stop.ts +77 -79
- package/src/targets/opencode/plugin.ts +13 -0
- package/src/tools/agent/project.ts +4 -42
- package/src/tools/self-model.ts +1 -0
package/src/hooks/lib/log.ts
CHANGED
|
@@ -5,35 +5,82 @@
|
|
|
5
5
|
* Only writes when PAL_DEBUG=1 or when called via logError (always logged).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { appendFileSync, existsSync, renameSync, statSync,
|
|
8
|
+
import { appendFileSync, existsSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
10
|
import { paths } from "./paths";
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
12
|
+
const MAX_LOG_SIZE = 50_000; // ~50KB per file
|
|
13
|
+
const MAX_ROTATED = 5; // keep up to 5 rotated files (.1 newest → .5 oldest)
|
|
14
|
+
|
|
15
|
+
/** Resolved lazily so PAL_HOME overrides at runtime are honored. */
|
|
16
|
+
function logFile(): string {
|
|
17
|
+
return resolve(paths.state(), "debug.log");
|
|
18
|
+
}
|
|
14
19
|
|
|
15
20
|
function timestamp(): string {
|
|
16
21
|
return new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Rotate debug.log when it exceeds MAX_LOG_SIZE.
|
|
26
|
+
*
|
|
27
|
+
* Keeps up to MAX_ROTATED rotated files numbered .1 (newest) through
|
|
28
|
+
* .MAX_ROTATED (oldest). Each rotation shifts .N-1 → .N, drops the oldest,
|
|
29
|
+
* and renames the current log to .1. Total disk footprint bounded at
|
|
30
|
+
* (MAX_ROTATED + 1) * MAX_LOG_SIZE ≈ 300KB.
|
|
31
|
+
*
|
|
32
|
+
* Migrates legacy .prev → .1 on first new rotation so existing history
|
|
33
|
+
* survives the format change.
|
|
34
|
+
*/
|
|
35
|
+
function rotateIfNeeded(path: string): void {
|
|
20
36
|
try {
|
|
21
|
-
if (existsSync(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
if (!existsSync(path) || statSync(path).size <= MAX_LOG_SIZE) return;
|
|
38
|
+
// Backward-compat migration: legacy .prev was the single old rotation file.
|
|
39
|
+
const legacyPrev = `${path}.prev`;
|
|
40
|
+
if (existsSync(legacyPrev) && !existsSync(`${path}.1`)) {
|
|
41
|
+
try {
|
|
42
|
+
renameSync(legacyPrev, `${path}.1`);
|
|
43
|
+
} catch {
|
|
44
|
+
/* ignore */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Drop the oldest, then shift .N-1 → .N for N from MAX_ROTATED down to 2.
|
|
48
|
+
const oldest = `${path}.${MAX_ROTATED}`;
|
|
49
|
+
if (existsSync(oldest)) {
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(oldest);
|
|
52
|
+
} catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
25
55
|
}
|
|
56
|
+
for (let i = MAX_ROTATED - 1; i >= 1; i--) {
|
|
57
|
+
const src = `${path}.${i}`;
|
|
58
|
+
const dst = `${path}.${i + 1}`;
|
|
59
|
+
if (existsSync(src)) {
|
|
60
|
+
try {
|
|
61
|
+
renameSync(src, dst);
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Finally, current → .1
|
|
68
|
+
renameSync(path, `${path}.1`);
|
|
26
69
|
} catch {
|
|
27
70
|
/* non-critical */
|
|
28
71
|
}
|
|
29
72
|
}
|
|
30
73
|
|
|
74
|
+
/** Test-only: max rotated count for callers that need to enumerate. */
|
|
75
|
+
export const DEBUG_LOG_MAX_ROTATED = MAX_ROTATED;
|
|
76
|
+
|
|
31
77
|
/** Log a debug message (only when PAL_DEBUG=1) */
|
|
32
78
|
export function logDebug(source: string, message: string): void {
|
|
33
79
|
if (process.env.PAL_DEBUG !== "1") return;
|
|
34
|
-
|
|
80
|
+
const path = logFile();
|
|
81
|
+
rotateIfNeeded(path);
|
|
35
82
|
try {
|
|
36
|
-
appendFileSync(
|
|
83
|
+
appendFileSync(path, `[${timestamp()}] DEBUG ${source}: ${message}\n`);
|
|
37
84
|
} catch {
|
|
38
85
|
/* non-critical */
|
|
39
86
|
}
|
|
@@ -41,10 +88,11 @@ export function logDebug(source: string, message: string): void {
|
|
|
41
88
|
|
|
42
89
|
/** Log an error (always written, regardless of PAL_DEBUG) */
|
|
43
90
|
export function logError(source: string, error: unknown): void {
|
|
44
|
-
|
|
91
|
+
const path = logFile();
|
|
92
|
+
rotateIfNeeded(path);
|
|
45
93
|
const msg = error instanceof Error ? `${error.message}\n${error.stack}` : String(error);
|
|
46
94
|
try {
|
|
47
|
-
appendFileSync(
|
|
95
|
+
appendFileSync(path, `[${timestamp()}] ERROR ${source}: ${msg}\n`);
|
|
48
96
|
} catch {
|
|
49
97
|
/* non-critical */
|
|
50
98
|
}
|
package/src/hooks/lib/notify.ts
CHANGED
|
@@ -28,6 +28,7 @@ function escapePowerShellSingle(s: string): string {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export async function notify(title: string, body: string): Promise<void> {
|
|
31
|
+
if (process.env.PAL_NOTIFICATIONS_DISABLED === "1") return;
|
|
31
32
|
if (process.platform === "darwin") {
|
|
32
33
|
const script = `display notification "${escapeAppleScript(body)}" with title "${escapeAppleScript(title)}"`;
|
|
33
34
|
await spawnSilent("osascript", ["-e", script]);
|
|
@@ -43,6 +43,58 @@ export interface ProjectProgress {
|
|
|
43
43
|
changelog?: string;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// ── Legacy migration types ────────────────────────────────────────
|
|
47
|
+
// Shared by `pal cli migrate` and `pal tool agent project --migrate` —
|
|
48
|
+
// both convert the old progress-JSON format to current ProjectProgress.
|
|
49
|
+
|
|
50
|
+
interface LegacyDecision {
|
|
51
|
+
ts: string;
|
|
52
|
+
decision: string;
|
|
53
|
+
rationale: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface LegacyProject {
|
|
57
|
+
name: string;
|
|
58
|
+
path: string;
|
|
59
|
+
status: ProjectStatus;
|
|
60
|
+
created: string;
|
|
61
|
+
updated: string;
|
|
62
|
+
facts?: string[];
|
|
63
|
+
objectives?: string[];
|
|
64
|
+
next_steps?: string[];
|
|
65
|
+
blockers?: string[];
|
|
66
|
+
handoff?: string;
|
|
67
|
+
decisions?: LegacyDecision[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Convert a parsed legacy progress JSON object into the current ProjectProgress
|
|
72
|
+
* shape. Returns null if required fields (name/path/status) are missing.
|
|
73
|
+
*/
|
|
74
|
+
export function legacyJsonToProgress(raw: unknown): ProjectProgress | null {
|
|
75
|
+
if (!raw || typeof raw !== "object") return null;
|
|
76
|
+
const r = raw as LegacyProject;
|
|
77
|
+
if (!r.name || !r.path || !r.status) return null;
|
|
78
|
+
const p: ProjectProgress = {
|
|
79
|
+
name: r.name,
|
|
80
|
+
path: r.path,
|
|
81
|
+
status: r.status,
|
|
82
|
+
created: r.created ?? new Date().toISOString(),
|
|
83
|
+
updated: r.updated ?? new Date().toISOString(),
|
|
84
|
+
...(r.handoff ? { handoff: r.handoff } : {}),
|
|
85
|
+
...(r.next_steps?.length ? { next: r.next_steps } : {}),
|
|
86
|
+
...(r.blockers?.length ? { blockers: r.blockers } : {}),
|
|
87
|
+
};
|
|
88
|
+
if (r.facts?.length) p.context = r.facts.join("\n");
|
|
89
|
+
if (r.objectives?.length) p.goal = r.objectives.map((o) => `- ${o}`).join("\n");
|
|
90
|
+
if (r.decisions?.length) {
|
|
91
|
+
p.decisions = r.decisions
|
|
92
|
+
.map((d) => `- ${d.ts.slice(0, 10)}: ${d.decision} (${d.rationale})`)
|
|
93
|
+
.join("\n");
|
|
94
|
+
}
|
|
95
|
+
return p;
|
|
96
|
+
}
|
|
97
|
+
|
|
46
98
|
const PROJECT_STALE_DAYS_DEFAULT = 14;
|
|
47
99
|
|
|
48
100
|
const PROJECT_MARKERS = [
|
|
@@ -32,6 +32,11 @@ const HOOK_MANAGED_FILES = [
|
|
|
32
32
|
"graduated.json",
|
|
33
33
|
"update-available.json",
|
|
34
34
|
"debug.log.prev",
|
|
35
|
+
"debug.log.1",
|
|
36
|
+
"debug.log.2",
|
|
37
|
+
"debug.log.3",
|
|
38
|
+
"debug.log.4",
|
|
39
|
+
"debug.log.5",
|
|
35
40
|
"opinions.json",
|
|
36
41
|
"pal-settings.json",
|
|
37
42
|
"skill-index.json",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn-guard — prevents PAL inference recursion.
|
|
3
|
+
*
|
|
4
|
+
* When the inference dispatcher (see src/hooks/lib/inference.ts) spawns an
|
|
5
|
+
* agent CLI (claude --print, codex exec, copilot -p, cursor-agent -p) for
|
|
6
|
+
* one-shot subscription-billed inference, it sets PAL_SPAWNED_INFERENCE=1
|
|
7
|
+
* and increments PAL_INFERENCE_DEPTH. PAL's own hooks check these on entry
|
|
8
|
+
* and short-circuit so the spawned subprocess does not itself trigger another
|
|
9
|
+
* inference call → infinite recursion.
|
|
10
|
+
*
|
|
11
|
+
* PRIMARY DEFENSE: per-agent CLI flags that disable hook loading in the
|
|
12
|
+
* spawned subprocess. PAI's canonical pattern (PAI/TOOLS/Inference.ts):
|
|
13
|
+
* --setting-sources '' → no settings.json → no hooks load
|
|
14
|
+
* --tools '' → no tool calls → no PreToolUse triggers
|
|
15
|
+
* --system-prompt <x> → explicit prompt instead of loaded default
|
|
16
|
+
* The dispatcher in step 3 must mirror this per supported agent.
|
|
17
|
+
*
|
|
18
|
+
* SECONDARY DEFENSE (this file): an env-var sentinel that survives across
|
|
19
|
+
* spawn boundaries. Catches cases where (a) we get a CLI flag wrong, (b) an
|
|
20
|
+
* agent CLI does not expose clean equivalents to claude's flags, (c) the
|
|
21
|
+
* environment leaks unexpectedly. Belt and suspenders.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const SPAWN_GUARD_ENV = {
|
|
25
|
+
/** Set to "1" by the dispatcher before spawning. Checked by every PAL hook. */
|
|
26
|
+
SENTINEL: "PAL_SPAWNED_INFERENCE",
|
|
27
|
+
/** Stringified integer. Incremented by the dispatcher; absent = 0. */
|
|
28
|
+
DEPTH: "PAL_INFERENCE_DEPTH",
|
|
29
|
+
/** Hard cap. Dispatcher MUST refuse to spawn when current depth >= MAX_DEPTH. */
|
|
30
|
+
MAX_DEPTH: 1,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/** True when the current process is a PAL-spawned inference subprocess. */
|
|
34
|
+
export function isPalSpawnedInference(): boolean {
|
|
35
|
+
return process.env[SPAWN_GUARD_ENV.SENTINEL] === "1";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How many PAL inference spawns deep we are. 0 = top-level user session. */
|
|
39
|
+
export function getInferenceDepth(): number {
|
|
40
|
+
const raw = process.env[SPAWN_GUARD_ENV.DEPTH];
|
|
41
|
+
if (!raw) return 0;
|
|
42
|
+
const n = Number.parseInt(raw, 10);
|
|
43
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the env a dispatcher applies before spawn. Returns a new object;
|
|
48
|
+
* `parentEnv` is never mutated. Sets the recursion sentinel, increments depth,
|
|
49
|
+
* and unsets CLAUDECODE so the child claude CLI does not trip its
|
|
50
|
+
* nested-session guard. CLAUDECODE: undefined is scoped to the child only —
|
|
51
|
+
* the parent process and OS env are untouched.
|
|
52
|
+
*/
|
|
53
|
+
export function buildSpawnGuardEnv(parentEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
54
|
+
const nextDepth = getInferenceDepthFrom(parentEnv) + 1;
|
|
55
|
+
return {
|
|
56
|
+
...parentEnv,
|
|
57
|
+
[SPAWN_GUARD_ENV.SENTINEL]: "1",
|
|
58
|
+
[SPAWN_GUARD_ENV.DEPTH]: String(nextDepth),
|
|
59
|
+
CLAUDECODE: undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getInferenceDepthFrom(env: NodeJS.ProcessEnv): number {
|
|
64
|
+
const raw = env[SPAWN_GUARD_ENV.DEPTH];
|
|
65
|
+
if (!raw) return 0;
|
|
66
|
+
const n = Number.parseInt(raw, 10);
|
|
67
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
68
|
+
}
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -4,25 +4,23 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
-
import {
|
|
7
|
+
import { mkdtemp, rename, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
8
9
|
import { resolve } from "node:path";
|
|
9
|
-
import { autoGraduate } from "../handlers/auto-graduate";
|
|
10
10
|
import { autoBackup } from "../handlers/backup";
|
|
11
11
|
import { writeContextDigests } from "../handlers/context-digests";
|
|
12
12
|
import { notifyDesktop } from "../handlers/desktop-notify";
|
|
13
|
-
import { captureFailure } from "../handlers/failure";
|
|
14
13
|
import { persistLastExchange } from "../handlers/persist-last-exchange";
|
|
15
14
|
import { projectTouch } from "../handlers/project-touch";
|
|
16
15
|
import { checkReflectTrigger } from "../handlers/reflect-trigger";
|
|
17
16
|
import { checkSelfModelTrigger } from "../handlers/self-model-trigger";
|
|
18
|
-
import { captureSessionIntelligence } from "../handlers/session-intelligence";
|
|
19
17
|
import { runSynthesis } from "../handlers/synthesis";
|
|
20
18
|
import { resetTab } from "../handlers/tab";
|
|
21
19
|
import { updateCounts } from "../handlers/update-counts";
|
|
22
20
|
import { captureWorkSession } from "../handlers/work-session";
|
|
23
|
-
import {
|
|
21
|
+
import { spawnDetachedInference } from "./detached-inference";
|
|
24
22
|
import { logDebug, logError } from "./log";
|
|
25
|
-
import { ensureDir, paths } from "./paths";
|
|
23
|
+
import { assets, ensureDir, paths } from "./paths";
|
|
26
24
|
import { extractContent, extractLastAssistant, parseMessages } from "./transcript";
|
|
27
25
|
|
|
28
26
|
interface RunStopHandlersOptions {
|
|
@@ -46,20 +44,25 @@ export async function runStopHandlers(
|
|
|
46
44
|
// Always persist last exchange — drives CompactRecover + "Pick Up Where You Left Off"
|
|
47
45
|
if (options.sessionId) persistLastExchange(messages, options.sessionId);
|
|
48
46
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
47
|
+
// Detach inference-bearing handlers — claude --print cold-start can exceed
|
|
48
|
+
// any in-hook budget. These spawn detached bun subprocesses that run the
|
|
49
|
+
// inference and write results to disk; they don't block this hook.
|
|
50
|
+
// autoGraduate is idempotent (24h TTL + state-dedup + content-dedup), so
|
|
51
|
+
// concurrent or overlapping detached runs are safe.
|
|
52
|
+
await detachSessionIntelligence(transcript, options.sessionId);
|
|
53
|
+
await detachFailurePrinciple(transcript);
|
|
54
|
+
detachAutoGraduate();
|
|
55
|
+
|
|
56
|
+
// Run remaining (non-inference) handlers concurrently.
|
|
51
57
|
// project-touch only fires when cwd resolves to an active registered project.
|
|
52
58
|
const results = await Promise.allSettled([
|
|
53
59
|
captureWorkSession(transcript, options.sessionId),
|
|
54
60
|
resetTab(),
|
|
55
|
-
captureSessionIntelligence(transcript, options.sessionId),
|
|
56
|
-
checkPendingFailure(transcript),
|
|
57
61
|
updateCounts(),
|
|
58
62
|
autoBackup(),
|
|
59
63
|
checkReflectTrigger(),
|
|
60
64
|
checkSelfModelTrigger(),
|
|
61
65
|
runSynthesis(),
|
|
62
|
-
autoGraduate(),
|
|
63
66
|
projectTouch(options.lastAssistantMessage),
|
|
64
67
|
notifyDesktop(options.sessionId),
|
|
65
68
|
Promise.resolve(writeContextDigests()),
|
|
@@ -68,14 +71,11 @@ export async function runStopHandlers(
|
|
|
68
71
|
const handlerNames = [
|
|
69
72
|
"work-session",
|
|
70
73
|
"tab",
|
|
71
|
-
"session-intelligence",
|
|
72
|
-
"pending-failure",
|
|
73
74
|
"update-counts",
|
|
74
75
|
"backup",
|
|
75
76
|
"reflect-trigger",
|
|
76
77
|
"self-model-trigger",
|
|
77
78
|
"synthesis",
|
|
78
|
-
"auto-graduate",
|
|
79
79
|
"project-touch",
|
|
80
80
|
"desktop-notify",
|
|
81
81
|
"context-digests",
|
|
@@ -151,76 +151,74 @@ function cacheLastResponse(
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
/** Write transcript to a fresh tmp file and return the path. Child unlinks it. */
|
|
155
|
+
async function writeTranscriptTmp(transcript: string): Promise<string> {
|
|
156
|
+
const dir = await mkdtemp(resolve(tmpdir(), "pal-transcript-"));
|
|
157
|
+
const file = resolve(dir, "transcript.txt");
|
|
158
|
+
await writeFile(file, transcript, "utf-8");
|
|
159
|
+
return file;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Spawn a detached child to run session-intelligence on a tmp copy of the transcript. */
|
|
163
|
+
async function detachSessionIntelligence(
|
|
164
|
+
transcript: string,
|
|
165
|
+
sessionId?: string
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
try {
|
|
168
|
+
const transcriptPath = await writeTranscriptTmp(transcript);
|
|
169
|
+
const scriptPath = resolve(assets.hooks(), "handlers", "session-intelligence.ts");
|
|
170
|
+
spawnDetachedInference(
|
|
171
|
+
scriptPath,
|
|
172
|
+
["--run", sessionId ?? "", transcriptPath],
|
|
173
|
+
"session-intelligence"
|
|
174
|
+
);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
logError("detachSessionIntelligence", err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Spawn a detached child to run the full autoGraduate cycle. */
|
|
181
|
+
function detachAutoGraduate(): void {
|
|
182
|
+
try {
|
|
183
|
+
const scriptPath = resolve(assets.hooks(), "handlers", "auto-graduate.ts");
|
|
184
|
+
spawnDetachedInference(scriptPath, ["--run"], "auto-graduate");
|
|
185
|
+
} catch (err) {
|
|
186
|
+
logError("detachAutoGraduate", err);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* If a pending-failure exists, rename it to a unique path (race-free claim),
|
|
192
|
+
* write the transcript to tmp, spawn the failure-principle handler detached.
|
|
193
|
+
*/
|
|
194
|
+
async function detachFailurePrinciple(transcript: string): Promise<void> {
|
|
155
195
|
const pendingPath = resolve(paths.state(), "pending-failure.json");
|
|
156
196
|
if (!existsSync(pendingPath)) return;
|
|
157
197
|
|
|
198
|
+
// Rename to claim the pending file atomically — prevents two Stop hooks
|
|
199
|
+
// racing on the same low rating (opencode notably fires session.idle AND
|
|
200
|
+
// session.diff concurrently, so runStopHandlers runs twice in parallel).
|
|
201
|
+
const claimedDir = await mkdtemp(resolve(tmpdir(), "pal-pending-"));
|
|
202
|
+
const claimedPath = resolve(claimedDir, "pending.json");
|
|
158
203
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
};
|
|
168
|
-
await unlink(pendingPath);
|
|
169
|
-
|
|
170
|
-
// Extract principle from full transcript if not already present
|
|
171
|
-
let { principle, detailedContext } = pending;
|
|
172
|
-
if (!principle) {
|
|
173
|
-
try {
|
|
174
|
-
const msgs = parseMessages(transcript);
|
|
175
|
-
const recent = msgs
|
|
176
|
-
.slice(-10)
|
|
177
|
-
.map((m) => `${m.role.toUpperCase()}: ${extractContent(m).slice(0, 300)}`)
|
|
178
|
-
.join("\n\n");
|
|
179
|
-
|
|
180
|
-
const result = await inference({
|
|
181
|
-
system: `Analyze this failed AI interaction. The user rated it ${pending.rating}/10.
|
|
182
|
-
|
|
183
|
-
Return JSON:
|
|
184
|
-
{
|
|
185
|
-
"principle": "<one actionable rule the AI should follow, 10-20 words. Start with a verb: 'Verify...', 'Always...', 'Never...', 'Ask before...'>",
|
|
186
|
-
"detailed_context": "<what went wrong and why, 50-150 words>"
|
|
187
|
-
}`,
|
|
188
|
-
user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
|
|
189
|
-
maxTokens: 400,
|
|
190
|
-
timeout: 10000,
|
|
191
|
-
jsonSchema: {
|
|
192
|
-
type: "object" as const,
|
|
193
|
-
properties: {
|
|
194
|
-
principle: { type: "string" as const },
|
|
195
|
-
detailed_context: { type: "string" as const },
|
|
196
|
-
},
|
|
197
|
-
required: ["principle", "detailed_context"],
|
|
198
|
-
additionalProperties: false,
|
|
199
|
-
},
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
if (result.success && result.output) {
|
|
203
|
-
const parsed = JSON.parse(result.output) as {
|
|
204
|
-
principle?: string;
|
|
205
|
-
detailed_context?: string;
|
|
206
|
-
};
|
|
207
|
-
principle = parsed.principle || undefined;
|
|
208
|
-
detailedContext ??= parsed.detailed_context || undefined;
|
|
209
|
-
}
|
|
210
|
-
} catch {
|
|
211
|
-
/* graceful fallback — capture without principle */
|
|
212
|
-
}
|
|
213
|
-
}
|
|
204
|
+
await rename(pendingPath, claimedPath);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// ENOENT means another concurrent Stop hook already claimed it. That's
|
|
207
|
+
// expected and benign — the other process will handle the failure.
|
|
208
|
+
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return;
|
|
209
|
+
logError("detachFailurePrinciple", err);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
214
212
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
213
|
+
try {
|
|
214
|
+
const transcriptPath = await writeTranscriptTmp(transcript);
|
|
215
|
+
const scriptPath = resolve(assets.hooks(), "handlers", "failure-principle.ts");
|
|
216
|
+
spawnDetachedInference(
|
|
217
|
+
scriptPath,
|
|
218
|
+
["--run", claimedPath, transcriptPath],
|
|
219
|
+
"failure-principle"
|
|
222
220
|
);
|
|
223
|
-
} catch {
|
|
224
|
-
|
|
221
|
+
} catch (err) {
|
|
222
|
+
logError("detachFailurePrinciple", err);
|
|
225
223
|
}
|
|
226
224
|
}
|
|
@@ -10,6 +10,13 @@ import type { Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
|
10
10
|
|
|
11
11
|
const PAL_DIR = process.env.PAL_DIR || resolve(import.meta.dir, "../../..");
|
|
12
12
|
|
|
13
|
+
// Identify ourselves as opencode for the shared detector in hooks/lib/agent.ts.
|
|
14
|
+
// Force-override (= not ??=) so an inherited PAL_AGENT from the parent shell
|
|
15
|
+
// — common when launching opencode from a Claude Code terminal — doesn't make
|
|
16
|
+
// the dispatcher route inference to the wrong agent's CLI. Mirrors how
|
|
17
|
+
// .claude/settings.json template prefixes every hook command with PAL_AGENT=claude.
|
|
18
|
+
process.env.PAL_AGENT = "opencode";
|
|
19
|
+
|
|
13
20
|
// Dynamic imports from shared lib — resolved at runtime via PAL_DIR
|
|
14
21
|
async function lib<T>(mod: string): Promise<T> {
|
|
15
22
|
return await import(resolve(PAL_DIR, "src", "hooks", "lib", mod));
|
|
@@ -74,15 +81,20 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
74
81
|
.filter((m) => m.content.length > 0);
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
const { isPalSpawnedInference } =
|
|
85
|
+
await lib<typeof import("../../hooks/lib/spawn-guard")>("spawn-guard.ts");
|
|
86
|
+
|
|
77
87
|
return {
|
|
78
88
|
// --- Per-message: Inject dynamic system reminder ---
|
|
79
89
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
90
|
+
if (isPalSpawnedInference()) return;
|
|
80
91
|
const reminder = buildSystemReminder({ agent: "opencode" });
|
|
81
92
|
if (reminder) output.system.push(reminder);
|
|
82
93
|
},
|
|
83
94
|
|
|
84
95
|
// --- Session events: start and stop handling ---
|
|
85
96
|
event: async ({ event }) => {
|
|
97
|
+
if (isPalSpawnedInference()) return;
|
|
86
98
|
logDebug("opencode:event", `Event: ${event.type}`);
|
|
87
99
|
|
|
88
100
|
if (event.type === "session.created" || event.type === "session.updated") {
|
|
@@ -123,6 +135,7 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
123
135
|
|
|
124
136
|
// --- Capture ratings + session naming from user messages (shared handlers) ---
|
|
125
137
|
"chat.message": async (input, output) => {
|
|
138
|
+
if (isPalSpawnedInference()) return;
|
|
126
139
|
const text = partsToText(output.parts ?? []);
|
|
127
140
|
if (!text.trim()) return;
|
|
128
141
|
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
defaultSlug,
|
|
30
30
|
deleteProject,
|
|
31
31
|
isStale,
|
|
32
|
+
legacyJsonToProgress,
|
|
32
33
|
type ProjectProgress,
|
|
33
34
|
type ProjectStatus,
|
|
34
35
|
readAllProjects,
|
|
@@ -276,26 +277,6 @@ function cmdIsaInit(args: string[]): void {
|
|
|
276
277
|
|
|
277
278
|
// ── migrate (from old JSON format) ───────────────────────────────
|
|
278
279
|
|
|
279
|
-
interface LegacyDecision {
|
|
280
|
-
ts: string;
|
|
281
|
-
decision: string;
|
|
282
|
-
rationale: string;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
interface LegacyProject {
|
|
286
|
-
name: string;
|
|
287
|
-
path: string;
|
|
288
|
-
status: ProjectStatus;
|
|
289
|
-
created: string;
|
|
290
|
-
updated: string;
|
|
291
|
-
facts?: string[];
|
|
292
|
-
objectives?: string[];
|
|
293
|
-
next_steps?: string[];
|
|
294
|
-
blockers?: string[];
|
|
295
|
-
handoff?: string;
|
|
296
|
-
decisions?: LegacyDecision[];
|
|
297
|
-
}
|
|
298
|
-
|
|
299
280
|
function cmdMigrate(): void {
|
|
300
281
|
const progressDir = paths.progress();
|
|
301
282
|
if (!existsSync(progressDir)) {
|
|
@@ -324,32 +305,13 @@ function cmdMigrate(): void {
|
|
|
324
305
|
}
|
|
325
306
|
|
|
326
307
|
try {
|
|
327
|
-
const raw = JSON.parse(readFileSync(filePath, "utf-8"))
|
|
328
|
-
|
|
308
|
+
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
309
|
+
const p = legacyJsonToProgress(raw);
|
|
310
|
+
if (!p) {
|
|
329
311
|
skipped++;
|
|
330
312
|
results.push(`${slug}: skipped (malformed JSON)`);
|
|
331
313
|
continue;
|
|
332
314
|
}
|
|
333
|
-
|
|
334
|
-
const p: ProjectProgress = {
|
|
335
|
-
name: raw.name,
|
|
336
|
-
path: raw.path,
|
|
337
|
-
status: raw.status,
|
|
338
|
-
created: raw.created,
|
|
339
|
-
updated: raw.updated,
|
|
340
|
-
...(raw.handoff ? { handoff: raw.handoff } : {}),
|
|
341
|
-
...(raw.next_steps?.length ? { next: raw.next_steps } : {}),
|
|
342
|
-
...(raw.blockers?.length ? { blockers: raw.blockers } : {}),
|
|
343
|
-
};
|
|
344
|
-
|
|
345
|
-
if (raw.facts?.length) p.context = raw.facts.join("\n");
|
|
346
|
-
if (raw.objectives?.length) p.goal = raw.objectives.map((o) => `- ${o}`).join("\n");
|
|
347
|
-
if (raw.decisions?.length) {
|
|
348
|
-
p.decisions = raw.decisions
|
|
349
|
-
.map((d) => `- ${d.ts.slice(0, 10)}: ${d.decision} (${d.rationale})`)
|
|
350
|
-
.join("\n");
|
|
351
|
-
}
|
|
352
|
-
|
|
353
315
|
writeProject(p);
|
|
354
316
|
migrated++;
|
|
355
317
|
results.push(`${slug}: migrated`);
|
package/src/tools/self-model.ts
CHANGED