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-controller.ts

mode id, tool names, applied-model ledger, plan entry, stats, last context, last-seen model; applyMode, switchMode, refreshStatus, announceModeChange, the drift watchdog

pi, config, configPath, debugLog

memory-wiring.ts

memory store + scope keys; load/save/archive; /remember, /memory, remember tool; staging prune; system-prompt block

pi, agent dir, a notify

subagent-wiring.ts

the five maps (runningSpawns, backgroundAgents, resultFetches, contracts, yieldStates); contract loading; tool_execution_start/end; the yield tool

pi, debugLog, controller (modeId, setRunningAgents), session dirs

plan-tools.ts

last plan review; enter_plan_mode, exit_plan_mode; restoreHumanModel; recordReview

pi, config, debugLog, agent dir, controller

judge-wiring.ts

judge registry, denials, disposer; permissions:ready registration; warmJudge; trace; stampedLog

pi, config, debugLog, controller

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 (modes.modeId()), never a copy taken at registration - step 5’s review looks for that specifically.

Session hooks register in index.ts only

session_start runs nine things in a fixed order today. Modules export onSessionStart(ctx) and index.ts calls them in that order, visibly. If modules registered their own hooks, order would become import order. Tools, commands and tool-execution hooks are order-independent and stay with their module.

The bridge is assembled in index.ts

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

exit_plan_mode.executereadPlanForReview, askApproval, keepPlanning, approvePlan. session_startrestorePersistedMode, restoreResumedModel, announceStartup (controller) + warmJudge (judge). switchModeswitchKeepingModel for the sticky branch. Same notifications, entries, debug events and messages, in the same order.

Shared harness = shared parts, two `load()`s

wiring.test.ts loads both extensions with a scripted review registry; wiring-lifecycle.test.ts loads pi-modes alone with message and status capture. Their duplicated fakes (fakePi, the pane fake, the theme, gitRepo) move to test/harness.ts; each suite keeps its own load() so what each proves stays legible.

Fidelity gap resolved toward pi

wiring.test.ts’s `pi.setModel is () ⇒ {}; it returns undefined, which applyMode reads as "no credentials", so that suite never records an applied model. The shared fake returns true and moves ctx.model, like pi and like the lifecycle suite. Any assertion that changes outcome under it is a test-fidelity finding, examined before step 1 lands - never a re-added no-op.

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 debug-log.ts); every shipped .ts is importable surface - the rule 0.10.0 and 0.11.0 were cut by. @gadhs/pi-modes 0.9.0, @gadhs/pi 0.12.0.

How each step is worked

  1. On refactor/split-modes-wiring. One commit per step.

  2. Move code; change no string, event name, entry shape or order.

  3. node tools/task.mjs validate green (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.

  4. Commit via .gadhs-commit-msg with a Review-Context: trailer naming the new module and packages/pi-modes/index.ts; answer the cold review.

  5. 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-lint clean.

Status: Done (2026-09-04)

Step 1 - test/harness.ts

  • New: fakePi() (lifecycle version: model-moving setModel, 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 or gitRepo; any outcome change under the faithful setModel is recorded in the commit.

Status: Done (2026-09-04) — no outcome changed under the faithful setModel

Step 2 - leaf moves

  • debugLogdebug-log.ts (same env gate, same append, same shape).

  • splitModelRefsession-model.ts beside ModelRef; config.test.ts and 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, remember tool, the staging prune.

  • before_agent_start calls systemPromptBlock() where it read the store directly; the corrupt-store warning goes through notify.

  • Exit: memory pins in wiring-lifecycle and tool-surfaces pass; 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, the yield tool.

  • tool_execution_end splits into onResultFetched and onSpawnEnded.

  • 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’s modeId call the controller.

  • The commit message accounts for every former currentModeId read site (28 today): those that move into the controller with their owning function, and those in index.ts converted to modes.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.execute decomposed as decided.

  • The bridge’s planReviewed calls recordReview.

  • Exit: plan-mode and review-pane pins in wiring-lifecycle and wiring pass; plan-review-pause in 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:ready registration (callback → adjudicate
    recordVerdict), warmJudge, stampedLog, withFullCommand, trace.

  • The bridge’s guardDeny/guardTrace call trace.

  • 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 for session_start and apply_mode and 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.

Edit this page · latest