Plan: split the pi-modes wiring closure along its seams (#63)
On this page
Closes: #63. Branch: refactor/split-modes-wiring. Status: Done (2026-09-04).
The problem
gadhsPiModes(pi) in packages/pi-modes/index.ts registers 6 tools,
3 commands, 7 hooks and 2 renderers inside one closure, and they share
12 let`s, 5 `Map`s and the `stats counters. The pure logic already lives
in modules (plan-mode.ts, memory.ts, judge.ts, …); what remains is
wiring, and it is that long because every handler can see every other
handler’s state. This week’s #61 and #64 fixes both landed in handlers here
that do not fit on a screen: exit_plan_mode.execute is 192 lines,
session_start 127, switchMode 75.
The shape
Follow the state, not the line count. Reading which handler touches which variable gives five clusters with thin edges:
| Module | Owns | Needs |
|---|---|---|
|
mode id, tool names, applied-model ledger, plan entry, stats, last
context, last-seen model; |
|
|
memory store + scope keys; load/save/archive; |
|
|
the five maps ( |
|
|
last plan review; |
|
|
judge registry, denials, disposer; |
|
index.ts keeps what composes them: the bootstrap call, the bridge object,
the two entry renderers, the four session hooks, the ask and count tools
(already thin), /mode and the cycle shortcut. Target: under 400 lines,
no closure-level let.
Controller interface (sketch)
interface ModeController {
modeId(): string;
currentMode(): ModeDescriptor | undefined;
planEntry(): PlanEntry | undefined;
lastAppliedModelId(): string | undefined;
readonly stats: SessionStats; // judge and bridge mutate counters
refreshStatus(ctx?: ExtensionContext): void;
applyMode(ctx, mode, opts?: { silent?: boolean }): Promise<void>;
switchMode(ctx, id: string): Promise<void>;
setRunningAgents(names: string[]): void; // stats.runningAgents + refresh
noteTurnModel(ctx): void; // the #32 drift watchdog
onSessionStart(ctx): Promise<void>; // tool names, persisted mode, resumed model, announce
}
Each wiring module is registerX(pi, deps) → { onSessionStart?, onShutdown?, … }.
Decisions
| Decision | Why |
|---|---|
One controller object, not five state bags |
The mode id, applied-model ledger, plan entry and stats are read by four
of the five clusters. One owner; everyone else reads through it
( |
Session hooks register in |
|
The bridge is assembled in |
It is the one object sibling packages see (ADR-004); its members delegate to the modules. One place to read the published surface. |
Long handlers are cut in their new module |
|
Shared harness = shared parts, two `load()`s |
|
Fidelity gap resolved toward pi |
|
No compatibility shims |
Pre-1.0. Moved functions are imported from their new home by every caller in the same commit; nothing is re-exported from where it used to be. The only stable surfaces are the bridge (ADR-004) and the tool/command names, and neither changes. |
Semver: MINOR |
Six new modules of exports (five wiring modules and |
How each step is worked
-
On
refactor/split-modes-wiring. One commit per step. -
Move code; change no string, event name, entry shape or order.
-
node tools/task.mjs validategreen (tsc strict on tests too, biome, all suites). The 490 tests are not edited except for harness imports in step 1; a test that needs editing to pass is a finding, not a step. -
Commit via
.gadhs-commit-msgwith aReview-Context:trailer naming the new module andpackages/pi-modes/index.ts; answer the cold review. -
Update this page’s step Status in the same commit.
Steps
Step 0 - plan to Antora
-
Copy this document to
docs/modules/ROOT/pages/plans/split-modes-wiring.adoc. -
Add it under Plans → Active in
docs/modules/ROOT/nav.adoc. -
node tools/task.mjs plan-lintclean.
Status: Done (2026-09-04)
Step 1 - test/harness.ts
-
New:
fakePi()(lifecycle version: model-movingsetModel, message capture),paneFake({ keys, renders, rows, options }),fakeTheme(),gitRepo(),ToolDef,ENTER/DOWN/ESCAPE. -
Both suites import the parts; each keeps its
load(). -
Exit: all tests pass; neither suite defines a fake
pi, a pane fake orgitRepo; any outcome change under the faithfulsetModelis recorded in the commit.
Status: Done (2026-09-04) — no outcome changed under the faithful setModel
Step 2 - leaf moves
-
debugLog→debug-log.ts(same env gate, same append, same shape). -
splitModelRef→session-model.tsbesideModelRef;config.test.tsand index.ts import it from there. -
Exit: tests pass; both definitions gone from index.ts.
Status: Done (2026-09-04)
Step 3 - memory-wiring.ts
-
registerMemory(pi, { agentDir, notify }) → { onSessionStart(ctx), systemPromptBlock() }. -
Moves:
memory,scopeKeys, load/save/archive,/remember,/memory,remembertool, the staging prune. -
before_agent_startcallssystemPromptBlock()where it read the store directly; the corrupt-store warning goes throughnotify. -
Exit: memory pins in
wiring-lifecycleandtool-surfacespass; no memory identifier left in index.ts.
Status: Done (2026-09-04) — found #66 (corrupt-store warning dropped on a fresh session); filed, not fixed here
Step 4 - subagent-wiring.ts
-
registerSubagentTracking(pi, { debugLog, modeId, setRunningAgents, sessionDirs }) → { onSessionStart(ctx) }. -
Moves: the five maps,
loadAgentContracts,spawnTarget,deliverYield, both tool-execution hooks, theyieldtool. -
tool_execution_endsplits intoonResultFetchedandonSpawnEnded. -
Exit: subagent, contract, yield and background round-trip pins pass.
Status: Done (2026-09-04) — AGENT_ENTRY moved to trace.ts (both sides need it); the duplicated contract check became one enforceContract
Step 5 - mode-controller.ts
-
createModeController(pi, config, configPath, { debugLog }) → ModeController(interface above). -
Moves: the seven state variables,
currentMode,refreshStatus,applyMode,switchMode(+switchKeepingModel),announceModeChange, the drift watchdog, the session-start restore sequence (decomposed). -
/mode, the shortcut and the bridge’smodeIdcall the controller. -
The commit message accounts for every former
currentModeIdread site (28 today): those that move into the controller with their owning function, and those in index.ts converted tomodes.modeId(), listed by line so the reviewer checks them off. -
Exit: mode-switch, sticky-model, plan-entry and resume pins pass; the seven controller variables are gone from index.ts (the judge’s and the plan tools' `let`s leave in steps 6 and 7).
Status: Done (2026-09-04)
Step 6 - plan-tools.ts
-
registerPlanTools(pi, modes, { config, debugLog, agentDir }) → { recordReview(record), onSessionStart() }. -
Moves:
lastPlanReview,matchingPlanReview,activeModelRef,restoreHumanModel, both tools;exit_plan_mode.executedecomposed as decided. -
The bridge’s
planReviewedcallsrecordReview. -
Exit: plan-mode and review-pane pins in
wiring-lifecycleandwiringpass;plan-review-pausein pi-workflow (crosses the bridge) passes.
Status: Done (2026-09-04) — askApproval split once more into the pane factory; the overlay options are one constant
Step 7 - judge-wiring.ts
-
registerJudge(pi, modes, { config, debugLog, cwd }) → { onSessionStart(ctx), onShutdown(), trace(record) }. -
Moves:
permissions:readyregistration (callback →adjudicate
recordVerdict),warmJudge,stampedLog,withFullCommand,trace. -
The bridge’s
guardDeny/guardTracecalltrace. -
Exit: authorizer and judge-warm pins pass; index.ts under 400 lines.
Status: Done (2026-09-04) — index.ts is 324 lines with no closure-level let (the ask tool’s two locals remain); TRACE_ENTRY joined AGENT_ENTRY in trace.ts
Step 8 - docs, probe, release, close
-
architecture.adoc: a pi-modes module map (composition root, the five modules, the lifecycle order). CHANGELOG entry. -
Probe:
node tools/consumer-sim.mjs(packs the working tree, runs pi headless in a scratch agent dir). Its checks read the debug log forsession_startandapply_modeand the seeded authorizer chain - the composed lifecycle observed through a real pi, not the harness. -
Release 0.12.0 (pi-modes 0.9.0): tag, pipeline green,
consumer-sim --registry 0.12.0. -
Close #63 with merge SHA, index.ts lines before/after, test count, the consumer-sim output. Plan Status Done; nav → Archive.
Status: Done (2026-09-04) — merge 7d591d9; consumer-sim OK from the working tree (session_start, restore_model, ask, git_guard.* with mode: "auto" in one debug log); released 0.12.0 (tag v0.12.0, pipeline 2821034784 success, consumer-sim --registry 0.12.0 OK); #63 closed with the merge SHA, 1,673 → 324 lines, 495 + 277 tests, the consumer-sim evidence and #66 as the one finding filed
Risks
-
A captured copy of mutable state - caught by the mode-switch pins (a switch after registration must reach every handler) and step 5’s read-site list.
-
Lifecycle order drift - prevented by keeping hooks in index.ts; caught by the resume pins, which need "persisted mode before resumed model before warm".
-
A behaviour change hiding in a decomposition - every step’s exit is "assertions unchanged, all pass".
-
Review-context size - index.ts is 74 KB (cap 80 KB/file); with the two suites (19 KB, 48 KB) the plan review stays under the 200 KB total. Later steps name only the cut module and index.ts.
Non-goals
-
No behaviour, wording, event or config change. Anything found gets an issue, not a fix on this branch.
-
No change to the pure modules.
-
No new tests beyond the harness move.