Skip to content
43 changes: 32 additions & 11 deletions packages/pi-plugin/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,20 @@ plugin process and reach `experimental.chat.messages.transform`. OpenCode gates
historian / m[0]m[1] injection / nudges / auto-search behind `fullFeatureMode`
(i.e. `!isSubagent`), and detects subagents via OpenCode's `session.parent_id`.

**Pi:** Pi has **no native subagent concept**. The *only* subagents that exist
are the ones Magic Context itself spawns (historian, dreamer, sidekick), and each
runs as a **separate `pi --print` process** loading only the lean
`subagent-entry.js`, whose recursion guard **never wires `pi.on("context")`**
(see `subagent-entry.ts` header). A Pi subagent therefore *cannot* reach the
context-handler pipeline at all.

**Consequence:** `is_subagent` is **never written `true`** for any Pi session.
**Pi:** Pi has **no native subagent concept**. The subagents Magic Context itself
spawns (historian, dreamer, sidekick) each run as a **separate `pi --print` process**
loading only the lean `subagent-entry.js`, whose recursion guard **never wires
`pi.on("context")`** (see `subagent-entry.ts` header). A Magic Context subagent
therefore *cannot* reach the context-handler pipeline at all.
`@gotgenes/pi-subagents`, however, can initialize a child session inside the same
process. The full extension uses Pi's public child-session lifecycle events plus
process-shared `AsyncLocalStorage` to suppress only the child while allowing
unrelated same-process sessions to initialize normally.

**Consequence:** `is_subagent` is **never written `true`** for any Pi session
that reaches the context-handler pipeline. Separate child processes load the lean
entry, while in-process child initialization is suppressed before the normal
context pipeline is registered.
There is nothing to gate, so Pi does NOT need OpenCode's `fullFeatureMode`
reduced-mode enforcement in `context-handler.ts`. The vestigial `!isSubagent`
checks that exist in the Pi context handler are harmless (always take the
Expand Down Expand Up @@ -115,15 +121,22 @@ the source array for dirty indices only.

---

## 6. Transient UI: Pi uses `ctx.ui.notify` toasts, not persistent dialogs
## 6. Transient UI: Pi uses `ctx.ui.notify` toasts and RPC dialogs

**OpenCode:** TUI dialogs (upgrade prompt, `/ctx-status`, `/ctx-recomp`, `/ctx-embed`, `/ctx-flush`) via RPC,
with an ignored-message fallback for Desktop/Web. Notification drain is
**session-scoped** (a notification tagged for one session never surfaces in
another) because one process can serve multiple sessions and TUI port discovery
is newest-pid-wins.

**Pi:** transient terminal notifications. The upgrade reminder passes
**Pi:** command status is appended as a model-invisible custom entry. Interactive
terminals render that entry through the registered entry renderer. In Pi RPC
mode, each command uses its live `ctx`: `ctx.ui.notify` presents short progress
as toasts. RPC hosts that execute the `ctx.ui.custom` component factory (such as
pi-web) present detailed results as dialogs; hosts where `custom` resolves without
executing the factory receive the same details through a notification fallback.
A context captured by `session_start` cannot be reused because pi-web can host
multiple sessions in one process. The upgrade reminder passes
`deliveryPersists=false` on Pi, so a missed toast does not honor the old explicit-
dismissal stamp. Both harnesses persist the 24-hour reminder cooldown and three-
delivery cap, preventing repeated startup toasts while `/ctx-status` still reports
Expand Down Expand Up @@ -155,6 +168,13 @@ shared resolver's log-only dubious-ownership warning while still using the same
**stdin** (Pi concatenates stdin + positional) to avoid Linux `MAX_ARG_STRLEN`
/ E2BIG; the positional is omitted when piping.
- `--no-session` keeps subagent JSONL out of the user's session picker.
- In pi-web, multiple sessions can share one process. Startup maintenance runs
once per process, while each session wires its own hooks. Dreamer registration
is process-shared and tracks sibling ownership, so one session's shutdown cannot
deregister another session's project timer.
- `session_shutdown` drains only that session's in-flight historian and recomp work
and only the shutting-down extension instance's Dreamer work. Child-session
lifecycle listeners are detached only for that extension instance.

---

Expand Down Expand Up @@ -385,7 +405,8 @@ mechanism differs because the process models differ:
inline `await` froze all input. Pi instead spawns the recomp via
`spawnPiRecompRun` (mirroring `spawnPiHistorianRun`): the handler returns
immediately after the ack message, the run is tracked in an in-flight map for
`session_shutdown` drain, and progress surfaces through `[ctx-status]`
`session_shutdown` drain (keyed by session id so one session does not drain
another), and progress surfaces through `[ctx-status]`
messages + the `recomp` status-line flag.

Because Pi's recomp runs in the background (not inside the user's turn), its
Expand Down
37 changes: 32 additions & 5 deletions packages/pi-plugin/src/agent-end-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,42 @@ describe("session_shutdown handler (drain location)", () => {
const body = extractSessionShutdownHandlerBody(INDEX_SRC);

test("drains in-flight historians through withTimeout", () => {
expect(body).toContain("awaitInFlightHistorians");
expect(body).toContain(
"withTimeout(awaitInFlightHistorians(), SHUTDOWN_DRAIN_MS)",
expect(body).toMatch(
/withTimeout\(\s*awaitInFlightHistorians\(sessionId\),\s*SHUTDOWN_DRAIN_MS,?\s*\)/,
);
expect(body).not.toContain("Promise.race");
});

test("drains in-flight dreamers (Promise.race with timeout)", () => {
expect(body).toContain("awaitInFlightDreamers");
test("drains the shutting-down session's recomp through withTimeout", () => {
expect(body).toMatch(
/withTimeout\(\s*awaitInFlightRecomps\(sessionId\),\s*SHUTDOWN_DRAIN_MS,?\s*\)/,
);
});

test("aborts a recomp that outlives the graceful drain before shutdown returns", () => {
const drainAt = body.indexOf("awaitInFlightRecomps(sessionId)");
const abortAt = body.indexOf("abortInFlightRecomps(sessionId)");
expect(abortAt).toBeGreaterThan(drainAt);
});

test("drains the current extension owner's dreamers through withTimeout", () => {
expect(body).toMatch(
/withTimeout\(\s*awaitInFlightDreamers\(dreamerRegistrationOwner\),\s*SHUTDOWN_DRAIN_MS,?\s*\)/,
);
});

test("stops Dreamer registration before draining its work", () => {
const shutdownAt = body.indexOf("sessionShuttingDown = true");
const unregisterAt = body.indexOf("unregisterPiDreamerProject");
const drainAt = body.indexOf("awaitInFlightDreamers");
expect(shutdownAt).toBeGreaterThanOrEqual(0);
expect(unregisterAt).toBeGreaterThanOrEqual(0);
expect(drainAt).toBeGreaterThanOrEqual(0);
expect(shutdownAt).toBeLessThan(unregisterAt);
expect(unregisterAt).toBeLessThan(drainAt);
expect(INDEX_SRC).toMatch(
/function syncDreamerProjectRegistration[\s\S]*?if \(sessionShuttingDown\) return;/,
);
});

test("drain timeout uses unref/clear helper", () => {
Expand Down
Loading