This document records deliberate differences between the Pi plugin
(packages/pi-plugin/) and the OpenCode plugin (packages/plugin/src/hooks/magic-context/).
For auditors (human, Oracle, or council): the items below are NOT bugs. They are architectural consequences of how Pi differs from OpenCode. Do not flag them as parity gaps. If you believe one is wrong, argue against the rationale here — don't just report "Pi differs from OpenCode."
The two implementations share a single SQLite DB (cortexkit/magic-context) and
the same packages/plugin/src core (storage, decay rendering, tag-transcript,
search). They must produce the same effective behavior (cache stability,
overflow protection, decay tiers), but the mechanism differs where the host
runtimes differ. "Same effective behavior, different mechanism" is the rule.
OpenCode: users spawn native subagents (via task()), which share the
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.
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
non-subagent branch); they are not the enforcement OpenCode has and adding that
gate would be dead code.
Recurring false positive: blind councils pattern-match OpenCode's subagent gate onto Pi and report "reduced mode not enforced." It does not apply.
OpenCode (strip-content.ts): replaces a placeholder-only message's parts
with a single empty-text sentinel, leaving the message in the array so the
array length / structure stays stable for proxy caches. Safe to run discovery on
any execute pass.
Pi (strip-placeholders-pi.ts): Pi rebuilds AgentMessage[] from JSONL every
pass, so there is no need to preserve array structure — it splices the
message out entirely.
Consequence: Pi gates placeholder discovery to history-refresh passes
only (args.isCacheBusting), NOT the broader shouldApplyPendingOps || shouldRunHeuristics OpenCode uses. A freshly-dropped tool stub renders as
[dropped §N§], which isDroppedOnlyText matches — so discovering on the same
execute pass that created the drop would splice out the just-dropped turn and
collapse it. Discovery is therefore deferred to the next refresh boundary;
replay still runs every pass. (This was learned the hard way — broadening the
gate to executedWorkThisPass caused a turn-collapse regression.)
Both harnesses never neutralize/remove user-role messages — they anchor turn
boundaries. In Pi's raw array tool results carry role "toolResult"; the
synthetic-user folds live only in the transcript view (never written back), so
only genuine prompts are user-role in the stripped array.
Pi has no makeSentinel empty-text-part wire path; the empty-part-sentinel
provider gate is OpenCode-only by construction.
OpenCode: session.deleted is terminal — the session is gone. OpenCode's
handler clears both in-memory maps AND durable per-session DB state.
Pi: Pi has no session.deleted. The closest event is
session_before_switch, which fires when the user switches away — but the
session can be switched back. So the Pi switch handler clears only in-memory
maps (the actual per-swap leak) and must NOT clear durable DB caches
(cached_m0_*, boundary). Clearing the durable m[0] cache on switch would force
a full re-materialization (cache bust) on switch-back. The DB cache is bounded
(one session_meta row) and self-invalidates via epoch/version/docs-hash.
clearSession() (full durable cleanup) only runs where Pi has a genuine terminal
signal; it is intentionally NOT wired to session_before_switch.
Pi cancels native Pi compaction (session_before_compact → {cancel:true})
and owns the boundary itself: Magic Context stages a native compaction marker
(pending_pi_compaction_marker_state) and drains it on the next materializing
pass so getBranch() returns the compacted tail. The wire/context trim
(trimPiMessagesToBoundary) runs every injection pass independent of the
native JSONL marker — so even if the marker lags (e.g. a crash window), the
model-visible context is still trimmed. OpenCode uses its own
deferred-compaction-marker mechanism (compaction-marker.ts); the two are
mechanism-parallel, not identical.
Consequence: Pi does not need OpenCode's in-place sentinel persistence for
array-shape stability. Byte-stability across defer passes is achieved by
replaying persisted state (tags, dropped-status, stripped_placeholder_ids,
note/sticky anchors, caveman depth, source_contents) deterministically each
pass. The transcript adapter's commit() writes part-level mutations back into
the source array for dirty indices only.
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
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
compartments that need upgrading.
OpenCode: when git refuses a repository as dubious ownership, the shared
project-identity resolver logs the fallback and the OpenCode transform/hook path
sends a one-shot session notification with the safe.directory command.
Pi: packages/pi-plugin/src/index.ts surfaces config warnings through the
standard warn() log channel and has no startup/session warning channel
equivalent to OpenCode's ignored-message fallback. Pi therefore relies on the
shared resolver's log-only dubious-ownership warning while still using the same
dir: fallback identity and retry cooldown.
- Pi sessions are JSONL (
~/.pi/agent/sessions/*.jsonl); OpenCode uses its own SQLite DB. Both write the shared Magic Context DB, tagged with aharnessdiscriminator on session-scoped tables. - Pi subagents spawn via
PiSubagentRunner(pi --print --mode json). Large prompts (> ~96 KiB, e.g. a 50K-token historian chunk) are delivered via piped stdin (Pi concatenates stdin + positional) to avoid LinuxMAX_ARG_STRLEN/ E2BIG; the positional is omitted when piping. --no-sessionkeeps subagent JSONL out of the user's session picker.
synth-user-<realId>folding: Pi folds runs oftoolResultentries into a synthetic user message (the toolResult→assistant transition). Tail tool-result runs (no following user) get asynth-user-<firstToolResultEntryId>id so the tail tool output is taggable/droppable. Consumers handle the prefix differently by design: compaction-boundary selection (findFirstKeptEntryId) defers (returns null) on a synthetic boundary; boundary trim resolves it to the underlying real entry id.pi_stable_id_scheme(migration v25): a one-time forced-execute cutover that re-keys persisted tag/drop/caveman/placeholder state frompi-msg-<index>ids to realSessionEntryids. OpenCode has stable message ids natively.syntheticLeadingCount: anchor-GC excludes the id-less m[0]/m[1] synthetic prepends from its "all messages resolved" denominator. OpenCode messages all have intrinsicinfo.id, so it has no such id-less injected messages to exclude.- Dynamic
upgradeState: Pi derivesupgradeStatefrom the presence of legacy compartments at runtime.
OpenCode: message.updated carries the finalized assistant messageID, so
Magic Context can bind the in-memory transform decision to that id as soon as the
terminal token update arrives.
Pi: the context event's AgentMessage has no stable id, and at message_end
the assistant SessionEntry wrapper has not been appended yet. Pi therefore
records the transform decision in memory with a snapshot of the newest assistant
entry id seen at pass start, then resolves it at the start of the next context
pass by finding the newest assistant SessionEntry.id different from that
snapshot. The dashboard keys Pi cache rows on that wrapper id, so this delayed
bind is the first point where the correct durable key exists. The final turn's
decision is written on the next prompt; that is accepted telemetry behavior.
The ctx_reduce nudge system (Channels 1 & 2) shares ALL metric math with OpenCode
via @magic-context/core/.../ctx-reduce-nudge (decideChannel1, computePressure,
shouldTriggerChannel2, both reminder builders, tailToolTokensFromStrings). Only
the harness I/O differs:
-
Channel 1 (in-turn tool-output nudge). OpenCode appends the
<system-reminder>to a tool'soutput.outputstring intool.execute.after; Pi appends aTextContentblock totoolResult.content[]inpi.on("tool_result")(returning{ content: [...event.content, block] }). Both persist (OpenCode→DB, Pi→JSONL viaappendMessageonmessage_end) and replay verbatim — "free sticky", no anchor/CAS/replay machinery. The metric baseline is computed at the end of the pipeline (pi.on("context")/ OpenCode transform) and read in the tool hook. The cadence/band state (last_nudge_undropped+last_nudge_level) is shared DB state so both harnesses suppress same-band repetition and reset afterctx_reduce. Pi tool output lives intoolResult.content[].text, not OpenCode'sparts[].state.output—computeTailToolTokensPiextracts it, then defers to the sharedtailToolTokensFromStrings. -
Channel 2 (hidden ceiling nudge). OpenCode MUST use a live-server
createOpencodeClient(serverUrl)+/sessionprobe to dodge the plugin runner-split bug (anomalyco/opencode#28202); Pi just calls the nativepi.sendMessage({ customType, content, display:false, details }, { deliverAs }). Pi has no #28202 workaround, no live-server client, and no probe — it is single-process, so the message coalesces natively and lands at the tail after the current turn. Hidden-render divergence (same intent, different mechanism): OpenCode marks its promptAsync partsynthetic: true(skips OC core's queued-message wrapper + the #129 flip-bust, drops from the user-message render, still model-visible); Pi has no such wrapper, so it achieves the same "model-visible but not a literal user turn" via asendMessagecustom message withdisplay:false(Pi convertsrole:"custom"→user message for the model via convertToLlm, renders only whendisplay:true). Neither presents the nudge as a user turn. The sharedchannel2_nudge_statelease (pending→claimed→delivered, TTL-scoped stale-claim heal, revert only on send failure) is used identically for the one-ceiling-per-lifetime cap; only the delivery call differs. Both deliver MID-TURN at step boundaries (the point of the channel: warn while the pile grows): OpenCode frommessage.updated(finish=tool-calls OR stop, queued message drains at the next run-loop step); Pi primarily fromtool_resultwith deliverAs "steer" (queued, pulled at the next step), withagent_end+ "followUp" as the idle fallback. -
Removed in this redesign (both harnesses): the rolling/iteration nudge (
nudger/injectPiNudge/nudge-injector.ts) and the tool-heavy sticky reminder (applyStickyTurnReminder,setPersistedStickyTurnReminder, the<instruction name="ctx_reduce_turn_cleanup">text). Pi's now-removedrecordPiToolExecution/toolUsageSinceUserTurntracking backed only the deleted sticky reminder. Note-nudges and auto-search hints are UNCHANGED (still append to user messages viaappendReminderToUserMessageByIdPi).
OpenCode: pressure is refreshed per step through message.updated /
step-finish, so a tool-heavy turn sees context usage climb before the next
request is assembled. OpenCode also performs its own step-finish overflow check,
so no explicit forward floor is needed in the shared pressure path.
Pi: message_end persists lastContextPercentage only after the whole turn.
During a long multi-step turn that value can stay frozen while the live
AgentMessage[] grows. Pi therefore floors both scheduler and historian trigger
pressure with ctx.getContextUsage().tokens, which is recomputed from the live
message array each context pass.
The floor scales only the forward-pressure denominator (contextLimit × 0.85)
to compensate for Pi's estimate-token undercount. It does not mutate the real
context limit, and it passes the raw forward token count onward so emergency drop
planning still sees the current assembled size. The floor is monotonic: it never
lowers the persisted pressure, and missing/null forward usage preserves the old
behavior. Earlier Channel 1/2 ctx_reduce nudges can result because their
usable/reclaimable math consumes the same corrected input-token reading; those
nudges are persisted/replayed like the rest of Pi's sticky context hints.
Emergency drops remain cache-stable: repeated force passes on the same provider
usage sample are latched by last_emergency_input_sample, fresh same-turn
forward growth may force another pass, and a no-candidate force pass leaves wire
bytes unchanged.
OpenCode: after the ≥95% historian attempt and emergency tool drops, it
aborts only when recovery was armed by a provider's own context-overflow rejection
and no historian fold materialized reclaim this pass. The TypeScript final-wire
estimate remains telemetry because it cannot reproduce provider framing; numeric
gating is deferred to module-side Rust accounting. Proactive model-shrink recovery
never aborts before the provider has rejected the turn. On a provider-proven abort,
OpenCode sends the actionable /ctx-flush or /clear notification and then awaits
session.abort(), interrupting the run before a guaranteed repeat rejection.
Pi: the context extension API has no turn-abort primitive. At ≥95%, Pi sends the same loud actionable notification, waits briefly for any in-flight historian, and applies its existing emergency drops, but must return the best-effort reduced context to Pi. Magic Context intentionally does not invent an abort mechanism outside Pi's extension API.
When Magic Context clears an aged reasoning/thinking block, the two harnesses use DIFFERENT mechanisms because their serializers differ. The divergence is deliberate and source-justified.
-
OpenCode (
clearOldReasoning+stripClearedReasoning,strip-content.ts): rewrites the thinking text to[cleared], then — only for canonical Anthropic (canUseEmptySentinels === providerID==="anthropic") — replaces the whole part with an empty text sentinel that@ai-sdk/anthropicdrops before the wire (signature gone). For NON-canonical providers OpenCode now gates the clear OFF entirely (reasoning left intact), because OpenCode's non-Anthropic adapters forward empty parts and would otherwise leave a literal[cleared](or a stale signature) on the wire. (#162 D2.) -
Pi (
reasoning-replay-pi.ts): EMPTIES the thinking text (thinking = "") and drops the now-stalethinkingSignature, with NO per-provider gate — EXCEPT it leavesredactedthinking blocks untouched. Every Pi serializer drops an empty non-redacted thinking block before the wire —anthropic.ts(empty thinking skipped),openai-completions.ts(filtered out ofnonEmptyThinkingBlocks, withreasoning_content=""auto-filled for providers that require it),amazon-bedrock.ts/google-shared.ts/mistral.ts(empty thinking skipped). So no normal block and no signature reach ANY provider, which structurally eliminates the stale-signature mismatch and needs no gate. Redacted blocks are the exception: they serializeredactedBEFORE the empty-thinking check (transform-messages.ts,anthropic.ts), so emptying one- dropping its signature would put a malformed redacted block (no data, no sig) on the wire. They carry no plaintext to save, so Pi keeps them verbatim — safe and byte-stable.
Why the OLD "keep the signature" note was wrong: a thinkingSignature is a
cryptographic signature over the ORIGINAL thinking text, so [cleared] (or any
rewrite) + the original signature is a content/signature MISMATCH on canonical
Claude/Bedrock — a real 400 hazard, not a safe no-op. Both harnesses now ensure
no rewritten-with-stale-signature thinking block reaches the wire: OpenCode by
dropping the empty sentinel (canonical only) / not clearing (otherwise), Pi by
emptying so its serializers drop the block. clearOldReasoning only touches OLD
assistants (≥ clear_reasoning_age tags back); the latest assistant keeps its
real reasoning on both harnesses.
OpenCode gates m[1] recompute on isCacheBustingPass (shouldApplyPendingOps || shouldRunHeuristics); Pi gates on executedWorkThisPass || rematerialized — same effective set, different assembly.
/ctx-recomp and /ctx-session-upgrade run DETACHED on both harnesses — the
REPL/TUI stays responsive while the multi-pass historian recomp runs — but the
mechanism differs because the process models differ:
- OpenCode runs
void runManagedRecomp(...)/void runManagedUpgrade(...)in its separate server process; the TUI client keeps accepting input and shows a live progress bar via RPC polling. - Pi is a single-process REPL where the command handler IS the turn, so an
inline
awaitfroze all input. Pi instead spawns the recomp viaspawnPiRecompRun(mirroringspawnPiHistorianRun): the handler returns immediately after the ack message, the run is tracked in an in-flight map forsession_shutdowndrain, and progress surfaces through[ctx-status]messages + therecompstatus-line flag.
Because Pi's recomp runs in the background (not inside the user's turn), its
post-publish signals are the DEFERRED variants (signalPiDeferredHistoryRefresh
/ signalPiDeferredMaterialization) and the compaction marker is STAGED (pending
blob + deferred drain), never applied eagerly — exactly like the background
historian's onPublished. Eager signals / eager marker apply would force a
materialization (or mutate getBranch()) on whatever transform pass is running,
possibly mid-turn, busting the cache.
The TUI/status "work metrics" (new-work / total-input tokens) are a display-only value. The two harnesses compute it from different sources, so the cost profiles differ and the fixes differ:
- Pi (
context-handler.ts) callscomputePiWorkMetrics(outputMessages)— a fold over the already-in-memory wire array, bounded by the on-wire message count. It is cheap per pass and stays where it is. - OpenCode previously called
computeOpenCodeWorkMetricson every transform pass — a window-functionjson_extractscan over EVERY assistant row of the session in OpenCode's DB (O(session age); ~250ms/pass at 47K rows). That was removed from the transform hot path. OpenCode now computes it lazily and incrementally inbuildSidebarSnapshot(the only consumer) viacomputeOpenCodeWorkMetricsIncremental+ a per-process watermark carry.
Pi does NOT need the incremental watermark machinery because its source is the bounded wire array, not an ever-growing DB table. Do not "port" the OpenCode lazy/incremental path to Pi — it would be solving a cost Pi does not have.
Both harnesses derive a per-session m[0] upgrade-state marker dynamically and use
it as a HARD-bust trigger so an upgraded session re-materializes m[0]. OpenCode
computes getUpgradeState; Pi computes ${PI_M0_UPGRADE_STATE}:${legacy|ready}
from the presence of legacy compartments at render time
(inject-compartments-pi.ts), and the materialize stale-check compares it
(current.upgradeState !== snapshotMarkers.upgradeState).
This is parity, not a divergence. (Earlier revisions of this doc described
Pi's marker as a pinned constant — that is stale: Pi gained its own legacy→v2
/ctx-session-upgrade flow and the marker was made dynamic to refold m[0] when a
session crosses from legacy to upgraded. Pi's detached recomp/upgrade —
divergence #11b — additionally re-signals materialization through its own path.)
OpenCode wires the SDK server.instance.disposed event to an orderly per-instance
cleanup (stop the RPC server, unregister the dream-schedule timer, abort the
auto-update controller), gated on the disposed directory resolving to the
instance's own project identity (Desktop runs many instances per process, each
disposed independently). Pi has no server.instance.disposed event — it does the
equivalent teardown in its existing session_shutdown handler (drain in-flight
historian, etc.). Neither harness disposes the native ONNX embedding session on
teardown: forcing onnxruntime-node's destructor makes the Bun N-API exit crash
worse (tracked upstream at oven-sh/bun#30291); the OS reclaims that memory on exit.
When the shared cross-harness context.db is migrated to a schema newer than
this binary supports, openDatabase() fail-closes (returns null) and the plugin
disables itself. Both harnesses log the reason. The user-facing surface
differs by necessity:
- OpenCode sends an ignored chat message via
sendSchemaFenceWarning(Desktop has no visible console, so a silent disable would be invisible to the user). Gated ongetSchemaFenceRejection(). - Pi emits a terminal
warn()only. Pi's fence check runs at extension init, before any sessionctx/ctx.uiexists (it early-returns before registering hooks), and Pi always runs in a terminal where the log line is directly visible — so the OpenCode "invisible disable" failure mode does not apply. Adding a chat-surface warning would require deferring the fence check past hook registration, which contradicts fail-closed-before-any-work.
Same effective behavior (fail closed + tell the user); different delivery because only OpenCode Desktop can hide the log.
Neither harness reads OpenCode's models.json (models.dev) file anymore — that
redundant read produced torn-read garbage (a 6748 "limit" for a session that had
run for hours) and let a stale on-disk copy out-vote the live auth-resolved cap
(922k vs the real Codex-OAuth 400k). Each harness now resolves the limit from its
own authoritative runtime source, then bounds it to a sane [20k, 3M] range
(shared isSaneLimit):
- OpenCode warms
apiCachefrom the SDKconfig.providers()(OpenCode's fully-resolved config: models.dev + snapshot + opencode.json + auth-plugin caps), persisted for cold-start.getSdkContextLimit()returns the SDK value orundefined. When it is undefined, OpenCode's trusted resolver can use the sanesession_meta.last_usage_context_limitonly whenlast_observed_model_keymatches the current model. Pi never warmsapiCache, so for Pi that getter is unused. - Pi resolves from its own runtime:
getContextUsage().contextWindow, falling back toctx.model.contextWindow(available at model-select, before any message). Because Pi supplies this runtime window even for models absent from models.dev, it does not need the OpenCode persisted-usage fallback. The detected-overflow limit still overrides both. This is Pi's equivalent of OpenCode's SDK — instant and auth-correct — so Pi does not callgetSdkContextLimit/resolveContextLimit/resolveTrustedContextLimitat all.
Same effective behavior (authoritative per-harness limit, sane-bounded, overflow override); different source because each harness exposes the resolved window through a different API. Pi resolves that window once per trigger evaluation and uses the same value for the trigger budget, boundary snapshot, and historian runner stale-snapshot check; when the trigger re-resolves a scaled boundary, the runner receives that trigger snapshot rather than the earlier probe snapshot.
The m[0]/m[1] materialization decision (mustMaterialize / mustMaterializePi)
folds m[1] into m[0] on a HARD bust — a provider-side cache-eviction event where
the prompt cache was already dead, so folding is "free". The HARD trigger set is
identical across harnesses: model/provider change, system-prompt-hash change,
and idle>TTL.
The tool-set hash trigger was previously used to detect tool changes, but was removed on both harnesses because the signal is process-global and produced false-positive folds. Pi and OpenCode now both operate without this trigger.
Both harnesses face the same hazard: needs_emergency_recovery armed by an
overflow that the user then resolves (e.g. /ctx-recomp), leaving a session at
low real pressure with a non-runnable tail. The flag must not keep force-bumping
pressure to 95% forever, but it MUST stay armed for a genuine overflow whose
tail is one in-progress arc (the window becomes runnable once the arc closes).
-
OpenCode keeps the flag armed and stops only the disruptive bump via a counter escape:
recovery_no_eligible_head_count >= RECOVERY_NO_HEAD_LIMIT (2)(transform.ts,protected-tail-boundary.ts). It never auto-clears; the flag is cleared by a successful historian publish, a model switch, or a successful/ctx-recomp(runManagedRecomp "done"). -
Pi does NOT increment that counter, so it disarms inline instead: inside
maybeFireHistorian's no-fire branch, when recovery is armed, no historian is in flight, there is no runnable compartment window, AND real pressure (usage.percentage, not the 95% bump) is< FORCE_MATERIALIZATION_PERCENTAGE→ clear the flag. The low-pressure gate is what makes this safe: a genuine overflow arc sits near the limit, so it stays armed (matching OpenCode's intent); only a stale flag (post-recomp ~20%) disarms.
Both also clear the flag on a successful /ctx-recomp (OpenCode runManagedRecomp
"done"; Pi result.published) — the recomp IS the overflow resolution.
17. Runaway hidden-agent loop: OpenCode needs an in-config step cap; Pi relies on subprocess-kill
A weak local model (e.g. llama.cpp with poor instruction-following) can get a hidden agent (historian/dreamer/sidekick) stuck in an infinite tool-call loop (issue #154). The protection differs because the spawn model differs:
-
OpenCode spawns hidden agents as a child SESSION whose run loop is an independent instance-scoped server fiber. Our prompt-timeout's
controller.abort()cancels only our client fetch — the fiber keeps re-calling the LLM, and the user's ESC only aborts the main session (noparentIDcascade). So OpenCode needs TWO guards: (a)steps/maxStepson the hidden agent config (buildHiddenAgentConfiginindex.ts) so OpenCode force- terminates the run loop after N steps, and (b)client.session.abort({id})on timeout/external-abort (in the sharedpromptWithTimeout) to interrupt the server-side loop —controller.abort()andsession.deletedo NOT stop it. -
Pi spawns hidden agents as separate
pi --printsubprocesses (PiSubagentRunner) and SIGTERMs the child process on timeout/abort. Killing the process kills the loop — there is no detached continuation. So Pi is structurally bounded bytimeoutMswithout needing an in-config step cap. A sooner per-step cap would be a nicety (terminate before burning the full timeout of local compute), only ifpi --printexposes one; the SIGTERM bound is sufficient for correctness.
Same effective guarantee (a runaway hidden agent cannot loop forever), different mechanism (OpenCode: in-config step cap + server-side abort; Pi: subprocess-kill).
Dreamer v2 lets each task carry its own model (falling back to the dreamer-level
model). The scheduler's executor sets body.model = { providerID, modelID } on the
child-session prompt for the resolved per-task model — this is the SAME mechanism
on both harnesses (the executor is shared core). The application differs at the
client boundary:
-
OpenCode passes
body.modelstraight toclient.session.prompt; the server honors it per call, so per-task models work with no extra plumbing. -
Pi has no server-side session model field on the prompt — the model is a spawn argument to
PiSubagentRunner(pi --print --model …). Pi's dreamer client facade therefore READSbody.modelback out (extractBodyModel) and threads it into the subprocess spawn, falling back to the dreamer-level model when absent. Per-taskthinking_levelis currently NOT threaded per-task on Pi (the facade uses the dreamer-levelthinking_level); per-task thinking is a deferred nicety, not a correctness gap.
Same effective behavior (each task runs on its configured model), different
application point (OpenCode: server honors body.model; Pi: facade reads it back
into the subprocess spawn args).
/ctx-dream runs the v2 per-task scheduler's runManualDream on both harnesses
(shared core): no arg = run every enabled task whose gate passes; a task arg =
force-run that one task ignoring its gate. The only divergence is the wiring: the
OpenCode command handler calls runManualDream directly with a freshly-built
executor; Pi routes through runPiDreamForProject → the registered project's
runManual, reusing the same PiSubagentRunner-backed client facade the timer
uses. The dashboard cannot trigger a run on either harness (DB-only, no live
channel) — it reflects task_schedule_state read-only.
Both harnesses freeze ids for aged, assistant-processed image messages on
cache-busting passes and replay the frozen set on every pass. OpenCode replaces
large base64 data-URL file parts; Pi replaces its native { type: "image", data, mimeType } user and tool-result parts with the same empty-text sentinel.
Both persist the frozen id before changing bytes, and clone inheritance copies
processed_image_stripped_ids.
When sidekick returns the empty-result sentinel (for example, "No relevant
memories found"), Pi sends the original prompt without a
<sidekick-augmentation> block. This is intentional: a no-op augmentation
block consumes tokens and adds noise while giving the main agent no useful
context.
OpenCode currently injects a <sidekick-augmentation> block containing the
empty sentinel text. Pi's behavior is the desired target; OpenCode should
eventually adopt the same skip-empty behavior.
Pi child agents are spawned as pi --print --mode json --no-session subprocesses.
They deliberately keep extension discovery enabled so auth/provider extensions
can register models (for example google-antigravity/*) and the AFT Pi extension
can register read tools. Recursion is blocked at the Magic Context entry point
instead: every child environment includes MAGIC_CONTEXT_PI_SUBAGENT=1, and the
full index.ts entry returns before registering tools, event handlers, DB work,
or timers. Children that need Magic Context's scoped ctx_* tools still load the
explicit lean subagent-entry.js; that entry is not guarded.
Because discovery is on, every child also receives an explicit registry gate:
known agents get --tools <agent-specific allow-list>, zero-tool agents get
--no-tools, and unknown agent ids fail closed to --no-tools with a warning.
Pi applies --tools during registry construction across both built-ins and
extension-registered tools, so non-listed extension tools (including write-capable
AFT tools) do not enter the child registry.
The open-book refresh-primers task runs a locked, read-only code-investigation
agent (dreamer-primer-investigator) that digs into the CURRENT source to ground
a primer's answer. The agent is intentionally read-only — no write/edit/
bash (source safety) and no ctx_memory/ctx_note (a ctx_memory mutation
bumps the project memory epoch and busts m[0], breaking the primers cache-neutral
contract).
The investigation TOOLSET now matches OpenCode's read-navigation intent while staying Pi-safe:
- OpenCode allow-list:
read, grep, glob, aft_outline, aft_zoom, aft_search, ctx_search— including AST-aware navigation (aft_*). - Pi strict
--toolsallow-list:read, grep, find, ls, aft_outline, aft_zoom, aft_search, ctx_search— Pi's canonical read-only built-in set (createReadOnlyToolDefinitions) plus optional AFT read tools andctx_search. If the AFT extension is not installed, Pi silently omits those unknown names from the registry.
The agent remains read-only and cache-neutral: no write/edit/bash, no
ctx_memory/ctx_note. It is in SEARCH_ONLY_SUBAGENT_TOOL_AGENTS (loads the
lean extension so ctx_search is registered) but NOT in DREAMER_ACTION_AGENTS
(which would add ctx_memory).
Origin-tag emission (the historian tagging each primer candidate with its origin compartment) IS mirrored across both harness historian runners — that part is true parity.
Shared dreamer task prompts for map-memories and verify (and related
read-only code checks) were authored against OpenCode's tool surface: they mention
names like glob, aft_search, aft_outline, and aft_zoom. On Pi those agents
run under a strict --tools allow-list of Pi's read-only built-ins plus optional
AFT read tools: read, grep, find, ls, aft_outline, aft_zoom, and
aft_search (see dreamer-memory-mapper in subagent-runner.ts). Pi still has
no glob; the closest built-in is find.
This is intentional — we do not fork the shared prompts per harness. The model
uses the registered read-only tools it actually has; if AFT is absent, Pi ignores
the listed-but-unregistered aft_* names and the task falls back to the built-ins.
That behavior is harmless for these tasks (local code read + structured manifest
output; the host applies DB writes).
Same safety pattern as §21 (refresh-primers investigator): OpenCode and Pi both
stay read-only; Pi maps glob to its find/ls built-ins and optionally gains
AFT navigation when the AFT extension is installed.
23. Shrinking model-switch overflow: OpenCode arms proactively; Pi is covered by the forward-pressure floor
When a session switches mid-conversation from a large-context model to a smaller one (e.g. 512k → 272k) while carrying more history than the new model's window, the first request on the new model would otherwise be sent oversized and rejected ("Input exceeds context window"), with recovery only arming on the next pass from the provider error (issue #188).
- OpenCode needs an explicit proactive arm. Its pressure on the first pass
after a switch reads the OLD model's low ratio (the live
contextUsageMapand persistedlastContextPercentagewere measured against the larger window), so no band trips. The transform's overflow block therefore arms recovery (flag-only) when the last-measured input exceeds the CURRENT model's trusted limit, so the existing bump-to-95% compacts before sending. - Pi needs no such arm. Its
applyForwardPressureFlooralready computesforwardTokens / (contextWindow * 0.85)every pass, and on the first pass after a switchgetContextUsage()returns.tokens≈ the forward estimate (last OLD-model usage + trailing) while.contextWindowalready reflects the NEW (smaller) model (set synchronously atsetModel). So the floor computes ~130% → trips the 95% emergency band → Pi compacts before sending. Verified against pi-mono source (getContextUsage→estimateContextTokens;setModelsetsstate.modelsynchronously).
Net behavior matches (both compact before the oversized request), via different mechanisms: OpenCode adds a targeted arm, Pi reuses its existing floor.
Narrow residual edge (not fixed, documented): if a user switches model in the
window right AFTER a compaction but BEFORE any assistant response on the new
model, Pi's getContextUsage().tokens is null (no post-compaction usage yet),
so the floor falls back to the low ratio and would not trip. The reported #188
scenario (substantial live history = many old-model assistant usages present) is
firmly outside this edge. If it ever surfaces, the clean Pi fix is
tokens === null AND model-window-shrank-since-last-pass → arm.
24. ctx_memory registration: OpenCode gates on launch config; Pi always registers + relies on the per-call guard
When memory.enabled is false, the <project-memory> block is never injected,
so an agent's ctx_memory writes can never resurface. Both harnesses drop the
ctx_memory PROMPT guidance for a memory-off project, but they register the TOOL
differently:
- OpenCode gates tool registration on
memory.enabledintool-registry.ts. This is consistent because the registry and the system prompt both read the SAME launch-resolved config for a given session, so the tool's presence always matches the prompt's guidance. - Pi must ALWAYS register
ctx_memoryin the main extension entry (index.ts,memoryToolEnabled: true). Pi is a single long-lived REPL that can/cdbetween projects: tool registration happens once at boot, but the system prompt re-resolvesmemory.enabledper project every pass. Gating registration on the boot project would mismatch after a switch (tool absent while prompt advertises it, or vice-versa). Instead Pi leans on the tool's own per-call guard (ctx-memory.ts→getProjectEmbeddingSnapshot(projectIdentity)), which refuses with "Cross-session memory is disabled for this project" when the CURRENT project has memory off. OpenCode's handler carries the identical guard, so behavior matches; only the registration strategy differs.
Note: Pi's memoryToolEnabled flag still exists and is still used by the
SUBAGENT entry (subagent-entry.ts) to keep ctx_memory off the retrieval-only
sidekick, a separate security concern, unaffected by this divergence.
Pi preserves JSONL entry ids when cloning a branch, so the clone-start hook can
copy compartments, tags, reductions, and deferred Pi marker state while filtering
them to the copied prefix. OpenCode re-mints message ids during /fork, making
entry-id-keyed migration unsafe there. OpenCode fork inheritance therefore needs
a separate future design based on a stable cross-fork identity.
Update this file whenever a deliberate Pi↔OpenCode divergence is introduced or changed. Point audit/council/Oracle briefs at it so intentional divergences are not re-reported as bugs each round.
OpenCode: raw session reads preserve full provider part JSON, including reasoning/thinking and image payload metadata. The protected-tail true-raw estimator can count those categories directly.
Pi: transcript shaping deliberately drops thinking parts and image payloads before the shared protected-tail core sees the folded OpenCode-shaped messages. Pi still preserves text and tool invocation/result I/O, so protected-tail sizing, tool-arc fencing, and historian eligibility are parity-tested for those fields.
Consequence: thinking/image token parity is a known provider-shape divergence and is deferred. Tests should assert text + tool-I/O parity and separately track Pi's expected undercount for thinking/images rather than treating it as a silent regression.
OpenCode: /ctx-wrapup uses the shared recomp progress channel, so the TUI
sidebar can render a live Wrapup row with chunk counters.
Pi: there is no persistent sidebar row. The command appends one ctx-status
custom entry for the upfront estimate and one per historian chunk, then a final
summary. On runtimes with registerEntryRenderer, one shared renderer presents
these entries in the interactive transcript. Plain custom entries are excluded
from Pi's LLM context, so status progress cannot queue a steer into a streaming
turn. The drain loop, durable wrapup_in_progress marker, sequential historian
runs, and deferred compaction semantics are shared in intent; only the progress
surface differs.
The supported and installed Pi floor is @earendil-works/pi-coding-agent 0.80.2.
That version exposes appendEntry but not registerEntryRenderer, so status entries
are persisted and session-logged but invisible in its TUI. Statuses never fall back
to sendMessage, because that would leak progress text into model context. Newer
runtimes render the same entries through the optional renderer. The intentionally
model-visible Channel-2 ceiling nudge remains on sendMessage.
OpenCode: when temporal_awareness is enabled, fresh m[0]/m[1] renders batch-read
compartment boundary timestamps from opencode.db and emit start-date / end-date.
Pi: Pi sessions are JSONL and the Pi compartment pipeline has never had a durable boundary-timestamp lookup equivalent to OpenCode's message table. Pi therefore omits these attributes while retaining temporal gap markers derived from Pi message data. Cached defer passes in both harnesses continue replaying their previously rendered bytes.
Both harnesses share resolveMuralWire (feature flag + vision gate + on-demand
deterministic PNG with text-hash change detection). The mural injects only on a
HARD m[0] materialization; defer passes replay the baked-in <memory-mural>
marker and image bytes without re-rendering. Restart-safe replay reloads the PNG
from mural_manifest when the cached m[0] text still carries the marker.
OpenCode: prepends a synthetic user head with a file part
(mime: image/png, url: data:image/png;base64,…, synthetic: true).
Pi: prepends a synthetic user entry with Pi's native image content block
({ type: "image", data: <raw base64>, mimeType: "image/png" }). Pi provider
serializers rebuild the data-URL form for the wire. Same PNG bytes; different
envelope because Pi's AgentMessage shape has no OpenCode-style file parts.
Vision gate: both call modelSupportsVision via the shared models.dev /
SDK metadata cache. Pi-native provider prefixes (openai-codex/…,
google-antigravity/…) are translated to the canonical OpenCode form before
lookup. Pi does not warm that cache (see §14); when metadata is absent the gate
fails closed (text-only baseline, no throw). A Pi-only install therefore
never injects the mural image until vision metadata is available; OpenCode warms
the cache from its SDK at startup.
Config: both honor experimental.mural.enabled (and experimental.mural.model
for the compress-cues dreamer task). No intentional per-provider image-part
blacklist today — every Pi serializer path that accepts user image content takes
raw base64 the same way.
- Last-known-good transform capture and replay for OpenCode and rust-mode sessions is pending for Pi.