@gotgenes/pi-subagents 7.2.5 → 7.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [7.2.6](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.2.5...pi-subagents-v7.2.6) (2026-05-25)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * archive Phase 11 to history, add Phase 11 to refactoring table ([2c617f2](https://github.com/gotgenes/pi-packages/commit/2c617f2c752ea935d55878ba58fce997985086b5))
14
+ * plan decompose renderWidgetLines ([#205](https://github.com/gotgenes/pi-packages/issues/205)) ([88d09cd](https://github.com/gotgenes/pi-packages/commit/88d09cdad53bc3f215c069b8d6da6a44e10b5af7))
15
+ * **retro:** add planning stage notes for issue [#205](https://github.com/gotgenes/pi-packages/issues/205) ([14afc1f](https://github.com/gotgenes/pi-packages/commit/14afc1ff82d61828fdac9373f31cb68ebfc1a2e7))
16
+ * **retro:** add retro notes for issue [#196](https://github.com/gotgenes/pi-packages/issues/196) ([cfc7d94](https://github.com/gotgenes/pi-packages/commit/cfc7d94f72b120a4550f73e2d1cf00822db759c2))
17
+ * **retro:** add TDD stage notes for issue [#205](https://github.com/gotgenes/pi-packages/issues/205) ([a676078](https://github.com/gotgenes/pi-packages/commit/a6760789898435e5b552941124de6f32be21407e))
18
+
8
19
  ## [7.2.5](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.2.4...pi-subagents-v7.2.5) (2026-05-25)
9
20
 
10
21
 
@@ -599,155 +599,18 @@ export type RunnerIO = EnvironmentIO & SessionFactoryIO;
599
599
  `RunnerIO` is kept as a type alias for the intersection.
600
600
  All existing consumers satisfy both sub-interfaces via structural typing with no call-site changes.
601
601
 
602
- ## Improvement roadmap (Phase 11)
602
+ ## Phase 11 (complete)
603
603
 
604
- Phase 11 converts closure factories to classes, eliminating the adapter closure density in `index.ts` (the package's #1 churn hotspot: 128 commits, accelerating, fan-out 25).
605
- The approach is layered: each step makes the next step trivial.
606
-
607
- > "Make the change that makes the change easy." —Kent Beck
608
-
609
- ### Findings
610
-
611
- | Metric | Value |
612
- | ------------------------- | ------------------------------------------ |
613
- | Health score | 75/100 (B) |
614
- | #1 hotspot | `index.ts` (128 commits, accelerating) |
615
- | Dead exports | 0 (down from 1; Layer 2 removed re-export) |
616
- | Production duplication | 0 |
617
- | Test duplication | 1,396 lines (69 clone groups, 22 files) |
618
- | `as any` casts in index | 1 (down from 5; Layer 1 resolved 4) |
619
- | Adapter closures in index | 40 (down from 44; Layers 1–2 resolved 4) |
620
- | Index fan-out | 25 imports |
621
-
622
- ### Root cause
623
-
624
- The 44 adapter closures in `index.ts` exist because the tool factories accept narrow interfaces that don't structurally match the real objects.
625
- The real objects can't satisfy the interfaces because:
626
-
627
- 1. ~~`SubagentRuntime.currentCtx` is typed `{ pi: unknown; ctx: unknown }` — so every consumer must `as any` cast to read fields.~~
628
- Resolved by Layer 0 (#192) + Layer 1 (#193).
629
- 2. ~~Context queries (`buildSnapshot`, `getModelInfo`, `getSessionInfo`) live as closures in index.ts instead of methods on the state holder.~~
630
- Resolved by Layer 1 (#193).
631
- 3. ~~`AgentToolManager` mixes fields from `AgentManager` and `SettingsManager` (source mismatch).~~
632
- Resolved by Layer 2 (#194).
633
- 4. ~~`AgentToolWidget` uses different method names than `SubagentRuntime` (name mismatch).~~
634
- Resolved by Layer 2 (#194).
635
-
636
- Fix these structural misalignments and the class conversions become mechanical.
637
-
638
- ### Layer 0: Define `SessionContext` narrow interface ([#192][192])
639
-
640
- `SubagentRuntime` currently types its context as `unknown` to avoid SDK coupling.
641
- But `ExtensionContext` is exported by the SDK — the `unknown` is a historical choice, not a constraint.
642
- Define a narrow `SessionContext` interface capturing the 5 fields runtime actually needs:
643
-
644
- ```typescript
645
- export interface SessionContext {
646
- readonly cwd: string;
647
- readonly model: unknown;
648
- readonly modelRegistry: ModelRegistry | undefined;
649
- getSystemPrompt(): string;
650
- readonly sessionManager: {
651
- getSessionFile(): string | undefined;
652
- getSessionId(): string;
653
- getBranch(): unknown[];
654
- };
655
- }
656
- ```
657
-
658
- - Target: `src/types.ts`
659
- - Smell: Category C (platform type threading)
660
- - Outcome: typed foundation for Layers 1–4; no `as any` needed by consumers of `SubagentRuntime`
661
-
662
- ### Layer 1: `SubagentRuntime` stores typed context, owns its queries ([#193][193]) ✓ done
663
-
664
- Change `currentCtx` from `{ pi: unknown; ctx: unknown }` to `SessionContext | undefined`.
665
- The single `as SessionContext` cast moves into `handleSessionStart` — the boundary where the SDK hands us the value.
666
- Add typed methods: `buildSnapshot(inheritContext)`, `getModelInfo()`, `getSessionInfo()`.
667
-
668
- - Target: `src/runtime.ts`, `src/handlers/lifecycle.ts`, `src/service/service-adapter.ts`, `src/index.ts`
669
- - Smell: Category C (closure queries on mutable field → methods on state owner)
670
- - Outcome: 3 closure queries in index.ts → 0; `SubagentRuntime` is self-sufficient for tool deps
671
- - Enables: Layer 3 (tools accept `SubagentRuntime` directly)
672
-
673
- ### Layer 2: Align interfaces so real objects satisfy tool deps structurally ([#194][194]) ✓ done
674
-
675
- Three alignment changes:
676
-
677
- 1. **Move `getMaxConcurrent` off `AgentToolManager`** — it reads from `SettingsManager`, not `AgentManager`.
678
- The tool already receives `settings`; read it from there.
679
- 2. **Rename widget methods** — align `SubagentRuntime` method names with `AgentToolWidget` (either rename `updateWidget()` → `update()` on runtime, or rename the interface to match).
680
- 3. **Remove dead re-export** — `getToolCallName` in `ui/message-formatters.ts` (fallow finding).
681
-
682
- After this step, `AgentManager` structurally satisfies `AgentToolManager` and `SubagentRuntime` structurally satisfies `AgentToolWidget`.
683
-
684
- - Target: `src/tools/agent-tool.ts` (interface), `src/runtime.ts` (method names), `src/ui/message-formatters.ts`
685
- - Smell: Category C (source mismatch, name mismatch) + Category A (dead export)
686
- - Outcome: structural typing connects real objects to tool interfaces without adapters; 0 dead exports (fallow clean)
687
- - Enables: Layer 3 (class constructors accept real objects directly)
688
-
689
- ### Layer 3: Convert closure factories to classes ([#195][195], [#196][196]) ✓ done
690
-
691
- All closure factories converted to classes. ✓ Tool factories in [#195][195]; runner and menu in [#196][196]:
692
-
693
- | Factory | Class | Constructor params |
694
- | -------------------------------- | ----------------------- | -------------------------------------------------------------------- |
695
- | `createAgentTool({...})` | `AgentTool` ✓ | `manager`, `runtime`, `settings`, `registry` |
696
- | `createGetResultTool(...)` | `GetResultTool` ✓ | `manager`, `notifications`, `registry` |
697
- | `createSteerTool(...)` | `SteerTool` ✓ | `manager`, `events` |
698
- | `createAgentRunner(runnerIO)` | `ConcreteAgentRunner` ✓ | `io: RunnerIO` |
699
- | `createAgentsMenuHandler({...})` | `AgentsMenuHandler` ✓ | `manager`, `registry`, `agentActivity`, `settings`, `fileOps`, paths |
700
-
701
- Each class satisfies the existing interface via structural typing.
702
- The `defineTool()` wrapper moves into a `toToolDefinition()` method on each tool class.
703
- `getModelLabel` internalized into `AgentsMenuHandler` (was a 7-line closure in `index.ts`).
704
-
705
- - Target: `src/tools/*.ts`, `src/lifecycle/agent-runner.ts`, `src/ui/agent-menu.ts`
706
- - Smell: Category C (closure factories masquerading as classes)
707
- - Outcome: deps are constructor params (inspectable, testable); no captured closures
708
- - Enables: Layer 4 (index.ts simplification)
709
-
710
- ### Layer 4: Simplify index.ts (included in [#196][196]) ✓ done
711
-
712
- With all factories converted to classes and `AgentManager` satisfying `AgentMenuManager` structurally:
713
-
714
- ```typescript
715
- const agentsMenu = new AgentsMenuHandler(
716
- manager, registry, runtime.agentActivity,
717
- settings, new FsAgentFileOps(),
718
- join(getAgentDir(), "agents"),
719
- join(process.cwd(), ".pi", "agents"),
720
- );
721
- ```
722
-
723
- Eliminated: 4 adapter closures (3 manager method adapters + `getModelLabel`), 4 unused imports.
724
- Remaining ~15 closures are structural (event registrations, SDK factory callbacks).
725
-
726
- - Target: `src/index.ts`
727
- - Smell: Category B (god file) + Category C (adapter closure density)
728
- - Outcome: adapter closure count reduced; `AgentManager` passed directly without wrappers; churn hotspot stabilized
729
-
730
- ### Step dependencies
731
-
732
- ```mermaid
733
- flowchart LR
734
- L0["Layer 0: SessionContext interface"] --> L1["Layer 1: Runtime owns queries"]
735
- L1 --> L3["Layer 3: Classes replace factories"]
736
- L2["Layer 2: Align interfaces"] --> L3
737
- L3 --> L4["Layer 4: Simplify index.ts"]
738
- ```
739
-
740
- Layers 0 and 2 are independent of each other.
741
- Layer 1 depends on Layer 0.
742
- Layer 3 depends on both Layer 1 and Layer 2.
743
- Layer 4 depends on Layer 3.
604
+ Phase 11 converted all closure factories to classes, eliminating adapter closure density in `index.ts`.
605
+ Four layers: SessionContext typing runtime query methods interface alignment → class conversions → index.ts simplification.
606
+ See [phase-11-closure-to-class.md](history/phase-11-closure-to-class.md) for details.
744
607
 
745
608
  ## Improvement roadmap (Phase 12)
746
609
 
747
610
  Phase 12 addresses the remaining fallow refactoring targets and test duplication.
748
611
  These are independent of Phase 11 and can proceed in parallel if desired.
749
612
 
750
- ### Step 1: Decompose `renderWidgetLines` (cognitive 44)
613
+ ### Step 1: Decompose `renderWidgetLines` (cognitive 44) — [#205]
751
614
 
752
615
  `renderWidgetLines` in `ui/widget-renderer.ts` handles agent-status formatting, tree connectors, overflow, and empty states.
753
616
  Extract per-status renderers and a tree-connector utility.
@@ -755,7 +618,7 @@ Extract per-status renderers and a tree-connector utility.
755
618
  - Target: `src/ui/widget-renderer.ts`
756
619
  - Outcome: cognitive complexity < 10
757
620
 
758
- ### Step 2: Decompose `showAgentDetail` (cognitive 33)
621
+ ### Step 2: Decompose `showAgentDetail` (cognitive 33) — [#206]
759
622
 
760
623
  `showAgentDetail` in `ui/agent-config-editor.ts` handles display, edit, eject, and delete flows.
761
624
  Extract sub-functions per menu action.
@@ -763,7 +626,7 @@ Extract sub-functions per menu action.
763
626
  - Target: `src/ui/agent-config-editor.ts`
764
627
  - Outcome: cognitive complexity < 10
765
628
 
766
- ### Step 3: Decompose `update` in `agent-widget.ts` (cognitive 31)
629
+ ### Step 3: Decompose `update` in `agent-widget.ts` (cognitive 31) — [#207]
767
630
 
768
631
  `update` mixes timer lifecycle, agent list assembly, render delegation, and visibility state.
769
632
  Extract `assembleWidgetState` (pure) and timer management.
@@ -771,7 +634,7 @@ Extract `assembleWidgetState` (pure) and timer management.
771
634
  - Target: `src/ui/agent-widget.ts`
772
635
  - Outcome: cognitive complexity < 10
773
636
 
774
- ### Step 4: Extract shared test fixtures
637
+ ### Step 4: Extract shared test fixtures — [#208]
775
638
 
776
639
  The 3 heaviest clone families:
777
640
 
@@ -786,7 +649,7 @@ Extract shared factories into `test/fixtures/` modules.
786
649
 
787
650
  ## Refactoring history
788
651
 
789
- Phases 1–5 and 7–10 are complete.
652
+ Phases 1–5 and 7–11 are complete.
790
653
  Phase 6 (UI extraction to a separate package) is deferred.
791
654
  Detailed records are preserved in per-phase history files:
792
655
 
@@ -802,6 +665,7 @@ Detailed records are preserved in per-phase history files:
802
665
  | 8 | Testability, display extraction, menu decomposition | Complete | [phase-8-testability.md](history/phase-8-testability.md) |
803
666
  | 9 | Observation consolidation, ctx elimination | Complete | [phase-9-observation-ctx.md](history/phase-9-observation-ctx.md) |
804
667
  | 10 | Domain organization, bag decomposition, complexity | Complete | [phase-10-structural-decomposition.md](history/phase-10-structural-decomposition.md) |
668
+ | 11 | Closure factories to classes | Complete | [phase-11-closure-to-class.md](history/phase-11-closure-to-class.md) |
805
669
 
806
670
  ### Structural refactoring issues
807
671
 
@@ -816,6 +680,8 @@ Detailed records are preserved in per-phase history files:
816
680
  | Testability | #131, #132, #133, #134, #135, #136 | Shared fixtures, session-config IO, runner SDK boundary, as-any reduction, display extraction, menu decomposition |
817
681
  | Observation/ctx | #144, #145, #146, #147, #148 | Observation consolidation, execute decomposition, UI context, text wrapping injection, widget rendering split |
818
682
  | Phase 10 | #164, #165, #166, #167, #168, #169, #170, #171, #172 | Domain directories, ResolvedSpawnConfig, ParentSessionInfo, RunnerIO split, ToolFilterConfig, RunContext, buildContentLines, renderResult, content-items |
683
+ | Phase 11 | #192, #193, #194, #195, #196 | SessionContext, runtime queries, interface alignment, tool classes, runner/menu classes, index.ts simplification |
684
+ | Phase 12 | #205, #206, #207, #208 | renderWidgetLines, showAgentDetail, widget update, shared test fixtures |
819
685
 
820
686
  The remaining open issue is #22 (parent-session resolution), a cross-extension track that does not gate the structural work.
821
687
 
@@ -839,8 +705,7 @@ The upstream test suite is run periodically as a regression canary for the agent
839
705
  [167]: https://github.com/gotgenes/pi-packages/issues/167
840
706
  [168]: https://github.com/gotgenes/pi-packages/issues/168
841
707
  [169]: https://github.com/gotgenes/pi-packages/issues/169
842
- [192]: https://github.com/gotgenes/pi-packages/issues/192
843
- [193]: https://github.com/gotgenes/pi-packages/issues/193
844
- [194]: https://github.com/gotgenes/pi-packages/issues/194
845
- [195]: https://github.com/gotgenes/pi-packages/issues/195
846
- [196]: https://github.com/gotgenes/pi-packages/issues/196
708
+ [#205]: https://github.com/gotgenes/pi-packages/issues/205
709
+ [#206]: https://github.com/gotgenes/pi-packages/issues/206
710
+ [#207]: https://github.com/gotgenes/pi-packages/issues/207
711
+ [#208]: https://github.com/gotgenes/pi-packages/issues/208
@@ -0,0 +1,100 @@
1
+ # Phase 11: Closure factories to classes
2
+
3
+ Target: convert all closure factories to classes, eliminating adapter closure density in `index.ts` (the package's #1 churn hotspot) and making the composition root a pure construction site.
4
+
5
+ ## Root cause
6
+
7
+ The 44 adapter closures in `index.ts` existed because tool factories accepted narrow interfaces that didn't structurally match the real objects.
8
+ Four structural misalignments prevented direct wiring:
9
+
10
+ 1. `SubagentRuntime.currentCtx` was typed `{ pi: unknown; ctx: unknown }` — every consumer had to `as any` cast.
11
+ 2. Context queries (`buildSnapshot`, `getModelInfo`, `getSessionInfo`) lived as closures in `index.ts` instead of methods on the state holder.
12
+ 3. `AgentToolManager` mixed fields from `AgentManager` and `SettingsManager` (source mismatch).
13
+ 4. `AgentToolWidget` used different method names than `SubagentRuntime` (name mismatch).
14
+
15
+ ## Layer 0: Define SessionContext narrow interface (#192)
16
+
17
+ Defined a `SessionContext` interface capturing the 5 fields `SubagentRuntime` actually needs.
18
+ Replaced the `{ pi: unknown; ctx: unknown }` typing with `SessionContext | undefined`.
19
+
20
+ Impact: typed foundation for all subsequent layers; no `as any` needed by consumers.
21
+
22
+ ## Layer 1: SubagentRuntime stores typed context, owns its queries (#193)
23
+
24
+ Changed `currentCtx` to `SessionContext | undefined`.
25
+ The single `as SessionContext` cast moved into `handleSessionStart` — the SDK boundary.
26
+ Added typed methods: `buildSnapshot(inheritContext)`, `getModelInfo()`, `getSessionInfo()`.
27
+
28
+ Impact: 3 closure queries in `index.ts` → 0; 4 `as any` casts eliminated; `SubagentRuntime` is self-sufficient for tool deps.
29
+
30
+ ## Layer 2: Align interfaces for structural typing (#194)
31
+
32
+ Three alignment changes:
33
+
34
+ 1. Moved `getMaxConcurrent` off `AgentToolManager` — it reads from `SettingsManager`, not `AgentManager`.
35
+ 2. Renamed widget methods — aligned `SubagentRuntime` method names with `AgentToolWidget`.
36
+ 3. Removed dead re-export `getToolCallName` in `ui/message-formatters.ts`.
37
+
38
+ Impact: `AgentManager` structurally satisfies `AgentToolManager`; `SubagentRuntime` structurally satisfies `AgentToolWidget`; 0 dead exports.
39
+
40
+ ## Layer 3: Convert closure factories to classes (#195, #196)
41
+
42
+ All five closure factories converted to classes:
43
+
44
+ | Factory | Class | Issue |
45
+ | -------------------------------- | --------------------- | ----- |
46
+ | `createAgentTool({...})` | `AgentTool` | #195 |
47
+ | `createGetResultTool(...)` | `GetResultTool` | #195 |
48
+ | `createSteerTool(...)` | `SteerTool` | #195 |
49
+ | `createAgentRunner(runnerIO)` | `ConcreteAgentRunner` | #196 |
50
+ | `createAgentsMenuHandler({...})` | `AgentsMenuHandler` | #196 |
51
+
52
+ Each class satisfies the existing interface via structural typing.
53
+ Tool classes expose `toToolDefinition()` for Pi tool registration.
54
+ `getModelLabel` internalized into `AgentsMenuHandler` (was a 7-line closure in `index.ts`).
55
+
56
+ Impact: deps are constructor params (inspectable, testable); no captured closures.
57
+
58
+ ## Layer 4: Simplify index.ts (#196)
59
+
60
+ With all factories converted and `AgentManager` satisfying `AgentMenuManager` structurally:
61
+
62
+ - 4 adapter closures eliminated (3 manager method adapters + `getModelLabel`).
63
+ - 4 unused imports removed.
64
+ - Remaining ~15 closures are structural (event registrations, SDK factory callbacks).
65
+
66
+ Impact: adapter closure count halved; `AgentManager` passed directly without wrappers; churn hotspot stabilized.
67
+
68
+ ## Step dependencies
69
+
70
+ ```mermaid
71
+ flowchart LR
72
+ L0["Layer 0: SessionContext #192"] --> L1["Layer 1: Runtime queries #193"]
73
+ L1 --> L3["Layer 3: Classes #195 #196"]
74
+ L2["Layer 2: Align interfaces #194"] --> L3
75
+ L3 --> L4["Layer 4: Simplify index.ts #196"]
76
+ ```
77
+
78
+ Layers 0 and 2 were independent.
79
+ Layer 1 depended on Layer 0.
80
+ Layer 3 depended on both Layer 1 and Layer 2.
81
+ Layer 4 depended on Layer 3.
82
+
83
+ ## Impact
84
+
85
+ | Metric | Before | After |
86
+ | ------------------------------ | ---------- | --------------------- |
87
+ | Health score | 75/100 (B) | 76/100 (B) |
88
+ | Adapter closures in `index.ts` | 44 | ~15 (structural only) |
89
+ | `as any` casts in `index.ts` | 5 | 1 (SDK boundary) |
90
+ | Dead exports | 1 | 0 |
91
+ | Closure factories | 5 | 0 |
92
+ | `index.ts` fan-out | 25 imports | 21 imports |
93
+
94
+ ## Related issues
95
+
96
+ - #192 — Define SessionContext narrow interface
97
+ - #193 — Runtime owns context queries
98
+ - #194 — Align tool interfaces for structural typing
99
+ - #195 — Convert tool factories to classes
100
+ - #196 — Convert AgentRunner and AgentsMenuHandler to classes, simplify index.ts
@@ -0,0 +1,140 @@
1
+ ---
2
+ issue: 205
3
+ issue_title: "Decompose renderWidgetLines (cognitive 44)"
4
+ ---
5
+
6
+ # Decompose `renderWidgetLines`
7
+
8
+ ## Problem Statement
9
+
10
+ `renderWidgetLines` in `ui/widget-renderer.ts` has cognitive complexity 44 (CRITICAL per fallow health).
11
+ It handles agent categorization, per-status line building with tree connectors, non-overflow assembly with last-connector fixup, and overflow-budget assembly — all in a single 106-line function.
12
+ This is the highest-complexity function remaining in the codebase (Phase 12, Step 1).
13
+
14
+ ## Goals
15
+
16
+ - Extract distinct concerns into separate pure functions, each with cognitive complexity < 10.
17
+ - Preserve all existing behavior — no visual or behavioral changes.
18
+ - Keep all extracted functions in `widget-renderer.ts` (they are private helpers, not a separate module).
19
+
20
+ ## Non-Goals
21
+
22
+ - Decomposing `showAgentDetail` (#206), `update` (#207), or shared test fixtures (#208) — those are sibling Phase 12 steps.
23
+ - Changing the widget's visual output or tree-connector style.
24
+ - Modifying `renderFinishedLine` or `renderRunningLines` — those are already single-concern functions.
25
+
26
+ ## Background
27
+
28
+ `widget-renderer.ts` was extracted from `AgentWidget` in #148.
29
+ The per-agent renderers (`renderFinishedLine`, `renderRunningLines`) are already clean single-concern functions.
30
+ The remaining complexity lives entirely in `renderWidgetLines`, which orchestrates categorization, section building, and assembly.
31
+
32
+ The function has five interwoven concerns:
33
+
34
+ 1. **Agent categorization** — filtering into running/queued/finished buckets.
35
+ 2. **Section building** — rendering each bucket into pre-formatted line arrays with `├─` tree connectors.
36
+ 3. **Heading construction** — choosing icon/color based on active vs. finished-only.
37
+ 4. **Non-overflow assembly** — concatenating sections when under `MAX_WIDGET_LINES`, then fixing the last connector (`├─` → `└─`).
38
+ 5. **Overflow assembly** — budget-based prioritized assembly (running > queued > finished) with an overflow indicator line.
39
+
40
+ ## Design Overview
41
+
42
+ Extract four helper functions from the body of `renderWidgetLines`:
43
+
44
+ ### `categorizeAgents`
45
+
46
+ Accepts the agents array and `shouldShowFinished` callback.
47
+ Returns `{ running, queued, finished }` arrays.
48
+ Pure filter — no rendering.
49
+
50
+ ### `buildSections`
51
+
52
+ Accepts categorized agents, `activityMap`, `registry`, `spinnerFrame`, `theme`, and a `truncate` function.
53
+ Returns `{ finishedLines, runningLines, queuedLine }` — the pre-formatted line arrays with `├─` connectors.
54
+ Calls `renderFinishedLine` and `renderRunningLines` internally.
55
+
56
+ ### `assembleWithinBudget`
57
+
58
+ Accepts `finishedLines`, `runningLines`, `queuedLine`, and the heading line.
59
+ Handles the non-overflow path: concatenates sections and fixes the last tree connector (`├─` → `└─`, `│` → space).
60
+ Returns the assembled `string[]`.
61
+
62
+ ### `assembleOverflow`
63
+
64
+ Accepts `finishedLines`, `runningLines`, `queuedLine`, heading line, `maxBody` budget, `truncate`, and `theme`.
65
+ Handles the overflow path: budget-based prioritized assembly with an overflow indicator.
66
+ Returns the assembled `string[]`.
67
+
68
+ After extraction, `renderWidgetLines` becomes a thin orchestrator:
69
+
70
+ ```typescript
71
+ export function renderWidgetLines(params: { ... }): string[] {
72
+ const { running, queued, finished } = categorizeAgents(agents, shouldShowFinished);
73
+ if (running.length === 0 && queued.length === 0 && finished.length === 0) return [];
74
+
75
+ const truncate = (line: string) => truncateToWidth(line, terminalWidth);
76
+ const heading = buildHeadingLine(running, queued, truncate, theme);
77
+ const sections = buildSections(running, queued, finished, activityMap, registry, spinnerFrame, theme, truncate);
78
+ const totalBody = sections.finishedLines.length + sections.runningLines.length * 2 + (sections.queuedLine ? 1 : 0);
79
+
80
+ if (totalBody <= MAX_WIDGET_LINES - 1) {
81
+ return assembleWithinBudget(heading, sections);
82
+ }
83
+ return assembleOverflow(heading, sections, MAX_WIDGET_LINES - 1, truncate, theme);
84
+ }
85
+ ```
86
+
87
+ Each helper is a pure function with a single concern and low branching.
88
+
89
+ ## Module-Level Changes
90
+
91
+ ### Changed: `src/ui/widget-renderer.ts`
92
+
93
+ - Add `categorizeAgents` (private) — extracts the three `agents.filter(...)` calls.
94
+ - Add `buildSections` (private) — extracts the three section-building loops.
95
+ - Add `assembleWithinBudget` (private) — extracts the non-overflow assembly + connector fixup.
96
+ - Add `assembleOverflow` (private) — extracts the overflow-budget assembly + indicator line.
97
+ - Simplify `renderWidgetLines` to a thin orchestrator calling the four helpers.
98
+
99
+ No exports are added, removed, or renamed.
100
+ No other files change.
101
+
102
+ ## Test Impact Analysis
103
+
104
+ 1. No new unit tests for the extracted helpers are needed — they are private functions tested through `renderWidgetLines`.
105
+ The existing `renderWidgetLines` tests in `test/widget-renderer.test.ts` (8 tests) cover all branches: single running, mixed agents, filtered finished, overflow priority, empty arrays, dim heading.
106
+ 2. No existing tests become redundant — they all exercise `renderWidgetLines` end-to-end, which is the correct level for assembly logic.
107
+ 3. All existing tests must stay as-is — the extraction is purely internal.
108
+
109
+ ## TDD Order
110
+
111
+ 1. **Red → Green:** Extract `categorizeAgents` and call it from `renderWidgetLines`.
112
+ All existing tests pass (no behavior change).
113
+ Commit: `refactor: extract categorizeAgents from renderWidgetLines`
114
+
115
+ 2. **Red → Green:** Extract `buildSections` and call it from `renderWidgetLines`.
116
+ All existing tests pass.
117
+ Commit: `refactor: extract buildSections from renderWidgetLines`
118
+
119
+ 3. **Red → Green:** Extract `assembleWithinBudget` and call it from `renderWidgetLines`.
120
+ All existing tests pass.
121
+ Commit: `refactor: extract assembleWithinBudget from renderWidgetLines`
122
+
123
+ 4. **Red → Green:** Extract `assembleOverflow` and call it from `renderWidgetLines`.
124
+ All existing tests pass.
125
+ Commit: `refactor: extract assembleOverflow from renderWidgetLines`
126
+
127
+ 5. **Verify:** Run `pnpm run check` and `pnpm vitest run test/widget-renderer.test.ts` to confirm no regressions.
128
+ Commit: n/a (verification only).
129
+
130
+ ## Risks and Mitigations
131
+
132
+ | Risk | Mitigation |
133
+ | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
134
+ | Tree-connector fixup logic is fragile (string replacement on Unicode chars) | Keep the fixup in `assembleWithinBudget` as-is — same logic, just relocated. Existing tests verify exact connector output. |
135
+ | Extracted helpers have many parameters | Accept a `sections` object from `buildSections` to bundle `finishedLines`, `runningLines`, `queuedLine` — avoids long parameter lists. |
136
+ | Intermediate commits break tests | Each extraction step is self-contained — the function body moves into a helper and the call site replaces it in the same commit. |
137
+
138
+ ## Open Questions
139
+
140
+ None — the decomposition is purely mechanical extraction of existing code into named helpers.
@@ -40,3 +40,34 @@ All type-check, lint, and dead-code gates pass clean.
40
40
  - `AgentManager` structurally satisfies `AgentMenuManager` with no adapter closures — confirmed by `pnpm run check` passing immediately.
41
41
  - The `agent-menu.test.ts` refactor replaced `Partial<AgentMenuDeps>` overrides with a `makeHandler(opts)` helper that returns both the handler and collaborator stubs, which is cleaner for assertion.
42
42
  - `rumdl` emitted 3 warnings in `pnpm run lint` — these are pre-existing and unrelated to this change (lint passes for markdown linting, the warnings are from biome/eslint steps that auto-fixed nothing).
43
+
44
+ ## Stage: Final Retrospective (2026-05-25T15:04:47Z)
45
+
46
+ ### Session summary
47
+
48
+ Completed all three stages (planning, TDD, shipping) in one sitting.
49
+ Issue #196 shipped as `pi-subagents-v7.2.5`.
50
+ All closure factories in pi-subagents are now classes; Phase 11 (Layers 3 + 4) is complete.
51
+
52
+ ### Observations
53
+
54
+ #### What went well
55
+
56
+ - The three-session lifecycle (plan → TDD → ship) completed cleanly in a single sitting with no user corrections.
57
+ - Structural typing confirmation during planning paid off — `AgentManager` satisfied `AgentMenuManager` without adapter closures, and `pnpm run check` passed immediately after the wiring change.
58
+ - The `makeHandler(opts)` test helper pattern (returning handler + collaborator stubs) was cleaner than the `Partial<AgentMenuDeps>` spread approach it replaced.
59
+
60
+ #### What caused friction (agent side)
61
+
62
+ - `wrong-abstraction` — The plan separated factory removal (step 3) from call-site update (step 5), even though the testing skill already has a rule: "When a TDD step changes an interface that has a single call site, the step must include updating that call site."
63
+ The planner treated this as a testing concern and didn't apply it during plan authoring.
64
+ Impact: steps 3 and 5 had to be merged at implementation time, producing a commit message explaining the deviation.
65
+ Added a cross-reference to `plan-issue.md`.
66
+
67
+ #### What caused friction (user side)
68
+
69
+ Nothing notable — the user's issue was well-specified and the three `/` commands ran without intervention.
70
+
71
+ ### Changes made
72
+
73
+ 1. Added single-call-site rule to `.pi/prompts/plan-issue.md` TDD Order section: when a step removes a factory/export with one call site, include the call-site update in the same step.
@@ -0,0 +1,36 @@
1
+ ---
2
+ issue: 205
3
+ issue_title: "Decompose renderWidgetLines (cognitive 44)"
4
+ ---
5
+
6
+ # Retro: #205 — Decompose renderWidgetLines
7
+
8
+ ## Stage: Planning (2026-05-25T15:27:45Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned the decomposition of `renderWidgetLines` (cognitive complexity 44) into four private helper functions: `categorizeAgents`, `buildSections`, `assembleWithinBudget`, and `assembleOverflow`.
13
+ Also updated `architecture.md` Phase 12 steps with issue links (#205–#208) and added a Phase 12 row to the structural refactoring issues table.
14
+
15
+ ### Observations
16
+
17
+ - The function's complexity comes from five interwoven concerns (categorization, section building, heading, non-overflow assembly with connector fixup, overflow-budget assembly) — but the extraction is mechanical since all logic is already pure and stateless.
18
+ - No new tests are needed — the existing 8 tests in `widget-renderer.test.ts` cover all branches end-to-end and remain the correct test level for assembly logic.
19
+ - The tree-connector fixup (swapping `├─` → `└─` via string replacement on Unicode chars) is the most fragile part; it stays as-is inside `assembleWithinBudget` rather than being further decomposed.
20
+ - A `sections` return object from `buildSections` bundles `finishedLines`, `runningLines`, and `queuedLine` to avoid long parameter lists on the assembly helpers.
21
+
22
+ ## Stage: Implementation — TDD (2026-05-25T15:35:10Z)
23
+
24
+ ### Session summary
25
+
26
+ Completed all four TDD steps: extracted `categorizeAgents`, `buildSections`, `assembleWithinBudget`, and `assembleOverflow` from `renderWidgetLines`.
27
+ All 23 `widget-renderer.test.ts` tests pass throughout; no new tests were added.
28
+ Full suite (856 tests, 54 files) is green; type check and lint are clean.
29
+
30
+ ### Observations
31
+
32
+ - **Stray backtick during `assembleOverflow` extraction:** The Edit tool introduced a double-backtick (`\`\``) at the end of the nested template literal on the overflow indicator line — the inner template literal's closing backtick concatenated with the outer template's closing backtick, creating a parse error.
33
+ Required Python-based line-level surgery to fix since the Edit tool cannot reliably match nested template literals through JSON escaping.
34
+ - The `renderWidgetLines` `else` block removal also required Python because the Edit tool's `oldText` matching is unreliable when the target contains nested template literals with backticks.
35
+ - Aside from the template-literal matching friction, all extractions were purely mechanical; no logic changes were needed.
36
+ - The final `renderWidgetLines` is a clean 12-line orchestrator; each helper is well under complexity 10.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "7.2.5",
3
+ "version": "7.2.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/service.ts"
@@ -127,6 +127,135 @@ export function renderRunningLines(
127
127
  /** Maximum number of rendered lines before overflow collapse kicks in. */
128
128
  const MAX_WIDGET_LINES = 12;
129
129
 
130
+ interface AgentCategories {
131
+ running: WidgetAgent[];
132
+ queued: WidgetAgent[];
133
+ finished: WidgetAgent[];
134
+ }
135
+
136
+ /** Partition agents into rendering buckets. */
137
+ function categorizeAgents(
138
+ agents: readonly WidgetAgent[],
139
+ shouldShowFinished: (agentId: string, status: string) => boolean,
140
+ ): AgentCategories {
141
+ return {
142
+ running: agents.filter(a => a.status === "running"),
143
+ queued: agents.filter(a => a.status === "queued"),
144
+ finished: agents.filter(
145
+ a => a.status !== "running" && a.status !== "queued" && a.completedAt != null
146
+ && shouldShowFinished(a.id, a.status),
147
+ ),
148
+ };
149
+ }
150
+
151
+ interface WidgetSections {
152
+ finishedLines: string[];
153
+ runningLines: [string, string][];
154
+ queuedLine: string | undefined;
155
+ }
156
+
157
+ /** Render each agent bucket into pre-formatted lines with ├─ tree connectors. */
158
+ function buildSections(
159
+ categories: AgentCategories,
160
+ activityMap: ReadonlyMap<string, WidgetActivity>,
161
+ registry: AgentConfigLookup,
162
+ spinnerFrame: number,
163
+ theme: Theme,
164
+ truncate: (line: string) => string,
165
+ ): WidgetSections {
166
+ const finishedLines: string[] = [];
167
+ for (const a of categories.finished) {
168
+ finishedLines.push(truncate(theme.fg("dim", "\u251C\u2500") + " " + renderFinishedLine(a, activityMap.get(a.id), registry, theme)));
169
+ }
170
+
171
+ const runningLines: [string, string][] = [];
172
+ for (const a of categories.running) {
173
+ const [header, act] = renderRunningLines(a, activityMap.get(a.id), registry, spinnerFrame, theme);
174
+ runningLines.push([
175
+ truncate(theme.fg("dim", "\u251C\u2500") + ` ${header}`),
176
+ truncate(theme.fg("dim", "\u2502 ") + act),
177
+ ]);
178
+ }
179
+
180
+ const queuedLine = categories.queued.length > 0
181
+ ? truncate(theme.fg("dim", "\u251C\u2500") + ` ${theme.fg("muted", "\u25E6")} ${theme.fg("dim", `${categories.queued.length} queued`)}`)
182
+ : undefined;
183
+
184
+ return { finishedLines, runningLines, queuedLine };
185
+ }
186
+
187
+ /**
188
+ * Assemble widget lines when total body fits within MAX_WIDGET_LINES.
189
+ * Fixes the last tree connector: ├─ → └─, and │ → space for the running-agent activity line.
190
+ */
191
+ function assembleWithinBudget(heading: string, sections: WidgetSections): string[] {
192
+ const { finishedLines, runningLines, queuedLine } = sections;
193
+ const lines: string[] = [heading, ...finishedLines];
194
+ for (const pair of runningLines) lines.push(...pair);
195
+ if (queuedLine) lines.push(queuedLine);
196
+
197
+ // Fix last connector: swap \u251C\u2500 \u2192 \u2514\u2500.
198
+ if (lines.length > 1) {
199
+ const last = lines.length - 1;
200
+ lines[last] = lines[last].replace("\u251C\u2500", "\u2514\u2500");
201
+ if (runningLines.length > 0 && !queuedLine) {
202
+ if (last >= 2) {
203
+ lines[last - 1] = lines[last - 1].replace("\u251C\u2500", "\u2514\u2500");
204
+ lines[last] = lines[last].replace("\u2502 ", " ");
205
+ }
206
+ }
207
+ }
208
+ return lines;
209
+ }
210
+
211
+ /**
212
+ * Assemble widget lines when total body exceeds MAX_WIDGET_LINES.
213
+ * Prioritizes running > queued > finished and appends an overflow indicator.
214
+ */
215
+ function assembleOverflow(
216
+ heading: string,
217
+ sections: WidgetSections,
218
+ maxBody: number,
219
+ truncate: (line: string) => string,
220
+ theme: Theme,
221
+ ): string[] {
222
+ const { finishedLines, runningLines, queuedLine } = sections;
223
+ const lines: string[] = [heading];
224
+ let budget = maxBody - 1;
225
+ let hiddenRunning = 0;
226
+ let hiddenFinished = 0;
227
+
228
+ for (const pair of runningLines) {
229
+ if (budget >= 2) {
230
+ lines.push(...pair);
231
+ budget -= 2;
232
+ } else {
233
+ hiddenRunning++;
234
+ }
235
+ }
236
+
237
+ if (queuedLine && budget >= 1) {
238
+ lines.push(queuedLine);
239
+ budget--;
240
+ }
241
+
242
+ for (const fl of finishedLines) {
243
+ if (budget >= 1) {
244
+ lines.push(fl);
245
+ budget--;
246
+ } else {
247
+ hiddenFinished++;
248
+ }
249
+ }
250
+
251
+ const overflowParts: string[] = [];
252
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
253
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
254
+ const overflowText = overflowParts.join(", ");
255
+ lines.push(truncate(theme.fg("dim", "\u2514\u2500") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`));
256
+ return lines;
257
+ }
258
+
130
259
  /** Pure rendering of the widget body. Returns lines to display. */
131
260
  export function renderWidgetLines(params: {
132
261
  agents: readonly WidgetAgent[];
@@ -139,12 +268,7 @@ export function renderWidgetLines(params: {
139
268
  }): string[] {
140
269
  const { agents, activityMap, registry, spinnerFrame, terminalWidth, theme, shouldShowFinished } = params;
141
270
 
142
- const running = agents.filter(a => a.status === "running");
143
- const queued = agents.filter(a => a.status === "queued");
144
- const finished = agents.filter(a =>
145
- a.status !== "running" && a.status !== "queued" && a.completedAt
146
- && shouldShowFinished(a.id, a.status),
147
- );
271
+ const { running, queued, finished } = categorizeAgents(agents, shouldShowFinished);
148
272
 
149
273
  const hasActive = running.length > 0 || queued.length > 0;
150
274
  const hasFinished = finished.length > 0;
@@ -155,82 +279,22 @@ export function renderWidgetLines(params: {
155
279
  const headingColor = hasActive ? "accent" : "dim";
156
280
  const headingIcon = hasActive ? "\u25CF" : "\u25CB";
157
281
 
158
- // Build sections separately for overflow-aware assembly.
159
- const finishedLines: string[] = [];
160
- for (const a of finished) {
161
- finishedLines.push(truncate(theme.fg("dim", "\u251C\u2500") + " " + renderFinishedLine(a, activityMap.get(a.id), registry, theme)));
162
- }
163
-
164
- const runningLines: [string, string][] = [];
165
- for (const a of running) {
166
- const [header, act] = renderRunningLines(a, activityMap.get(a.id), registry, spinnerFrame, theme);
167
- runningLines.push([
168
- truncate(theme.fg("dim", "\u251C\u2500") + ` ${header}`),
169
- truncate(theme.fg("dim", "\u2502 ") + act),
170
- ]);
171
- }
172
-
173
- const queuedLine = queued.length > 0
174
- ? truncate(theme.fg("dim", "\u251C\u2500") + ` ${theme.fg("muted", "\u25E6")} ${theme.fg("dim", `${queued.length} queued`)}`)
175
- : undefined;
282
+ const { finishedLines, runningLines, queuedLine } = buildSections(
283
+ { running, queued, finished },
284
+ activityMap,
285
+ registry,
286
+ spinnerFrame,
287
+ theme,
288
+ truncate,
289
+ );
176
290
 
177
291
  // Assemble with overflow cap (heading takes 1 line).
178
292
  const maxBody = MAX_WIDGET_LINES - 1;
179
293
  const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
180
-
181
- const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
294
+ const heading = truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"));
182
295
 
183
296
  if (totalBody <= maxBody) {
184
- lines.push(...finishedLines);
185
- for (const pair of runningLines) lines.push(...pair);
186
- if (queuedLine) lines.push(queuedLine);
187
-
188
- // Fix last connector: swap \u251C\u2500 \u2192 \u2514\u2500 and \u2502 \u2192 space for activity lines.
189
- if (lines.length > 1) {
190
- const last = lines.length - 1;
191
- lines[last] = lines[last].replace("\u251C\u2500", "\u2514\u2500");
192
- if (runningLines.length > 0 && !queuedLine) {
193
- if (last >= 2) {
194
- lines[last - 1] = lines[last - 1].replace("\u251C\u2500", "\u2514\u2500");
195
- lines[last] = lines[last].replace("\u2502 ", " ");
196
- }
197
- }
198
- }
199
- } else {
200
- // Overflow — prioritize: running > queued > finished.
201
- let budget = maxBody - 1;
202
- let hiddenRunning = 0;
203
- let hiddenFinished = 0;
204
-
205
- for (const pair of runningLines) {
206
- if (budget >= 2) {
207
- lines.push(...pair);
208
- budget -= 2;
209
- } else {
210
- hiddenRunning++;
211
- }
212
- }
213
-
214
- if (queuedLine && budget >= 1) {
215
- lines.push(queuedLine);
216
- budget--;
217
- }
218
-
219
- for (const fl of finishedLines) {
220
- if (budget >= 1) {
221
- lines.push(fl);
222
- budget--;
223
- } else {
224
- hiddenFinished++;
225
- }
226
- }
227
-
228
- const overflowParts: string[] = [];
229
- if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
230
- if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
231
- const overflowText = overflowParts.join(", ");
232
- lines.push(truncate(theme.fg("dim", "\u2514\u2500") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`));
297
+ return assembleWithinBudget(heading, { finishedLines, runningLines, queuedLine });
233
298
  }
234
-
235
- return lines;
299
+ return assembleOverflow(heading, { finishedLines, runningLines, queuedLine }, maxBody, truncate, theme);
236
300
  }