portable-agent-layer 0.39.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/src/cli/index.ts CHANGED
@@ -22,6 +22,8 @@ import { spawnSync } from "node:child_process";
22
22
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
24
  import { resolve } from "node:path";
25
+ import { inference, previewInferenceRoute } from "../hooks/lib/inference";
26
+ import { DEBUG_LOG_MAX_ROTATED } from "../hooks/lib/log";
25
27
  import { palHome, palPkg, platform } from "../hooks/lib/paths";
26
28
  import { hasRealContent, SETUP_STEPS, STEP_ORDER } from "../hooks/lib/setup";
27
29
  import { log } from "../targets/lib";
@@ -167,9 +169,13 @@ async function runCli(command: string | undefined, args: string[]) {
167
169
  case "status":
168
170
  await status();
169
171
  break;
170
- case "doctor":
172
+ case "doctor": {
171
173
  doctor();
174
+ if (args.includes("--probe-inference") || args.includes("--probe")) {
175
+ await probeInference();
176
+ }
172
177
  break;
178
+ }
173
179
  case "migrate": {
174
180
  const { runMigrate } = await import("./migrate");
175
181
  runMigrate(args);
@@ -217,7 +223,7 @@ function showHelp() {
217
223
  pal cli export [path] [--dry-run] Export state to zip
218
224
  pal cli import [path] [--dry-run] Import state from zip
219
225
  pal cli status Show PAL configuration
220
- pal cli doctor Check prerequisites and health
226
+ pal cli doctor [--probe-inference] Check prerequisites and health (--probe fires real inference per route)
221
227
  pal cli migrate [--list] [--dry-run] Run pending data migrations
222
228
  pal cli usage Summarize token usage and cost
223
229
 
@@ -385,6 +391,136 @@ function checkCopilotInstructionsPresent(): boolean {
385
391
  return existsSync(resolve(platform.copilotDir(), "copilot-instructions.md"));
386
392
  }
387
393
 
394
+ // ── Install integrity (Tier 2 doctor checks) ──
395
+
396
+ /** Recursively collect every `command` or `bash` field value in a hook-config JSON. */
397
+ function extractAllHookCommands(obj: unknown, out: string[] = []): string[] {
398
+ if (Array.isArray(obj)) {
399
+ for (const item of obj) extractAllHookCommands(item, out);
400
+ } else if (obj && typeof obj === "object") {
401
+ for (const [k, v] of Object.entries(obj)) {
402
+ if ((k === "command" || k === "bash") && typeof v === "string") {
403
+ out.push(v);
404
+ } else {
405
+ extractAllHookCommands(v, out);
406
+ }
407
+ }
408
+ }
409
+ return out;
410
+ }
411
+
412
+ interface HookPrefixCheck {
413
+ ok: boolean;
414
+ total: number;
415
+ missing: number;
416
+ firstMissing?: string;
417
+ }
418
+
419
+ /** Verify every command in an installed hook file starts with `PAL_AGENT=<agent>`. */
420
+ function checkAgentHookPrefix(filePath: string, agentName: string): HookPrefixCheck {
421
+ if (!existsSync(filePath)) return { ok: false, total: 0, missing: 0 };
422
+ try {
423
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
424
+ const commands = extractAllHookCommands(data);
425
+ const prefix = `PAL_AGENT=${agentName} `;
426
+ const missing = commands.filter((c) => !c.startsWith(prefix));
427
+ return {
428
+ ok: commands.length > 0 && missing.length === 0,
429
+ total: commands.length,
430
+ missing: missing.length,
431
+ firstMissing: missing[0]?.slice(0, 80),
432
+ };
433
+ } catch {
434
+ return { ok: false, total: 0, missing: 0 };
435
+ }
436
+ }
437
+
438
+ interface FreshnessCheck {
439
+ ok: boolean;
440
+ reason?: string;
441
+ }
442
+
443
+ /**
444
+ * Verify the installed opencode plugin is at least as new as the source. Catches
445
+ * the "stale install" failure mode we hit live — plugin file is a copy, not a
446
+ * symlink, so source edits don't reach the running opencode until reinstall.
447
+ */
448
+ /**
449
+ * Probe every supported agent route with a tiny real inference call.
450
+ * Sequential (concurrent multi-CLI spawns trigger the empty-abort race we
451
+ * already mitigate but don't want to invite). Each probe temporarily sets
452
+ * PAL_AGENT then restores; doesn't pollute user shell.
453
+ *
454
+ * Triggered by `pal cli doctor --probe-inference` (opt-in: costs tokens).
455
+ */
456
+ async function probeInference(): Promise<void> {
457
+ console.log("");
458
+ log.info("Inference probe (live calls, ~5-60s each)");
459
+ const green = "\x1b[32m";
460
+ const red = "\x1b[31m";
461
+ const yellow = "\x1b[33m";
462
+ const dim = "\x1b[90m";
463
+ const reset = "\x1b[0m";
464
+ const agents = ["claude", "codex", "opencode", "copilot", "cursor"] as const;
465
+ const savedAgent = process.env.PAL_AGENT;
466
+ try {
467
+ for (const agent of agents) {
468
+ process.env.PAL_AGENT = agent;
469
+ const preview = previewInferenceRoute();
470
+ const tag = `${agent.padEnd(10)} → ${preview.route.padEnd(15)}`;
471
+ if (preview.route === "none") {
472
+ console.log(` ${dim}-${reset} ${tag} ${dim}(skip: ${preview.reason})${reset}`);
473
+ continue;
474
+ }
475
+ if (preview.route === "disabled") {
476
+ console.log(` ${yellow}⚠${reset} ${tag} ${dim}${preview.reason}${reset}`);
477
+ continue;
478
+ }
479
+ const start = Date.now();
480
+ const r = await inference({
481
+ user: "Reply with exactly: OK",
482
+ system: "Reply in 3 words or fewer.",
483
+ caller: "doctor-probe",
484
+ timeout: 60_000,
485
+ });
486
+ const elapsedMs = Date.now() - start;
487
+ if (r.success) {
488
+ const bytes = r.output?.length ?? 0;
489
+ console.log(
490
+ ` ${green}✓${reset} ${tag} ${String(elapsedMs).padStart(6)}ms bytes=${bytes}`
491
+ );
492
+ } else {
493
+ console.log(
494
+ ` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/memory/state/debug.log${reset}`
495
+ );
496
+ }
497
+ }
498
+ } finally {
499
+ if (savedAgent === undefined) delete process.env.PAL_AGENT;
500
+ else process.env.PAL_AGENT = savedAgent;
501
+ }
502
+ }
503
+
504
+ function checkOpencodePluginFresh(): FreshnessCheck {
505
+ const installedPath = resolve(platform.opencodeDir(), "plugins", "pal-plugin.ts");
506
+ const sourcePath = resolve(palPkg(), "src", "targets", "opencode", "plugin.ts");
507
+ if (!existsSync(installedPath))
508
+ return { ok: false, reason: "installed plugin missing" };
509
+ if (!existsSync(sourcePath)) return { ok: true }; // can't compare; assume installed is fine
510
+ try {
511
+ const installedMtime = statSync(installedPath).mtimeMs;
512
+ const sourceMtime = statSync(sourcePath).mtimeMs;
513
+ if (installedMtime >= sourceMtime) return { ok: true };
514
+ const ageMin = Math.round((sourceMtime - installedMtime) / 60000);
515
+ return {
516
+ ok: false,
517
+ reason: `source is ${ageMin}m newer than installed — run 'pal cli install --opencode'`,
518
+ };
519
+ } catch {
520
+ return { ok: true };
521
+ }
522
+ }
523
+
388
524
  function playwrightBrowsersPath(): string {
389
525
  if (process.env.PLAYWRIGHT_BROWSERS_PATH) return process.env.PLAYWRIGHT_BROWSERS_PATH;
390
526
  const home = homedir();
@@ -437,12 +573,24 @@ function nodeInstallHint(): string {
437
573
  }
438
574
 
439
575
  function checkHookHealth(home: string): HookHealth {
440
- const logPath = resolve(home, "memory", "state", "debug.log");
576
+ const stateDir = resolve(home, "memory", "state");
577
+ // Read current + all rotated logs (`.1`..`.5`) + legacy `.prev` so a recent
578
+ // rotation doesn't make the 24h window appear empty.
579
+ const candidates = [
580
+ resolve(stateDir, "debug.log"),
581
+ resolve(stateDir, "debug.log.prev"),
582
+ ...Array.from({ length: DEBUG_LOG_MAX_ROTATED }, (_, i) =>
583
+ resolve(stateDir, `debug.log.${i + 1}`)
584
+ ),
585
+ ];
441
586
 
442
587
  try {
443
- if (!existsSync(logPath)) return { totalErrors: 0, lastError: null };
588
+ let content = "";
589
+ for (const path of candidates) {
590
+ if (existsSync(path)) content += `${readFileSync(path, "utf-8")}\n`;
591
+ }
592
+ if (!content) return { totalErrors: 0, lastError: null };
444
593
 
445
- const content = readFileSync(logPath, "utf-8");
446
594
  const lines = content.split("\n").filter((l) => l.includes("] ERROR "));
447
595
 
448
596
  // Filter to last 24h
@@ -516,9 +664,10 @@ function doctor(silent = false): DoctorResult {
516
664
  const ok = (msg: string) => console.log(` \x1b[32m\u2713\x1b[0m ${msg}`);
517
665
  const warn = (msg: string) => console.log(` \x1b[33m\u26A0\x1b[0m ${msg}`);
518
666
  const fail = (msg: string) => console.log(` \x1b[31m\u2717\x1b[0m ${msg}`);
667
+ const info = (msg: string) => console.log(` \x1b[90m\u00B7\x1b[0m ${msg}`);
519
668
 
520
669
  console.log("");
521
- log.info("Doctor");
670
+ log.info("Prerequisites");
522
671
  ok(`Bun ${bun.version}`);
523
672
  const node = checkNode();
524
673
  if (!node.available) {
@@ -547,6 +696,14 @@ function doctor(silent = false): DoctorResult {
547
696
  codex.available
548
697
  ? ok(`Codex ${codex.version || ""}`.trim())
549
698
  : fail("Codex — not found");
699
+ checkPlaywrightChromium()
700
+ ? ok("Playwright Chromium installed")
701
+ : fail(
702
+ "Playwright Chromium — not found (run 'pal cli install' or 'bunx playwright install chromium')"
703
+ );
704
+
705
+ console.log("");
706
+ log.info("PAL state");
550
707
  ok(`PAL home: ${home}`);
551
708
  telosCount > 0 ? ok(`TELOS: ${telosCount} files`) : fail("TELOS: not scaffolded");
552
709
 
@@ -591,7 +748,15 @@ function doctor(silent = false): DoctorResult {
591
748
  );
592
749
  }
593
750
 
751
+ // Dependencies (PAL's own npm packages)
752
+ const nodeModulesPath = resolve(palPkg(), "node_modules");
753
+ existsSync(nodeModulesPath)
754
+ ? ok("Dependencies installed")
755
+ : fail("Dependencies missing — run 'pal cli install'");
756
+
594
757
  // Skills (per installed agent)
758
+ console.log("");
759
+ log.info("Skills");
595
760
  const countSkillsIn = (dir: string) =>
596
761
  existsSync(dir)
597
762
  ? readdirSync(dir).filter((f) => existsSync(resolve(dir, f, "SKILL.md"))).length
@@ -627,20 +792,9 @@ function doctor(silent = false): DoctorResult {
627
792
  : warn("Codex skills — none found (run 'pal cli install --codex')");
628
793
  }
629
794
 
630
- // Dependencies
631
- const nodeModulesPath = resolve(palPkg(), "node_modules");
632
- existsSync(nodeModulesPath)
633
- ? ok("Dependencies installed")
634
- : fail("Dependencies missing — run 'pal cli install'");
635
-
636
- // Playwright Chromium (required by create-pdf + consulting-report skills)
637
- checkPlaywrightChromium()
638
- ? ok("Playwright Chromium installed")
639
- : fail(
640
- "Playwright Chromium — not found (run 'pal cli install' or 'bunx playwright install chromium')"
641
- );
642
-
643
795
  // Hook registration (per installed agent)
796
+ console.log("");
797
+ log.info("Hooks");
644
798
  if (claude.available) {
645
799
  checkClaudeHooksRegistered()
646
800
  ? ok("Claude Code hooks registered")
@@ -670,10 +824,116 @@ function doctor(silent = false): DoctorResult {
670
824
  : fail("Codex hooks — not registered (run 'pal cli install --codex')");
671
825
  }
672
826
 
673
- // API key checks
827
+ // Install integrity — verify PAL_AGENT prefix on every command in installed
828
+ // hook files. Catches stale installs after template changes (the exact bug
829
+ // we hit live for opencode + copilot). opencode uses a plugin file instead
830
+ // of a hooks.json, so it gets a separate freshness check.
831
+ console.log("");
832
+ log.info("Install integrity");
833
+ const prefixCheck = (
834
+ filePath: string,
835
+ agentName: string,
836
+ installCmd: string
837
+ ): void => {
838
+ const r = checkAgentHookPrefix(filePath, agentName);
839
+ if (r.ok) {
840
+ ok(`${agentName}: PAL_AGENT=${agentName} on all ${r.total} hook commands`);
841
+ } else if (r.total === 0) {
842
+ fail(`${agentName}: hook file missing or unreadable at ${filePath}`);
843
+ } else {
844
+ fail(
845
+ `${agentName}: ${r.missing}/${r.total} hook commands missing PAL_AGENT=${agentName} prefix (run '${installCmd}')`
846
+ );
847
+ if (r.firstMissing) {
848
+ log.warn(` First offender: ${r.firstMissing}…`);
849
+ }
850
+ }
851
+ };
852
+ if (claude.available) {
853
+ prefixCheck(
854
+ resolve(platform.claudeDir(), "settings.json"),
855
+ "claude",
856
+ "pal cli install --claude"
857
+ );
858
+ }
859
+ if (cursor.available) {
860
+ prefixCheck(
861
+ resolve(platform.cursorDir(), "hooks.json"),
862
+ "cursor",
863
+ "pal cli install --cursor"
864
+ );
865
+ }
866
+ if (copilot.available) {
867
+ prefixCheck(
868
+ resolve(platform.copilotDir(), "hooks", "pal-hooks.json"),
869
+ "copilot",
870
+ "pal cli install --copilot"
871
+ );
872
+ }
873
+ if (codex.available) {
874
+ prefixCheck(
875
+ resolve(platform.codexDir(), "hooks.json"),
876
+ "codex",
877
+ "pal cli install --codex"
878
+ );
879
+ }
880
+ if (opencode.available) {
881
+ const fresh = checkOpencodePluginFresh();
882
+ fresh.ok
883
+ ? ok("opencode plugin: source-and-installed in sync")
884
+ : fail(`opencode plugin: ${fresh.reason}`);
885
+ }
886
+
887
+ // Inference routing preview — what `inference()` would do RIGHT NOW
888
+ console.log("");
889
+ log.info("Inference");
890
+ {
891
+ const preview = previewInferenceRoute();
892
+ console.log(` → Active agent: ${preview.agent}`);
893
+ if (preview.route === "none") {
894
+ fail(`Would route to: NONE — ${preview.reason}`);
895
+ } else if (preview.route === "disabled") {
896
+ warn(`Would route to: DISABLED — ${preview.reason}`);
897
+ } else if (preview.route.endsWith("-api")) {
898
+ warn(`Would route to: ${preview.route} (${preview.reason})`);
899
+ } else {
900
+ ok(`Would route to: ${preview.route} (${preview.reason})`);
901
+ }
902
+ }
903
+ if (process.env.PAL_INFERENCE_DISABLED === "1") {
904
+ warn(
905
+ "PAL_INFERENCE_DISABLED=1 — test kill-switch leaked into prod env; every inference call will return failure"
906
+ );
907
+ } else {
908
+ ok("PAL_INFERENCE_DISABLED is not set (production-safe)");
909
+ }
910
+
911
+ // Spawn-guard env vars should never appear in the user's shell — they're
912
+ // set ONLY in PAL-spawned subprocesses. Leaks into the user shell cause
913
+ // every inference call to short-circuit silently.
914
+ if (process.env.PAL_SPAWNED_INFERENCE) {
915
+ fail(
916
+ `PAL_SPAWNED_INFERENCE=${process.env.PAL_SPAWNED_INFERENCE} leaked into shell — every inference call will refuse. Unset it.`
917
+ );
918
+ } else {
919
+ ok("PAL_SPAWNED_INFERENCE not leaked (recursion guard clean)");
920
+ }
921
+ if (process.env.PAL_INFERENCE_DEPTH) {
922
+ fail(
923
+ `PAL_INFERENCE_DEPTH=${process.env.PAL_INFERENCE_DEPTH} leaked into shell — depth circuit-breaker will fire. Unset it.`
924
+ );
925
+ } else {
926
+ ok("PAL_INFERENCE_DEPTH not leaked (depth counter clean)");
927
+ }
928
+
929
+ // API key checks — both are optional safety-net fallbacks. Unset is normal
930
+ // for CLI-only setups; only matters if your native CLI breaks.
674
931
  process.env.PAL_ANTHROPIC_API_KEY
675
- ? ok("PAL_ANTHROPIC_API_KEY is set")
676
- : fail("PAL_ANTHROPIC_API_KEY not set (hooks need it for inference)");
932
+ ? ok("PAL_ANTHROPIC_API_KEY set (anthropic-api fallback available)")
933
+ : info("PAL_ANTHROPIC_API_KEY unset (optional anthropic-api fallback off)");
934
+ process.env.PAL_OPENAI_API_KEY
935
+ ? ok("PAL_OPENAI_API_KEY set (openai-api fallback for codex available)")
936
+ : info("PAL_OPENAI_API_KEY unset (optional — openai-api fallback off)");
677
937
  process.env.PAL_GEMINI_API_KEY
678
938
  ? ok("PAL_GEMINI_API_KEY is set")
679
939
  : warn("PAL_GEMINI_API_KEY — not set (optional, for YouTube analysis)");
@@ -14,8 +14,8 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
14
  import { resolve } from "node:path";
15
15
  import { paths } from "../hooks/lib/paths";
16
16
  import {
17
+ legacyJsonToProgress,
17
18
  type ProjectProgress,
18
- type ProjectStatus,
19
19
  readAllProjects,
20
20
  readProject,
21
21
  writeProject,
@@ -39,26 +39,6 @@ interface Migration {
39
39
 
40
40
  // ── v1-projects: JSON progress files → ISA.md ─────────────────────
41
41
 
42
- interface LegacyDecision {
43
- ts: string;
44
- decision: string;
45
- rationale: string;
46
- }
47
-
48
- interface LegacyProject {
49
- name: string;
50
- path: string;
51
- status: ProjectStatus;
52
- created: string;
53
- updated: string;
54
- facts?: string[];
55
- objectives?: string[];
56
- next_steps?: string[];
57
- blockers?: string[];
58
- handoff?: string;
59
- decisions?: LegacyDecision[];
60
- }
61
-
62
42
  function pendingJsonFiles(): string[] {
63
43
  const progressDir = paths.progress();
64
44
  if (!existsSync(progressDir)) return [];
@@ -92,37 +72,14 @@ const v1Projects: Migration = {
92
72
  const filePath = resolve(progressDir, file);
93
73
 
94
74
  try {
95
- const raw = JSON.parse(readFileSync(filePath, "utf-8")) as LegacyProject;
96
- if (!raw?.name || !raw?.path || !raw?.status) {
75
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
76
+ const p = legacyJsonToProgress(raw);
77
+ if (!p) {
97
78
  skipped++;
98
79
  results.push(`${slug}: skipped (malformed JSON)`);
99
80
  continue;
100
81
  }
101
-
102
- if (!dryRun) {
103
- const p: ProjectProgress = {
104
- name: raw.name,
105
- path: raw.path,
106
- status: raw.status,
107
- created: raw.created ?? new Date().toISOString(),
108
- updated: raw.updated ?? new Date().toISOString(),
109
- ...(raw.handoff ? { handoff: raw.handoff } : {}),
110
- ...(raw.next_steps?.length ? { next: raw.next_steps } : {}),
111
- ...(raw.blockers?.length ? { blockers: raw.blockers } : {}),
112
- };
113
-
114
- if (raw.facts?.length) p.context = raw.facts.join("\n");
115
- if (raw.objectives?.length)
116
- p.goal = raw.objectives.map((o) => `- ${o}`).join("\n");
117
- if (raw.decisions?.length) {
118
- p.decisions = raw.decisions
119
- .map((d) => `- ${d.ts.slice(0, 10)}: ${d.decision} (${d.rationale})`)
120
- .join("\n");
121
- }
122
-
123
- writeProject(p);
124
- }
125
-
82
+ if (!dryRun) writeProject(p);
126
83
  migrated++;
127
84
  results.push(`${slug}: ${dryRun ? "would migrate" : "migrated"} (source kept)`);
128
85
  } catch {
@@ -14,8 +14,12 @@ import { resolve } from "node:path";
14
14
  import { isCursor } from "./lib/agent";
15
15
  import { logDebug, logError } from "./lib/log";
16
16
  import { paths } from "./lib/paths";
17
+ import { isPalSpawnedInference } from "./lib/spawn-guard";
17
18
  import { readStdinJSON } from "./lib/stdin";
18
19
 
20
+ // Recursion guard — spawned subprocesses don't compact, so nothing to recover.
21
+ if (isPalSpawnedInference()) process.exit(0);
22
+
19
23
  interface SessionStartInput {
20
24
  session_id?: string;
21
25
  hook_event_name?: string;
@@ -11,10 +11,16 @@
11
11
 
12
12
  import { mkdirSync, writeFileSync } from "node:fs";
13
13
  import { resolve } from "node:path";
14
+ import { getActiveAgent, isCodex, isCopilot, isCursor } from "./lib/agent";
14
15
  import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
15
16
  import { type AgentTarget, buildSystemReminder } from "./lib/context";
16
17
  import { logDebug, logError } from "./lib/log";
17
18
  import { platform } from "./lib/paths";
19
+ import { isPalSpawnedInference } from "./lib/spawn-guard";
20
+
21
+ // Recursion guard — when this process is a PAL-spawned inference subprocess,
22
+ // skip all context loading so we don't trigger another inference call.
23
+ if (isPalSpawnedInference()) process.exit(0);
18
24
 
19
25
  // --- Skip heavy context for subagents ---
20
26
  const isSubagent =
@@ -37,14 +43,13 @@ try {
37
43
  // --- Context to stdout (or file for Copilot) ---
38
44
  try {
39
45
  // Determine agent target — controls which sections are skipped (loaded natively instead).
40
- let agent: AgentTarget = "claude";
41
- if (process.env.PAL_AGENT === "copilot") agent = "copilot";
42
- else if (process.env.PAL_AGENT === "cursor" || process.env.CURSOR_VERSION)
43
- agent = "cursor";
46
+ const active = getActiveAgent();
47
+ const agent: AgentTarget =
48
+ active === "copilot" || active === "cursor" ? active : "claude";
44
49
  const reminder = buildSystemReminder({ agent });
45
50
  if (!reminder) process.exit(0);
46
51
 
47
- if (process.env.PAL_AGENT === "copilot") {
52
+ if (isCopilot()) {
48
53
  // Copilot: semi-static in ~/.copilot/instructions/pal-*.instructions.md (written at stop).
49
54
  // Write AGENTS.md + dynamic context to pal-session.instructions.md on each session start.
50
55
  const instructionsDir = resolve(platform.copilotDir(), "instructions");
@@ -62,13 +67,13 @@ try {
62
67
  "LoadContext",
63
68
  `Copilot session instructions written: ${context.length} chars`
64
69
  );
65
- } else if (process.env.PAL_AGENT === "cursor" || process.env.CURSOR_VERSION) {
70
+ } else if (isCursor()) {
66
71
  // Cursor: semi-static in ~/.cursor/rules/pal-context.mdc; inject AGENTS.md + dynamic here
67
72
  const agentsMd = buildClaudeMd();
68
73
  const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
69
74
  process.stdout.write(JSON.stringify({ additional_context: context }));
70
75
  logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
71
- } else if (process.env.PAL_AGENT === "codex") {
76
+ } else if (isCodex()) {
72
77
  // Codex: AGENTS.md already loaded via symlink; inject only dynamic context
73
78
  process.stdout.write(
74
79
  JSON.stringify({
@@ -80,7 +85,7 @@ try {
80
85
  );
81
86
  logDebug("LoadContext", `Codex reminder injected: ${reminder.length} chars`);
82
87
  } else {
83
- // Claude Code: raw text
88
+ // Claude Code (and opencode, which uses the plugin path not this hook): raw text
84
89
  console.log(reminder);
85
90
  logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
86
91
  }
@@ -17,9 +17,13 @@ import { resolve } from "node:path";
17
17
  import { persistLastExchange } from "./handlers/persist-last-exchange";
18
18
  import { logDebug, logError } from "./lib/log";
19
19
  import { paths } from "./lib/paths";
20
+ import { isPalSpawnedInference } from "./lib/spawn-guard";
20
21
  import { readStdinJSON } from "./lib/stdin";
21
22
  import { readTranscriptFile } from "./lib/transcript";
22
23
 
24
+ // Recursion guard — spawned subprocesses don't compact, so nothing to persist.
25
+ if (isPalSpawnedInference()) process.exit(0);
26
+
23
27
  interface PreCompactInput {
24
28
  session_id?: string;
25
29
  transcript_path?: string;
@@ -9,14 +9,22 @@
9
9
  import { checkReadmeSync } from "./handlers/readme-sync";
10
10
  import { isCodex, isCursor } from "./lib/agent";
11
11
  import { logError } from "./lib/log";
12
+ import { isPalSpawnedInference } from "./lib/spawn-guard";
12
13
  import { readStdinJSON } from "./lib/stdin";
13
14
  import { runStopHandlers } from "./lib/stop";
14
15
  import { readTranscriptFile } from "./lib/transcript";
15
16
 
17
+ // Recursion guard — spawned inference subprocesses must not record session
18
+ // learning, ratings, or handoffs from their throwaway transcript.
19
+ if (isPalSpawnedInference()) process.exit(0);
20
+
16
21
  interface StopHookInput {
17
- session_id: string;
18
- transcript_path: string;
22
+ session_id?: string;
23
+ sessionId?: string; // Copilot uses camelCase
24
+ transcript_path?: string;
25
+ transcriptPath?: string; // Copilot uses camelCase
19
26
  last_assistant_message?: string;
27
+ lastAssistantMessage?: string; // Copilot uses camelCase
20
28
  }
21
29
 
22
30
  // Check README sync before anything else — may block the session
@@ -40,18 +48,22 @@ try {
40
48
  }
41
49
 
42
50
  const input = await readStdinJSON<StopHookInput>();
43
- if (!input?.transcript_path) {
51
+ const transcriptPath = input?.transcript_path ?? input?.transcriptPath;
52
+ const sessionId = input?.session_id ?? input?.sessionId;
53
+ const lastAssistant = input?.last_assistant_message ?? input?.lastAssistantMessage;
54
+
55
+ if (!transcriptPath) {
44
56
  logError("StopOrchestrator", "No transcript_path in hook input");
45
57
  process.exit(0);
46
58
  }
47
59
 
48
60
  // Read the actual transcript from the file on disk
49
- const messages = readTranscriptFile(input.transcript_path);
61
+ const messages = readTranscriptFile(transcriptPath);
50
62
  if (messages.length < 2) process.exit(0);
51
63
 
52
64
  // Serialize and run handlers
53
65
  const transcript = JSON.stringify(messages);
54
66
  await runStopHandlers(transcript, {
55
- lastAssistantMessage: input.last_assistant_message,
56
- sessionId: input.session_id,
67
+ lastAssistantMessage: lastAssistant,
68
+ sessionId,
57
69
  });
@@ -11,11 +11,17 @@ import { injectRetrieval } from "./handlers/inject-retrieval";
11
11
  import { captureRating } from "./handlers/rating";
12
12
  import { captureSessionName } from "./handlers/session-name";
13
13
  import { logDebug, logError } from "./lib/log";
14
+ import { isPalSpawnedInference } from "./lib/spawn-guard";
14
15
  import { readStdinJSON } from "./lib/stdin";
15
16
 
17
+ // Recursion guard — the "prompt" inside a spawned inference is the dispatcher's
18
+ // payload, not a real user message. Skip rating capture, session naming, etc.
19
+ if (isPalSpawnedInference()) process.exit(0);
20
+
16
21
  interface PromptSubmitInput {
17
22
  prompt: string;
18
23
  session_id?: string;
24
+ sessionId?: string; // Copilot sends this (camelCase) instead of session_id
19
25
  conversation_id?: string; // Cursor sends this instead of session_id
20
26
  }
21
27
 
@@ -23,7 +29,7 @@ const input = await readStdinJSON<PromptSubmitInput>();
23
29
  logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)}`);
24
30
  if (!input?.prompt) process.exit(0);
25
31
 
26
- const sessionId = input.session_id ?? input.conversation_id;
32
+ const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id;
27
33
  const results = await Promise.allSettled([
28
34
  captureRating(input.prompt, sessionId),
29
35
  captureSessionName(input.prompt, sessionId ?? ""),
@@ -96,6 +96,7 @@ interface AutoGraduateResult {
96
96
  * Returns a summary of what happened so callers (handler, tests) can reason
97
97
  * about the run without re-reading state.
98
98
  */
99
+ /** @lintignore — consumed by test/auto-graduate.test.ts via dynamic import */
99
100
  export async function autoGraduate(
100
101
  opts: AutoGraduateOptions = {}
101
102
  ): Promise<AutoGraduateResult> {
@@ -167,3 +168,10 @@ export async function autoGraduate(
167
168
  }
168
169
  return result;
169
170
  }
171
+
172
+ // Detached child entry point — runs the full autoGraduate cycle in isolation
173
+ // so the parent Stop hook does not block on the inference step.
174
+ if (process.argv[2] === "--run") {
175
+ await autoGraduate();
176
+ process.exit(0);
177
+ }