portable-agent-layer 0.40.0 → 0.41.1

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.
Files changed (34) hide show
  1. package/README.md +37 -16
  2. package/assets/templates/PAL/MEMORY_SYSTEM.md +63 -17
  3. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +81 -8
  4. package/assets/templates/hooks.copilot.json +4 -4
  5. package/assets/templates/settings.claude.json +7 -7
  6. package/package.json +8 -5
  7. package/src/cli/index.ts +282 -22
  8. package/src/cli/migrate.ts +5 -48
  9. package/src/hooks/CompactRecover.ts +4 -0
  10. package/src/hooks/LoadContext.ts +13 -8
  11. package/src/hooks/PreCompactPersist.ts +4 -0
  12. package/src/hooks/StopOrchestrator.ts +18 -6
  13. package/src/hooks/UserPromptOrchestrator.ts +7 -1
  14. package/src/hooks/handlers/auto-graduate.ts +8 -0
  15. package/src/hooks/handlers/failure-principle.ts +122 -0
  16. package/src/hooks/handlers/rating.ts +57 -26
  17. package/src/hooks/handlers/reflect-trigger.ts +1 -0
  18. package/src/hooks/handlers/session-intelligence.ts +26 -6
  19. package/src/hooks/handlers/session-name.ts +13 -21
  20. package/src/hooks/handlers/update-check.ts +4 -0
  21. package/src/hooks/lib/agent.ts +28 -13
  22. package/src/hooks/lib/detached-inference.ts +40 -0
  23. package/src/hooks/lib/graduation.ts +1 -0
  24. package/src/hooks/lib/inference.ts +787 -5
  25. package/src/hooks/lib/log.ts +60 -12
  26. package/src/hooks/lib/notify.ts +1 -0
  27. package/src/hooks/lib/projects.ts +52 -0
  28. package/src/hooks/lib/retrieval-index.ts +1 -0
  29. package/src/hooks/lib/security.ts +5 -0
  30. package/src/hooks/lib/spawn-guard.ts +68 -0
  31. package/src/hooks/lib/stop.ts +77 -79
  32. package/src/targets/opencode/plugin.ts +13 -0
  33. package/src/tools/agent/project.ts +4 -42
  34. package/src/tools/self-model.ts +1 -0
@@ -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, writeFileSync } from "node:fs";
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 LOG_FILE = resolve(paths.state(), "debug.log");
13
- const MAX_LOG_SIZE = 50_000; // ~50KB, then rotate
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
- function rotateIfNeeded(): void {
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(LOG_FILE) && statSync(LOG_FILE).size > MAX_LOG_SIZE) {
22
- const prev = `${LOG_FILE}.prev`;
23
- writeFileSync(prev, "");
24
- renameSync(LOG_FILE, prev);
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
- rotateIfNeeded();
80
+ const path = logFile();
81
+ rotateIfNeeded(path);
35
82
  try {
36
- appendFileSync(LOG_FILE, `[${timestamp()}] DEBUG ${source}: ${message}\n`);
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
- rotateIfNeeded();
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(LOG_FILE, `[${timestamp()}] ERROR ${source}: ${msg}\n`);
95
+ appendFileSync(path, `[${timestamp()}] ERROR ${source}: ${msg}\n`);
48
96
  } catch {
49
97
  /* non-critical */
50
98
  }
@@ -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 = [
@@ -191,6 +191,7 @@ function spawnBackgroundRebuild(): void {
191
191
  detached: true,
192
192
  stdio: "ignore",
193
193
  env: process.env,
194
+ windowsHide: true,
194
195
  });
195
196
  child.unref();
196
197
  } catch (err) {
@@ -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
+ }
@@ -4,25 +4,23 @@
4
4
  */
5
5
 
6
6
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
- import { readFile, unlink } from "node:fs/promises";
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 { inference } from "./inference";
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
- // Run all handlers concurrently. Auto-graduate is idempotent (24h TTL +
50
- // state-dedup + content-dedup) so it's safe to fire on every Stop.
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
- async function checkPendingFailure(transcript: string): Promise<void> {
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
- const pending = JSON.parse(await readFile(pendingPath, "utf-8")) as {
160
- rating: number;
161
- context: string;
162
- detailedContext?: string;
163
- principle?: string;
164
- responsePreview?: string;
165
- userPreview?: string;
166
- cwd?: string;
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
- await captureFailure(
216
- pending.rating,
217
- pending.context,
218
- transcript,
219
- detailedContext,
220
- principle,
221
- pending.cwd
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
- // Non-critical
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")) as LegacyProject;
328
- if (!raw?.name || !raw?.path || !raw?.status) {
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`);
@@ -540,6 +540,7 @@ async function composeSelfModel(days: number): Promise<string> {
540
540
  model: SONNET_MODEL,
541
541
  maxTokens: 1500,
542
542
  timeout: 30000,
543
+ caller: "self-model",
543
544
  });
544
545
 
545
546
  if (result.usage) logTokenUsage("self-model", result.usage, SONNET_MODEL);