diff --git a/packages/pi-plugin/src/index-in-process-latch.test.ts b/packages/pi-plugin/src/index-in-process-latch.test.ts
index 40ae93765..c6b532ec1 100644
--- a/packages/pi-plugin/src/index-in-process-latch.test.ts
+++ b/packages/pi-plugin/src/index-in-process-latch.test.ts
@@ -151,9 +151,11 @@ function createCountingPi() {
for (const handler of eventBusHandlers.get(channel) ?? []) handler(data);
},
async emitPiEvent(event: string, data: unknown = {}, ctx: unknown = {}) {
+ const results: unknown[] = [];
for (const handler of piEventHandlers.get(event) ?? []) {
- await handler(data, ctx);
+ results.push(await handler(data, ctx));
}
+ return results;
},
};
}
@@ -965,6 +967,90 @@ describe("Pi in-process child guard (#247)", () => {
}, 15_000);
});
+describe("Pi system-prompt section injection (#649)", () => {
+ it("injects the guidance block as a structured section that composes with later extensions", async () => {
+ isolateXdgEnv();
+ delete process.env[MAGIC_CONTEXT_PI_SUBAGENT_ENV];
+ const runtime = createCountingPi();
+ await magicContextPiExtension(runtime.pi);
+
+ // Register a second extension's handler AFTER magic-context, mirroring
+ // Pi's in-order before_agent_start dispatch: a later extension writing
+ // its own section into the same shared, mutable options map.
+ runtime.pi.on("before_agent_start", (event) => {
+ event.systemPromptOptions.sections.later_ext =
+ "snapshot";
+ });
+
+ const ctx = {
+ cwd: process.cwd(),
+ hasUI: false,
+ sessionManager: { getSessionId: () => "ses-section-injection" },
+ ui: { setStatus() {} },
+ };
+ await runtime.emitPiEvent("session_start", {}, ctx);
+
+ // Mirror Pi's BeforeAgentStartEvent: systemPromptOptions.sections is
+ // the normalized, mutable map every extension's handler writes into.
+ const event = {
+ type: "before_agent_start",
+ prompt: "hello",
+ systemPrompt: "BASE PROMPT",
+ systemPromptOptions: { sections: {} as Record },
+ };
+ await runtime.emitPiEvent("before_agent_start", event, ctx);
+
+ // The guidance block must land in the shared sections map — not in a
+ // forced prompt, which Pi would render INSTEAD of the sections and
+ // thereby hide the later extension's section from the provider.
+ expect(event.systemPromptOptions.sections.magic_context).toContain(
+ "## Magic Context",
+ );
+ expect(event.systemPromptOptions.sections.later_ext).toBe(
+ "snapshot",
+ );
+
+ await runtime.emitPiEvent("session_shutdown", {}, ctx);
+ }, 20_000);
+
+ it("keeps the forced-prompt fallback on hosts without a sections API (Oh My Pi)", async () => {
+ isolateXdgEnv();
+ delete process.env[MAGIC_CONTEXT_PI_SUBAGENT_ENV];
+ const runtime = createCountingPi();
+ await magicContextPiExtension(runtime.pi);
+ const ctx = {
+ cwd: process.cwd(),
+ hasUI: false,
+ sessionManager: { getSessionId: () => "ses-omp-fallback" },
+ ui: { setStatus() {} },
+ };
+ await runtime.emitPiEvent("session_start", {}, ctx);
+
+ // Oh My Pi's before_agent_start event has no systemPromptOptions
+ // (verified on Oh My Pi 18.2.6 and 18.8.7): the handler must not throw
+ // and must keep injecting via a forced prompt return, or requests
+ // would go out with no guidance block at all.
+ const event = {
+ type: "before_agent_start",
+ prompt: "hello",
+ systemPrompt: "BASE PROMPT",
+ };
+ const results = await runtime.emitPiEvent(
+ "before_agent_start",
+ event,
+ ctx,
+ );
+ const forced = results.find(
+ (r): r is { systemPrompt: string } =>
+ typeof r === "object" && r !== null && "systemPrompt" in r,
+ );
+ expect(forced?.systemPrompt).toContain("BASE PROMPT");
+ expect(forced?.systemPrompt).toContain("## Magic Context");
+
+ await runtime.emitPiEvent("session_shutdown", {}, ctx);
+ }, 20_000);
+});
+
beforeEach(async () => {
await observeProducerModelsForTest(["test/historian"]);
});
diff --git a/packages/pi-plugin/src/index.ts b/packages/pi-plugin/src/index.ts
index 96ab7dbcd..7d266fb82 100644
--- a/packages/pi-plugin/src/index.ts
+++ b/packages/pi-plugin/src/index.ts
@@ -2428,21 +2428,45 @@ async function startPiMagicContextRuntime(
existingSystemPrompt: event.systemPrompt,
});
- // Compose the final system prompt: base prompt from Pi + our
- // magic-context block. We always run hash detection on the
- // composed string so even sessions with no data block (e.g.
- // memories disabled, no docs, no key files) still get
- // sticky-date freezing and hash-change tracking.
+ // Compose the reference system prompt (base prompt from Pi +
+ // our magic-context block) for hash detection. We always run
+ // hash detection on the composed string so even sessions with
+ // no data block (e.g. memories disabled, no docs, no key
+ // files) still get hash-change tracking.
const composedPrompt = composeMagicContextSystemPrompt(
event.systemPrompt,
block,
);
+ // Inject through the host's structured sections API when it has
+ // one. On Pi, returning `systemPrompt` from before_agent_start
+ // makes the host replace the WHOLE prompt with opaque text:
+ // sections set by later-registered extensions are then still
+ // recorded in the transcript but never sent to the provider
+ // (Pi renders forceSystemPrompt instead of the structured
+ // sections), so any extension registered after us silently
+ // loses its system-prompt section. Oh My Pi's before_agent_start
+ // event has no systemPromptOptions at all, and Pi releases
+ // before 0.87 have no `sections` field, so the forced prompt
+ // return below remains the only injection path on those hosts.
+ // The plugin compiles against Pi 0.83 types, which predate
+ // `sections`; the widened type below describes newer hosts
+ // without claiming the field always exists.
+ const promptOptions:
+ | (NonNullable & {
+ sections?: Record;
+ })
+ | undefined = event.systemPromptOptions;
+ const hostSections = promptOptions?.sections;
+ if (block && hostSections) {
+ hostSections.magic_context = block;
+ }
+
if (!sessionId) {
- // No session id yet — return the composed prompt without
- // cache logic. The next turn (with a session id) will
- // compute the first hash and set sticky date.
- if (block) return { systemPrompt: composedPrompt };
+ // No session id yet — inject without cache logic. The
+ // next turn (with a session id) will compute the first
+ // hash and set sticky date.
+ if (block && !hostSections) return { systemPrompt: composedPrompt };
return;
}
@@ -2475,6 +2499,16 @@ async function startPiMagicContextRuntime(
clearSystemPromptRefresh(sessionId);
}
+ // On hosts with a sections API (Pi) the block already went
+ // out as a structured section above; returning a forced
+ // prompt here would hide later extensions' sections from the
+ // provider. Only hosts without that API
+ // (Oh My Pi) still need the forced return. Pi's prompt has
+ // no `Today's date:` line for processSystemPromptForCache
+ // to freeze, so dropping the force there loses nothing.
+ if (hostSections) {
+ return;
+ }
return { systemPrompt: result.systemPrompt };
} catch (error) {
warn("failed to build magic-context block:", error);
diff --git a/packages/pi-plugin/src/signal-peek-drain.test.ts b/packages/pi-plugin/src/signal-peek-drain.test.ts
index 278205234..d91e29967 100644
--- a/packages/pi-plugin/src/signal-peek-drain.test.ts
+++ b/packages/pi-plugin/src/signal-peek-drain.test.ts
@@ -298,6 +298,32 @@ describe("source contract: peek-then-drain in before_agent_start (system prompt)
expect(window).toMatch(/if\s*\(\s*isCacheBusting\s*\)/);
});
+ test("system-prompt injection prefers sections, with a guarded forced fallback for hosts without them", () => {
+ // Pi exposes systemPromptOptions.sections; Oh My Pi's
+ // before_agent_start event has no systemPromptOptions at all
+ // (verified on Oh My Pi 18.2.6 and 18.8.7). Injection must prefer the
+ // shared sections map — an unconditional forced return hides later
+ // extensions' sections on Pi — and fall back to a forced prompt
+ // only when the host has no sections API, because an unconditional
+ // sections assignment throws on Oh My Pi and the handler's catch
+ // would then drop our guidance block entirely.
+ expect(code).toContain("= event.systemPromptOptions;");
+ expect(code).toContain("const hostSections = promptOptions?.sections;");
+ expect(code).toContain("hostSections.magic_context = block");
+ // Exactly two forced fallbacks exist, each on its explicit
+ // no-sections guard: an unguarded forced return adds a third match
+ // or breaks the guard patterns (hiding later Pi sections), and
+ // deleting a fallback drops the count (breaking Oh My Pi).
+ const forcedReturns = [...code.matchAll(/return\s*\{\s*systemPrompt\b/g)];
+ expect(forcedReturns).toHaveLength(2);
+ expect(code).toMatch(
+ /if\s*\(\s*block\s*&&\s*!hostSections\s*\)\s*return\s*\{\s*systemPrompt\b/,
+ );
+ expect(code).toMatch(
+ /if\s*\(\s*hostSections\s*\)\s*\{\s*return;?\s*\}\s*return\s*\{\s*systemPrompt\b/,
+ );
+ });
+
test("system-prompt injection supports global disable, skip signatures, and existing prompt dedup", () => {
// Council #4 (project-config bleed on /cd): these decisions use
// effectiveConfig — the config re-resolved from the CURRENT checkout's