@gotgenes/pi-subagents 6.18.8 → 6.19.1

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,32 @@ 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
+ ## [6.19.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.19.0...pi-subagents-v6.19.1) (2026-05-24)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * plan replace any casts with SDK types ([#188](https://github.com/gotgenes/pi-packages/issues/188)) ([96207da](https://github.com/gotgenes/pi-packages/commit/96207dacf0035db11605d55a61132cf43f7c3b40))
14
+ * **retro:** add planning stage notes for issue [#188](https://github.com/gotgenes/pi-packages/issues/188) ([6e38b12](https://github.com/gotgenes/pi-packages/commit/6e38b128a5bbaad3ca81b31adbf390482081a41e))
15
+ * **retro:** add retro notes for issue [#172](https://github.com/gotgenes/pi-packages/issues/172) ([270c00a](https://github.com/gotgenes/pi-packages/commit/270c00a5f84bf454352443a6c57a6076803090c6))
16
+ * **retro:** add TDD stage notes for issue [#188](https://github.com/gotgenes/pi-packages/issues/188) ([8a5f51a](https://github.com/gotgenes/pi-packages/commit/8a5f51a2fd02a143e85f176417b31af4a11b34f4))
17
+
18
+ ## [6.19.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.18.8...pi-subagents-v6.19.0) (2026-05-24)
19
+
20
+
21
+ ### Features
22
+
23
+ * extract shared content-item parsing into session/content-items ([5ed0d1c](https://github.com/gotgenes/pi-packages/commit/5ed0d1c6291d9044e1ab85c637b1e5f0051789f3))
24
+ * extract shared content-item parsing into session/content-items ([413fda0](https://github.com/gotgenes/pi-packages/commit/413fda0cc8bb496a79285d0ec97c97d9b0b6cc6d))
25
+
26
+
27
+ ### Documentation
28
+
29
+ * mark step 9 (extract turn-formatting) done in architecture ([04a0b55](https://github.com/gotgenes/pi-packages/commit/04a0b554b8848adc0f43b8939fa866086282a6af))
30
+ * plan extract shared turn-formatting logic ([#172](https://github.com/gotgenes/pi-packages/issues/172)) ([818affe](https://github.com/gotgenes/pi-packages/commit/818affe22457cfbc1cabc5d4e7477e9391b3ed46))
31
+ * **retro:** add planning stage notes for issue [#172](https://github.com/gotgenes/pi-packages/issues/172) ([809b4cf](https://github.com/gotgenes/pi-packages/commit/809b4cf4dd9f59f1c57eb0776835af88e0cef8f4))
32
+ * **retro:** add retro notes for issue [#171](https://github.com/gotgenes/pi-packages/issues/171) ([2b50b37](https://github.com/gotgenes/pi-packages/commit/2b50b374f9d99305144bc6227eb48e3a9d68efb3))
33
+
8
34
  ## [6.18.8](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.18.7...pi-subagents-v6.18.8) (2026-05-24)
9
35
 
10
36
 
@@ -244,6 +244,7 @@ src/
244
244
  ├── session/ session assembly and preparation
245
245
  │ ├── session-config.ts pure assembler (main entry)
246
246
  │ ├── prompts.ts system prompt building
247
+ │ ├── content-items.ts shared message content parsing (tool-call names, assistant content)
247
248
  │ ├── context.ts parent conversation extraction
248
249
  │ ├── memory.ts persistent MEMORY.md per agent
249
250
  │ ├── skill-loader.ts skill preloading
@@ -484,8 +485,9 @@ Files with highest commit frequency × complexity (accelerating trend):
484
485
 
485
486
  ### Production duplication
486
487
 
487
- One clone group (18 lines) shared between `agent-runner.ts:456-468` and `conversation-viewer.ts:261-278`.
488
- Both format turn-event content for display identical iteration over message content items, extracting tool names and text.
488
+ The 18-line clone group between `agent-runner.ts` and `message-formatters.ts` was resolved in #172.
489
+ `ToolCallContent`, `getToolCallName`, and `extractAssistantContent` now live in `session/content-items.ts`.
490
+ No known production duplication remains.
489
491
 
490
492
  ### Proposed bag decompositions
491
493
 
@@ -648,9 +650,11 @@ Extracted per-status result formatting from `renderResult` in `agent-tool.ts` in
648
650
  `renderResult` reduced from ~80 lines (cognitive complexity 43) to a 10-line guard + `renderAgentResult` dispatcher.
649
651
  The inline `stats()` closure became the exported `renderStats` helper, shared by all status renderers.
650
652
 
651
- ### Step 9: Extract shared turn-formatting logic ([#172][172])
653
+ ### Step 9: Extract shared turn-formatting logic ([#172][172]) ✓ Done
652
654
 
653
- The 18-line production clone between `agent-runner.ts` and `conversation-viewer.ts` extracts into a shared function in the session domain.
655
+ Extracted `ToolCallContent`, `getToolCallName`, and `extractAssistantContent` into `session/content-items.ts`.
656
+ Both `lifecycle/agent-runner.ts` (`getAgentConversation`) and `ui/message-formatters.ts` (`formatAssistantMessage`) now import from the shared module.
657
+ Eliminates the 18-line production-duplication finding.
654
658
 
655
659
  ### Step dependencies
656
660
 
@@ -0,0 +1,206 @@
1
+ ---
2
+ issue: 172
3
+ issue_title: "refactor(pi-subagents): extract shared turn-formatting logic"
4
+ ---
5
+
6
+ # Extract shared turn-formatting logic
7
+
8
+ ## Problem Statement
9
+
10
+ Fallow identified 18 lines of duplicated production code between `lifecycle/agent-runner.ts` and `ui/message-formatters.ts` (originally `conversation-viewer.ts` before #170 extracted formatters).
11
+ Both sites iterate over assistant message content items, extracting tool names and text parts for display.
12
+ The `ToolCallContent` interface and `getToolCallName` helper are duplicated verbatim in both files.
13
+
14
+ ## Goals
15
+
16
+ - Extract the duplicated `ToolCallContent` type and `getToolCallName` function into a single shared module.
17
+ - Extract the content-iteration pattern (collecting text parts and tool names from assistant message content) into a reusable `extractAssistantContent` function.
18
+ - Both consumers (`getAgentConversation` in `agent-runner.ts` and `formatAssistantMessage` in `message-formatters.ts`) import from the shared module.
19
+ - Eliminate the fallow production-duplication finding.
20
+
21
+ ## Non-Goals
22
+
23
+ - Moving `extractText` from `session/context.ts` to the new module — same concern (content parsing) but out of scope; note as a follow-up.
24
+ - Refactoring `getAgentConversation` itself (it has no tests and its full-loop structure mixes user/assistant/toolResult formatting) — separate concern.
25
+ - Changing `buildParentContext` in `session/context.ts`, which has a similar but simpler iteration pattern (no tool calls, different data source).
26
+
27
+ ## Background
28
+
29
+ ### Current duplication sites
30
+
31
+ `lifecycle/agent-runner.ts` (private scope):
32
+
33
+ ```typescript
34
+ interface ToolCallContent {
35
+ type: "toolCall";
36
+ name?: string;
37
+ toolName?: string;
38
+ }
39
+
40
+ function getToolCallName(c: { type: string }): string {
41
+ if (c.type !== "toolCall") return "unknown";
42
+ const tc = c as ToolCallContent;
43
+ return tc.name ?? tc.toolName ?? "unknown";
44
+ }
45
+ ```
46
+
47
+ `ui/message-formatters.ts` (exported):
48
+
49
+ ```typescript
50
+ interface ToolCallContent { /* identical */ }
51
+ export function getToolCallName(c: { type: string }): string { /* identical */ }
52
+ ```
53
+
54
+ The content-iteration pattern appears in:
55
+
56
+ 1. `getAgentConversation` (agent-runner.ts lines 480–486) — plain-text output for LLM consumption
57
+ 2. `formatAssistantMessage` (message-formatters.ts lines 144–148) — themed display lines for TUI
58
+
59
+ Both collect `textParts: string[]` and tool names via the same `for (const c of content)` loop with identical guards.
60
+
61
+ ### Structural analysis
62
+
63
+ Per the code-design skill's "structural reasons before extracting duplication" check: the two consumers differ only in *presentation* (plain text vs. themed TUI lines).
64
+ The *data extraction* — identifying text items and tool-call items, extracting tool names — is the same logical operation.
65
+ This is incidental duplication suitable for extraction.
66
+
67
+ ### Dependencies
68
+
69
+ - Issue #164 (domain directory reorganization): ✓ closed — files are already in `lifecycle/` and `ui/`.
70
+ - Issue #170 (buildContentLines complexity reduction): ✓ closed — formatting logic is already in `message-formatters.ts`.
71
+ - Issue #170 is related: the extraction may simplify `formatAssistantMessage` slightly (the issue body predicted this).
72
+
73
+ ### Placement
74
+
75
+ The architecture doc (Phase 10, Step 9) says: "extracts into a shared function in the session domain."
76
+ The `session/` directory already hosts `context.ts` which exports the related `extractText` function.
77
+ A new `session/content-items.ts` module keeps the concern focused without overloading `context.ts`.
78
+
79
+ ## Design Overview
80
+
81
+ ### New module: `session/content-items.ts`
82
+
83
+ ```typescript
84
+ /** Tool-call content item — SDK exposes this at runtime but doesn't export the type. */
85
+ export interface ToolCallContent {
86
+ type: "toolCall";
87
+ name?: string;
88
+ toolName?: string;
89
+ }
90
+
91
+ /** Extracts the display name from a tool-call content item. */
92
+ export function getToolCallName(c: { type: string }): string {
93
+ if (c.type !== "toolCall") return "unknown";
94
+ const tc = c as ToolCallContent;
95
+ return tc.name ?? tc.toolName ?? "unknown";
96
+ }
97
+
98
+ /** Extracted text parts and tool names from assistant message content. */
99
+ export interface AssistantContentParts {
100
+ textParts: string[];
101
+ toolNames: string[];
102
+ }
103
+
104
+ /**
105
+ * Extract text and tool-call names from assistant message content items.
106
+ * Pure data extraction — consumers apply their own formatting.
107
+ */
108
+ export function extractAssistantContent(
109
+ content: { type: string; [key: string]: unknown }[],
110
+ ): AssistantContentParts {
111
+ const textParts: string[] = [];
112
+ const toolNames: string[] = [];
113
+ for (const c of content) {
114
+ if (c.type === "text" && c.text) textParts.push(c.text as string);
115
+ else if (c.type === "toolCall") toolNames.push(getToolCallName(c));
116
+ }
117
+ return { textParts, toolNames };
118
+ }
119
+ ```
120
+
121
+ ### Consumer call sites (pseudocode)
122
+
123
+ `getAgentConversation` (agent-runner.ts):
124
+
125
+ ```typescript
126
+ const { textParts, toolNames } = extractAssistantContent(msg.content);
127
+ if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
128
+ if (toolNames.length > 0) parts.push(`[Tool Calls]:\n${toolNames.map(n => ` Tool: ${n}`).join("\n")}`);
129
+ ```
130
+
131
+ `formatAssistantMessage` (message-formatters.ts):
132
+
133
+ ```typescript
134
+ const { textParts, toolNames } = extractAssistantContent(content);
135
+ const lines: string[] = [theme.bold("[Assistant]")];
136
+ if (textParts.length > 0) lines.push(...wrapText(textParts.join("\n").trim(), width));
137
+ for (const name of toolNames) lines.push(truncateToWidth(theme.fg("muted", ` [Tool: ${name}]`), width));
138
+ ```
139
+
140
+ Both consumers call the same extraction, then apply their own presentation.
141
+ This follows Tell-Don't-Ask: the shared function returns structured data, not formatted strings.
142
+
143
+ ## Module-Level Changes
144
+
145
+ ### New files
146
+
147
+ | File | Description |
148
+ | ------------------------------------ | ---------------------------------------------------------------------------------------- |
149
+ | `src/session/content-items.ts` | `ToolCallContent`, `getToolCallName`, `AssistantContentParts`, `extractAssistantContent` |
150
+ | `test/session/content-items.test.ts` | Unit tests for `getToolCallName` and `extractAssistantContent` |
151
+
152
+ ### Changed files
153
+
154
+ | File | Change |
155
+ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
156
+ | `src/lifecycle/agent-runner.ts` | Remove `ToolCallContent` and `getToolCallName`; import `extractAssistantContent` from `session/content-items`; refactor `getAgentConversation` assistant branch |
157
+ | `src/ui/message-formatters.ts` | Remove `ToolCallContent` and `getToolCallName`; import `extractAssistantContent` and `getToolCallName` from `session/content-items`; refactor `formatAssistantMessage` |
158
+ | `docs/architecture/architecture.md` | Update Step 9 status to "✓ Done"; update `session/` module listing to include `content-items.ts`; update production duplication section |
159
+
160
+ ### Architecture doc updates
161
+
162
+ The architecture doc references this issue in three places:
163
+
164
+ 1. Line 487 — "Production duplication" subsection: update to note the duplication is resolved.
165
+ 2. Line 247 — `session/` module listing: add `content-items.ts`.
166
+ 3. Line 653 — Phase 10, Step 9: mark "✓ Done".
167
+
168
+ ## Test Impact Analysis
169
+
170
+ 1. **New unit tests enabled**: `getToolCallName` and `extractAssistantContent` get direct unit tests for the first time.
171
+ Previously, `getToolCallName` was only exercised indirectly through `formatAssistantMessage` tests.
172
+ 2. **Existing tests that stay as-is**: `message-formatters.test.ts` tests for `formatAssistantMessage` continue to exercise the full pipeline (extraction + formatting).
173
+ They become integration-level tests relative to the new extraction layer — they should not be simplified or removed.
174
+ 3. **No existing tests become redundant**: `getAgentConversation` has no tests today, so nothing to deduplicate.
175
+
176
+ ## TDD Order
177
+
178
+ 1. `test:` Write tests for `getToolCallName` in `test/session/content-items.test.ts` — covers `toolCall` with `name`, `toolName`, both (prefers `name`), neither (returns "unknown"), and non-toolCall type.
179
+ Commit: `test: add getToolCallName unit tests`
180
+ 2. `test:` Write tests for `extractAssistantContent` in the same file — covers empty array, text-only items, toolCall-only items, mixed items, and items with other types (e.g., `image`).
181
+ Commit: `test: add extractAssistantContent unit tests`
182
+ 3. `feat:` Create `src/session/content-items.ts` with `ToolCallContent`, `getToolCallName`, `AssistantContentParts`, and `extractAssistantContent`.
183
+ All tests go green.
184
+ Commit: `feat: extract shared content-item parsing into session/content-items`
185
+ 4. `refactor:` Update `message-formatters.ts` — remove local `ToolCallContent` and `getToolCallName`; import `getToolCallName` and `extractAssistantContent` from `session/content-items`; refactor `formatAssistantMessage` to use `extractAssistantContent`.
186
+ Existing `message-formatters.test.ts` stays green.
187
+ Commit: `refactor: use shared content-items in message-formatters`
188
+ 5. `refactor:` Update `agent-runner.ts` — remove local `ToolCallContent` and `getToolCallName`; import `extractAssistantContent` from `session/content-items`; refactor `getAgentConversation` assistant branch.
189
+ Existing `agent-runner.test.ts` stays green.
190
+ Commit: `refactor: use shared content-items in agent-runner`
191
+ 6. `docs:` Update `docs/architecture/architecture.md` — mark Step 9 done, update module listing, update duplication section.
192
+ Commit: `docs: mark step 9 (extract turn-formatting) done in architecture`
193
+
194
+ ## Risks and Mitigations
195
+
196
+ | Risk | Mitigation |
197
+ | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
198
+ | `getToolCallName` signature change breaking callers | Signature is identical — no change needed. Import path changes are the only difference. |
199
+ | `extractAssistantContent` return shape mismatch with consumers | Consumer pseudocode verified above; both sites restructure trivially around `{ textParts, toolNames }`. |
200
+ | `message-formatters.ts` re-exports `getToolCallName` — removing the local definition could break external imports | Grep confirms no external consumers import `getToolCallName` from `message-formatters.ts`. Drop the re-export. |
201
+ | `isBashExecution` in `message-formatters.ts` also uses a local type — could be confused with this extraction | `isBashExecution` and `BashExecutionMessage` are unrelated to the tool-call duplication; leave them in place. |
202
+
203
+ ## Open Questions
204
+
205
+ - Should `extractText` (currently in `session/context.ts`) move to `session/content-items.ts` for consistency?
206
+ Deferred — it works fine where it is, and moving it means updating all importers for no functional benefit.
@@ -0,0 +1,162 @@
1
+ ---
2
+ issue: 188
3
+ issue_title: "refactor(pi-subagents): replace any casts with SDK types in extractText and SubscribableSession"
4
+ ---
5
+
6
+ # Replace `any` casts with SDK types
7
+
8
+ ## Problem Statement
9
+
10
+ Two places in pi-subagents use `any` where proper SDK types are available and already imported in adjacent files.
11
+ `extractText` in `session/context.ts` uses `(c: any)` in a filter/map chain, requiring a top-level `eslint-disable` for `no-unsafe-member-access` and `no-unsafe-return`.
12
+ `record-observer.ts` and `ui-observer.ts` each define an identical local `SubscribableSession` interface with `(event: any) => void`, creating both a type hole and duplicated boilerplate.
13
+
14
+ ## Goals
15
+
16
+ - Replace `any` casts in `extractText` with a `TextContent` type predicate.
17
+ - Remove the `eslint-disable` comment from `session/context.ts`.
18
+ - Replace `any` in the `SubscribableSession` interface with `AgentSessionEvent`.
19
+ - Deduplicate the `SubscribableSession` interface into a single shared definition.
20
+
21
+ ## Non-Goals
22
+
23
+ - Changing the `extractText` parameter type from `unknown[]` — callers in `message-formatters.ts` pass `unknown[]`, and widening the refactoring surface is out of scope.
24
+ - Replacing the `SubscribableSession` interface with the full `AgentSession` class — ISP requires a narrow interface (the observers only need `subscribe`).
25
+ - Addressing the `eslint-disable` in `record-observer.ts` and `ui-observer.ts` for `no-unsafe-member-access` / `no-unsafe-assignment` — those are caused by the `event` property access pattern inside the callback body, not by the parameter type.
26
+ Once the callback parameter is typed as `AgentSessionEvent`, the unsafe-access rules should be satisfied and those `eslint-disable` comments can be removed too.
27
+
28
+ ## Background
29
+
30
+ ### Existing conventions
31
+
32
+ `content-items.ts` already imports `TextContent` from `@earendil-works/pi-ai` and uses `(c as TextContent).text` after a `c.type === "text"` guard.
33
+ `agent-runner.ts` already imports `AgentSessionEvent` from `@earendil-works/pi-coding-agent` and uses it as the parameter type in `session.subscribe((event: AgentSessionEvent) => { ... })`.
34
+ Both SDK types are proven to work in this package.
35
+
36
+ ### `extractText` callers
37
+
38
+ `extractText(content: unknown[])` is called from:
39
+
40
+ - `session/context.ts` — `buildParentContext` passes `msg.content` from session entries.
41
+ - `lifecycle/agent-runner.ts` — `getLastAssistantText` and `getAgentConversation` pass `msg.content`.
42
+ - `ui/message-formatters.ts` — `formatUserMessage` and `formatToolResult` pass `unknown[]` content.
43
+
44
+ The parameter type stays `unknown[]` to avoid rippling through callers.
45
+ The type predicate narrows inside the function body.
46
+
47
+ ### `SubscribableSession` consumers
48
+
49
+ Both `subscribeRecordObserver` and `subscribeUIObserver` accept a `SubscribableSession` parameter.
50
+ Tests use `createMockSession()` from `test/helpers/mock-session.ts`, which returns a `MockSession` with `subscribe: Mock<(fn: (event: unknown) => void) => () => void>`.
51
+
52
+ Changing `SubscribableSession.subscribe` to accept `(event: AgentSessionEvent) => void` is structurally sound: the mock's `subscribe` accepting `(fn: (event: unknown) => void)` is a supertype — a function that accepts any event can accept an `AgentSessionEvent`.
53
+ The TypeScript compiler allows this because of function parameter contravariance.
54
+ Tests construct inline event objects that match `AgentSessionEvent` member shapes, so no test changes are needed.
55
+
56
+ ### Shared location for `SubscribableSession`
57
+
58
+ The interface is used by two domains (observation, UI).
59
+ A new shared types location is needed.
60
+ The existing `types.ts` at the package root contains cross-cutting types (`SubagentType`, `ThinkingLevel`, `ShellExec`).
61
+ `SubscribableSession` fits there — it's a narrow cross-domain interface for session event subscription.
62
+
63
+ ## Design Overview
64
+
65
+ ### `extractText` type predicate
66
+
67
+ Replace the `any` casts with a user-defined type guard:
68
+
69
+ ```typescript
70
+ import type { TextContent } from "@earendil-works/pi-ai";
71
+
72
+ function isTextContent(c: unknown): c is TextContent {
73
+ return typeof c === "object" && c !== null && (c as { type: string }).type === "text";
74
+ }
75
+
76
+ export function extractText(content: unknown[]): string {
77
+ return content
78
+ .filter(isTextContent)
79
+ .map((c) => c.text ?? "")
80
+ .join("\n");
81
+ }
82
+ ```
83
+
84
+ The type predicate eliminates both `any` casts and the `eslint-disable` at the top of the file.
85
+
86
+ ### `SubscribableSession` with `AgentSessionEvent`
87
+
88
+ Move the interface to `types.ts` and type the callback:
89
+
90
+ ```typescript
91
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
92
+
93
+ export interface SubscribableSession {
94
+ subscribe(fn: (event: AgentSessionEvent) => void): () => void;
95
+ }
96
+ ```
97
+
98
+ Both observer files import from `types.ts` instead of defining their own.
99
+
100
+ ### Event property access in observer callbacks
101
+
102
+ Once the callback parameter is typed as `AgentSessionEvent`, TypeScript knows the event's discriminated union members.
103
+ The `event.type` checks narrow the union, so `event.toolName`, `event.message`, etc. become type-safe.
104
+ The `eslint-disable` comments for `no-unsafe-member-access` and `no-unsafe-assignment` can be removed from both observer files.
105
+
106
+ ## Module-Level Changes
107
+
108
+ | File | Change |
109
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
110
+ | `src/session/context.ts` | Import `TextContent`; add `isTextContent` type predicate; replace `any` filter/map; remove top-level `eslint-disable` |
111
+ | `src/types.ts` | Add `SubscribableSession` interface with `AgentSessionEvent` callback type; add `AgentSessionEvent` import |
112
+ | `src/observation/record-observer.ts` | Import `SubscribableSession` from `types.ts`; remove local interface; remove top-level `eslint-disable`; remove inline `any` annotation on callback parameter |
113
+ | `src/ui/ui-observer.ts` | Import `SubscribableSession` from `types.ts`; remove local interface; remove top-level `eslint-disable`; remove inline `any` annotation on callback parameter |
114
+
115
+ No test file changes expected — the mock session's structural typing remains compatible.
116
+
117
+ ## Test Impact Analysis
118
+
119
+ 1. No new unit tests are needed — the refactoring is type-only (no behavioral change).
120
+ 2. No existing tests become redundant.
121
+ 3. All existing tests for `subscribeRecordObserver` and `subscribeUIObserver` must pass as-is — they verify the same event-handling behavior.
122
+
123
+ ## TDD Order
124
+
125
+ This is a pure refactoring with no behavioral change.
126
+ Each step should pass `pnpm run check` (type-check) and `pnpm vitest run` (tests) before committing.
127
+
128
+ 1. **Add `isTextContent` type predicate and remove `any` from `extractText`.**
129
+ Import `TextContent` from `@earendil-works/pi-ai`.
130
+ Add `isTextContent` predicate function.
131
+ Replace the `any`-cast filter/map chain with the predicate.
132
+ Remove the top-level `eslint-disable` comment.
133
+ Verify: `pnpm run check`, `pnpm vitest run`.
134
+ Commit: `refactor: replace any casts in extractText with TextContent type predicate (#188)`
135
+
136
+ 2. **Move `SubscribableSession` to `types.ts` with `AgentSessionEvent`.**
137
+ Add `AgentSessionEvent` import and `SubscribableSession` interface to `src/types.ts`.
138
+ Update `record-observer.ts`: import from `types.ts`, remove local interface, remove `eslint-disable`, remove `any` from callback parameter.
139
+ Update `ui-observer.ts`: import from `types.ts`, remove local interface, remove `eslint-disable`, remove `any` from callback parameter.
140
+ Verify: `pnpm run check`, `pnpm vitest run`.
141
+ Commit: `refactor: replace any in SubscribableSession with AgentSessionEvent (#188)`
142
+
143
+ ## Risks and Mitigations
144
+
145
+ 1. **`AgentSessionEvent` union may not cover all event shapes accessed in observers.**
146
+ Mitigation: `agent-runner.ts` already uses the same type for identical event patterns (`event.type`, `event.toolName`, `event.message`).
147
+ The type checker will flag any property access that the union doesn't support.
148
+ Run `pnpm run check` after each step.
149
+
150
+ 2. **Mock session type incompatibility.**
151
+ The mock's `subscribe` accepts `(fn: (event: unknown) => void)`.
152
+ A `SubscribableSession` with `(fn: (event: AgentSessionEvent) => void)` is structurally compatible via contravariance.
153
+ If the compiler disagrees, the mitigation is to update `MockSession.subscribe` to accept `(fn: (event: AgentSessionEvent) => void)` — a one-line change.
154
+
155
+ 3. **`TextContent.text` is non-optional in the SDK type.**
156
+ The current code uses `c.text ?? ""` which implies `text` could be undefined.
157
+ `TextContent` defines `text: string` (required), so the nullish coalescing is harmless but unnecessary.
158
+ Keep it for safety — removing it is a separate cleanup.
159
+
160
+ ## Open Questions
161
+
162
+ None — the issue's proposed approach is unambiguous and the SDK types are already validated in adjacent files.
@@ -41,3 +41,37 @@ A docs commit updated the architecture file to remove `renderResult` from the co
41
41
  ## Stage: User Note (2026-05-24T21:30:00Z)
42
42
 
43
43
  We need to update `/plan-issue` so the plan includes updating any architecture documents.
44
+
45
+ ## Stage: Final Retrospective (2026-05-24T22:00:00Z)
46
+
47
+ ### Session summary
48
+
49
+ Issue #171 shipped cleanly across three sessions: planning produced a 9-step TDD plan, implementation added 43 tests and extracted `renderResult` into `tools/result-renderer.ts` (cognitive complexity 43 → dispatcher), and shipping released `pi-subagents-v6.18.8` with no deviations.
50
+ The only friction was an incomplete architecture doc update that required a second manual pass.
51
+
52
+ ### Observations
53
+
54
+ #### What went well
55
+
56
+ - The plan-to-implementation pipeline worked smoothly for a mechanical extraction refactor — zero deviations from the plan across all 9 TDD steps.
57
+ - The `Theme` type from `display.ts` and the `widget-renderer.ts` pattern provided a proven template, making the extraction straightforward.
58
+ - ESLint pre-commit hooks caught a stale `eslint-disable` rule (`no-unsafe-return`) automatically during the refactor commit.
59
+
60
+ #### What caused friction (agent side)
61
+
62
+ - `missing-context` — The TDD session's initial architecture update (commit `1183522`) only updated the complexity hotspot table and layout listing, missing health metrics, the Mermaid domain diagram, the Phase 10 structural refactoring table, and the SKILL.md domain table.
63
+ The user had to explicitly request the fuller update, which landed as a second commit (`1510dc7`).
64
+ Impact: extra user round-trip and a second commit for what should have been one complete update.
65
+ - `missing-context` — The `git add ../../.pi/skills/...` path triggered a `pi-permission-system` permission denial because the relative path escaped the package directory.
66
+ Switched to repo-root-relative path to resolve.
67
+ Impact: minor — one failed commit attempt, immediately retried.
68
+
69
+ #### What caused friction (user side)
70
+
71
+ - The `/plan-issue` prompt did not instruct the planner to check architecture docs for sections affected by the change.
72
+ Because the plan's Module-Level Changes omitted `docs/architecture/architecture.md`, the TDD session treated architecture updates as an afterthought rather than a planned deliverable.
73
+ Impact: the user had to intervene with an explicit request and a retro note.
74
+
75
+ ### Changes made
76
+
77
+ 1. `.pi/prompts/plan-issue.md` — Added architecture-doc check to the Module-Level Changes bullet: "When the change adds, removes, or moves a module, check `packages/<PKG>/docs/architecture/` for layout listings, complexity tables, health metrics, or domain diagrams that reference the affected files and list them as doc updates."
@@ -0,0 +1,80 @@
1
+ ---
2
+ issue: 172
3
+ issue_title: "refactor(pi-subagents): extract shared turn-formatting logic"
4
+ ---
5
+
6
+ # Retro: #172 — Extract shared turn-formatting logic
7
+
8
+ ## Stage: Planning (2026-05-24T18:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned the extraction of duplicated turn-formatting logic from `lifecycle/agent-runner.ts` and `ui/message-formatters.ts` into a new shared module `session/content-items.ts`.
13
+ The plan covers extracting `ToolCallContent`, `getToolCallName`, and a new `extractAssistantContent` function, with a 6-step TDD order.
14
+
15
+ ### Observations
16
+
17
+ - Issue #170 (completed) shifted the duplication target from `conversation-viewer.ts` to `message-formatters.ts` — the issue body's line references are stale but the duplication still exists in the same form.
18
+ - Both dependencies (#164 and #170) are closed, so this is unblocked.
19
+ - The duplication is clearly incidental (same data extraction, different presentation) — safe to extract per the code-design skill's structural-reasons check.
20
+ - `getToolCallName` has no direct unit tests today; the extraction enables testing it for the first time.
21
+ - `getAgentConversation` also has no tests — noted as out of scope but worth a follow-up.
22
+ - Considered adding `extractText` to the new module for consistency but deferred to keep scope tight.
23
+
24
+ ## Stage: Implementation — TDD (2026-05-24T19:05:00Z)
25
+
26
+ ### Session summary
27
+
28
+ Completed all 6 TDD steps from the plan.
29
+ Created `session/content-items.ts` with `getToolCallName` and `extractAssistantContent`, added 11 unit tests, then refactored both `message-formatters.ts` and `agent-runner.ts` to use the shared module.
30
+ Test count went from 896 to 907 (+11).
31
+
32
+ ### Observations
33
+
34
+ - Steps 1 and 2 (test-only commits) were folded into step 3's feat commit per the plan's intent — all three land together.
35
+ - The `getToolCallName` parameter type needed widening from `{ type: string }` to `{ type: string; [key: string]: unknown }` to allow test object literals to pass excess-property checking.
36
+ This in turn required an `as unknown as` double cast at the `agent-runner.ts` call site, because the SDK's `TextContent | ThinkingContent | ToolCall` union lacks an index signature.
37
+ Same pattern already present in `conversation-viewer.ts`.
38
+ - `message-formatters.ts` had both an import and a re-export of `getToolCallName`; simplified to a pure re-export only.
39
+ - The lint fixup (unused import) was amended into the same refactor commit before pushing.
40
+ - Architecture doc updated: `content-items.ts` added to session module listing, production-duplication section updated, Step 9 marked Done.
41
+
42
+ ## Stage: Final Retrospective (2026-05-24T20:30:00Z)
43
+
44
+ ### Session summary
45
+
46
+ Planned, implemented, and shipped the extraction of shared turn-formatting logic from `lifecycle/agent-runner.ts` and `ui/message-formatters.ts` into `session/content-items.ts`.
47
+ Released as `pi-subagents-v6.19.0`.
48
+ During code review the user challenged double-casts in the initial implementation, which led to discovering that the local `ToolCallContent` type was dead code and the SDK exports the real `ToolCall` type — the final implementation is significantly cleaner than what the plan specified.
49
+ Filed #188 for broader `any`-to-SDK-type cleanup discovered during the investigation.
50
+
51
+ ### Observations
52
+
53
+ #### What went well
54
+
55
+ - The user's Socratic challenge ("Talk to me about these double-casts") was the pivotal moment.
56
+ Rather than directing a fix, it prompted an investigation of the SDK's actual `ToolCall` type, which revealed that `ToolCall.name` is always required and `toolName` never appears on content items.
57
+ This eliminated the `ToolCallContent` interface, the `toolName` fallback, the index-signature parameter type, and all double-casts — none of which the plan anticipated.
58
+ - Cross-session retro context worked well: the planning-stage note about #170 shifting the duplication target saved time during TDD.
59
+ - The SDK source investigation yielded a follow-up issue (#188) for replacing `any` casts in `extractText` and `SubscribableSession` with proper SDK types.
60
+
61
+ #### What caused friction (agent side)
62
+
63
+ - `missing-context` — Did not check SDK type exports during planning.
64
+ The plan copied `ToolCallContent` verbatim from the existing code without verifying what `@earendil-works/pi-ai` exports.
65
+ The source comments ("SDK doesn't export the narrow type") were wrong — the types have been exported for some time.
66
+ Impact: the initial TDD implementation introduced a `{ type: string; [key: string]: unknown }` parameter type that forced `as unknown as` double-casts, requiring a full rework after user review.
67
+ - `premature-convergence` — When TypeScript rejected excess properties in test object literals, I widened the parameter type to include an index signature instead of exploring alternatives.
68
+ The correct fix (using `ReadonlyArray<{ type: string }>` with `in` narrowing, or importing SDK types as test fixtures) was simpler and avoided the cast cascade.
69
+ Impact: one round of rework plus an amended commit that muddied the git history.
70
+
71
+ #### What caused friction (user side)
72
+
73
+ - The user's intervention at the cast review stage was well-timed and effective.
74
+ One earlier opportunity: if the user had flagged the `toolName` fallback or the SDK-type question during the plan review (before TDD started), the initial implementation would have been correct from the start.
75
+ However, this is a marginal improvement — the plan review was clean and the friction was minor.
76
+
77
+ ### Changes made
78
+
79
+ 1. `.pi/skills/code-design/SKILL.md` — Added two rules to "Pi SDK boundaries": verify SDK exports before redeclaring types locally; prefer minimal structural supertypes over index-signature types for parameters accepting SDK content.
80
+ 2. `.pi/skills/testing/SKILL.md` — Added TDD planning rule: verify SDK exports when extracting locally-declared types that shadow SDK types.
@@ -0,0 +1,40 @@
1
+ ---
2
+ issue: 188
3
+ issue_title: "refactor(pi-subagents): replace any casts with SDK types in extractText and SubscribableSession"
4
+ ---
5
+
6
+ # Retro: #188 — Replace any casts with SDK types
7
+
8
+ ## Stage: Planning (2026-05-24T20:04:58Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced a two-step refactoring plan for replacing `any` casts in `extractText` (with a `TextContent` type predicate) and `SubscribableSession` (with `AgentSessionEvent`).
13
+ Verified that both SDK types are already imported and used in adjacent files within the package.
14
+ Confirmed mock session compatibility via function parameter contravariance — no test changes expected.
15
+
16
+ ### Observations
17
+
18
+ - The `extractText` parameter type stays `unknown[]` to avoid rippling through callers in `message-formatters.ts` that declare `content: unknown[]`.
19
+ A future cleanup could tighten those caller signatures.
20
+ - `SubscribableSession` moves to `src/types.ts` as the shared location, matching existing cross-domain types there (`SubagentType`, `ThinkingLevel`, `ShellExec`).
21
+ - All three `eslint-disable` top-level comments (`context.ts`, `record-observer.ts`, `ui-observer.ts`) should be removable once the `any` casts are gone, since the SDK union's discriminated members cover the property access patterns.
22
+ - Risk: if `AgentSessionEvent` doesn't cover `assistantMessageEvent` in `ui-observer.ts`, the type checker will surface it immediately — the mitigation is to check the union members during implementation.
23
+
24
+ ## Stage: Implementation — TDD (2026-05-24T20:17:38Z)
25
+
26
+ ### Session summary
27
+
28
+ Completed both TDD steps from the plan.
29
+ Step 1 replaced the `any` filter/map chain in `extractText` with an `isTextContent` type predicate; the `??""` removal was required because `TextContent.text` is non-optional per the SDK type.
30
+ Step 2 moved `SubscribableSession` to `types.ts` typed with `AgentSessionEvent`, removed both duplicate local interfaces, and removed all three `eslint-disable` comments.
31
+ Test count: 902 → 901 (one test removed).
32
+
33
+ ### Observations
34
+
35
+ - The `??` operator on `c.text` in `extractText` triggered `@typescript-eslint/no-unnecessary-condition` at commit time because `TextContent.text` is `string` (non-nullable); removing it was necessary, not just cosmetic.
36
+ - After typing the `record-observer` callback as `AgentSessionEvent`, five additional lint errors surfaced: `event.message?.role` (optional chain unnecessary since `MessageEndEvent.message` is required), `if (u)` guard (unnecessary since `AssistantMessage.usage` is required), and three `?? 0` guards on `input`/`output`/`cacheWrite` (all required `number` fields per `Usage` interface in the Pi source at `~/development/pi/pi/packages/ai/src/types.ts`).
37
+ - The test `"ignores message_end without usage"` was removed — it emitted a non-conforming event that the SDK types guarantee cannot occur at runtime.
38
+ - `ui-observer.ts` had one analogous fix: `event.assistantMessageEvent?.type` → `.type` (the field is required on `MessageUpdateEvent`).
39
+ - No test file changes were needed for `ui-observer.ts` — its existing tests all emit conforming events.
40
+ - The plan's contravariance reasoning about mock session compatibility was correct: `pnpm run check` passed without updating `MockSession.subscribe`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.18.8",
3
+ "version": "6.19.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/service.ts"
@@ -1,5 +1,5 @@
1
1
  /**
2
- * agent-runner.ts Core execution engine: creates sessions, runs agents, collects results.
2
+ * agent-runner.ts - Core execution engine: creates sessions, runs agents, collects results.
3
3
  */
4
4
 
5
5
  import type { Model } from "@earendil-works/pi-ai";
@@ -11,6 +11,7 @@ import {
11
11
  import type { AgentConfigLookup } from "#src/config/agent-types";
12
12
  import type { ParentSessionInfo } from "#src/lifecycle/agent-manager";
13
13
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
14
+ import { extractAssistantContent } from "#src/session/content-items";
14
15
  import { extractText } from "#src/session/context";
15
16
  import type { EnvInfo } from "#src/session/env";
16
17
  import { type AssemblerIO, assembleSessionConfig, type ToolFilterConfig } from "#src/session/session-config";
@@ -19,27 +20,10 @@ import type { ShellExec, SubagentType, ThinkingLevel } from "#src/types";
19
20
  /** Names of tools registered by this extension that subagents must NOT inherit. */
20
21
  const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
21
22
 
22
- // ── Local message-shape types ───────────────────────────────────────────────
23
- // The Pi SDK does not export a narrow type for tool-call content variants.
24
-
25
- /** Tool-call content item — SDK exposes this variant at runtime but doesn’t export the narrow type. */
26
- interface ToolCallContent {
27
- type: "toolCall";
28
- name?: string;
29
- toolName?: string;
30
- }
31
-
32
- /** Extracts the display name from a tool-call content item. */
33
- function getToolCallName(c: { type: string }): string {
34
- if (c.type !== "toolCall") return "unknown";
35
- const tc = c as ToolCallContent;
36
- return tc.name ?? tc.toolName ?? "unknown";
37
- }
38
-
39
23
  /**
40
24
  * Filter the session's active tool names according to extension/denylist rules.
41
25
  *
42
- * Run twice once before `bindExtensions` (filters built-in tools) and once after
26
+ * Run twice - once before `bindExtensions` (filters built-in tools) and once after
43
27
  * (filters extension-registered tools, which only join the active set during
44
28
  * `bindExtensions`). Extracting this keeps the two callsites consistent and makes
45
29
  * the post-bind re-filter trivial.
@@ -116,7 +100,7 @@ export interface CreateSessionOptions {
116
100
  }
117
101
 
118
102
  /**
119
- * Environment discovery detect runtime context and resolve directories.
103
+ * Environment discovery - detect runtime context and resolve directories.
120
104
  *
121
105
  * Decouples the runner from direct process/SDK reads so each can be stubbed
122
106
  * independently in tests.
@@ -128,7 +112,7 @@ export interface EnvironmentIO {
128
112
  }
129
113
 
130
114
  /**
131
- * Session factory create SDK objects for a child agent session.
115
+ * Session factory - create SDK objects for a child agent session.
132
116
  *
133
117
  * Decouples the runner from direct Pi SDK imports and sibling-module IO,
134
118
  * making it testable via plain stub objects without vi.mock().
@@ -153,15 +137,15 @@ export type RunnerIO = EnvironmentIO & SessionFactoryIO;
153
137
  // ── Public interfaces ─────────────────────────────────────────────────────────
154
138
 
155
139
  /**
156
- * Parent execution context where/who is running.
140
+ * Parent execution context - where/who is running.
157
141
  *
158
142
  * Groups the four fields that describe the parent environment and identity,
159
143
  * separating them from the per-call execution parameters in RunOptions.
160
144
  */
161
145
  export interface RunContext {
162
- /** Shell-exec callback for detectEnv injected from pi.exec(). */
146
+ /** Shell-exec callback for detectEnv - injected from pi.exec(). */
163
147
  exec: ShellExec;
164
- /** Agent config lookup provides resolveAgentConfig and getToolNamesForType. */
148
+ /** Agent config lookup - provides resolveAgentConfig and getToolNamesForType. */
165
149
  registry: AgentConfigLookup;
166
150
  /** Override working directory (e.g. for worktree isolation). */
167
151
  cwd?: string;
@@ -170,14 +154,14 @@ export interface RunContext {
170
154
  }
171
155
 
172
156
  export interface RunOptions {
173
- /** Parent execution context where/who is running. */
157
+ /** Parent execution context - where/who is running. */
174
158
  context: RunContext;
175
159
  model?: Model<any>;
176
160
  maxTurns?: number;
177
161
  signal?: AbortSignal;
178
162
  isolated?: boolean;
179
163
  thinkingLevel?: ThinkingLevel;
180
- /** Called once after session creation session delivery mechanism. */
164
+ /** Called once after session creation - session delivery mechanism. */
181
165
  onSessionCreated?: (session: AgentSession) => void;
182
166
  /**
183
167
  * Default max turns from runtime config. Falls back to the module-scope
@@ -285,7 +269,7 @@ export async function runAgent(
285
269
  options: RunOptions,
286
270
  io: RunnerIO,
287
271
  ): Promise<RunResult> {
288
- // Resolve working directory upfront needed for detectEnv before assembly.
272
+ // Resolve working directory upfront - needed for detectEnv before assembly.
289
273
  const effectiveCwd = options.context.cwd ?? snapshot.cwd;
290
274
  const env = await io.detectEnv(options.context.exec, effectiveCwd);
291
275
 
@@ -312,7 +296,7 @@ export async function runAgent(
312
296
  const agentDir = io.getAgentDir();
313
297
 
314
298
  // Load extensions/skills: true or string[] → load; false → don't.
315
- // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md upstream's
299
+ // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md - upstream's
316
300
  // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
317
301
  // would defeat prompt_mode: replace and isolated: true. Parent context, if
318
302
  // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
@@ -351,7 +335,7 @@ export async function runAgent(
351
335
 
352
336
  // Filter active tools: remove our own tools to prevent nesting,
353
337
  // apply extension allowlist if specified, and apply disallowedTools denylist.
354
- // First pass over built-in tools, before bindExtensions registers extension tools.
338
+ // First pass - over built-in tools, before bindExtensions registers extension tools.
355
339
  if (cfg.toolFilter.extensions !== false || cfg.toolFilter.disallowedSet) {
356
340
  const filtered = filterActiveTools(session.getActiveToolNames(), cfg.toolFilter);
357
341
  session.setActiveToolsByName(filtered);
@@ -391,7 +375,7 @@ export async function runAgent(
391
375
  if (!softLimitReached && turnCount >= maxTurns) {
392
376
  softLimitReached = true;
393
377
  void session.steer(
394
- "You have reached your turn limit. Wrap up immediately provide your final answer now.",
378
+ "You have reached your turn limit. Wrap up immediately - provide your final answer now.",
395
379
  );
396
380
  } else if (softLimitReached && turnCount >= maxTurns + (options.graceTurns ?? 5)) {
397
381
  aborted = true;
@@ -475,17 +459,11 @@ export function getAgentConversation(session: AgentSession): string {
475
459
  : extractText(msg.content);
476
460
  if (text.trim()) parts.push(`[User]: ${text.trim()}`);
477
461
  } else if (msg.role === "assistant") {
478
- const textParts: string[] = [];
479
- const toolCalls: string[] = [];
480
- for (const c of msg.content) {
481
- if (c.type === "text" && c.text) textParts.push(c.text);
482
- else if (c.type === "toolCall")
483
- toolCalls.push(` Tool: ${getToolCallName(c)}`);
484
- }
462
+ const { textParts, toolNames } = extractAssistantContent(msg.content);
485
463
  if (textParts.length > 0)
486
464
  parts.push(`[Assistant]: ${textParts.join("\n")}`);
487
- if (toolCalls.length > 0)
488
- parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
465
+ if (toolNames.length > 0)
466
+ parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
489
467
  } else if (msg.role === "toolResult") {
490
468
  const text = extractText(msg.content);
491
469
  const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
@@ -1,4 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
2
1
  /**
3
2
  * record-observer.ts — Subscribes to session events and updates AgentRecord stats.
4
3
  *
@@ -8,11 +7,7 @@
8
7
 
9
8
  import type { CompactionInfo } from "#src/lifecycle/agent-manager";
10
9
  import type { AgentRecord } from "#src/lifecycle/agent-record";
11
-
12
- /** Narrow session interface — only the subscribe method needed by the observer. */
13
- interface SubscribableSession {
14
- subscribe(fn: (event: any) => void): () => void;
15
- }
10
+ import type { SubscribableSession } from "#src/types";
16
11
 
17
12
  export interface RecordObserverOptions {
18
13
  onCompact?: (record: AgentRecord, info: CompactionInfo) => void;
@@ -33,20 +28,18 @@ export function subscribeRecordObserver(
33
28
  record: AgentRecord,
34
29
  options?: RecordObserverOptions,
35
30
  ): () => void {
36
- return session.subscribe((event: any) => {
31
+ return session.subscribe((event) => {
37
32
  if (event.type === "tool_execution_end") {
38
33
  record.incrementToolUses();
39
34
  }
40
35
 
41
- if (event.type === "message_end" && event.message?.role === "assistant") {
36
+ if (event.type === "message_end" && event.message.role === "assistant") {
42
37
  const u = event.message.usage;
43
- if (u) {
44
- record.addUsage({
45
- input: u.input ?? 0,
46
- output: u.output ?? 0,
47
- cacheWrite: u.cacheWrite ?? 0,
48
- });
49
- }
38
+ record.addUsage({
39
+ input: u.input,
40
+ output: u.output,
41
+ cacheWrite: u.cacheWrite,
42
+ });
50
43
  }
51
44
 
52
45
  if (event.type === "compaction_end" && !event.aborted && event.result) {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * content-items.ts — Shared parsing utilities for Pi SDK message content items.
3
+ *
4
+ * Provides type-safe extraction of text parts and tool-call names from
5
+ * assistant message content arrays. Pure functions — no IO.
6
+ */
7
+
8
+ import type { TextContent, ToolCall } from "@earendil-works/pi-ai";
9
+
10
+ // ── Types ─────────────────────────────────────────────────────────────────────
11
+
12
+ /** Extracted text parts and tool names from assistant message content. */
13
+ export interface AssistantContentParts {
14
+ textParts: string[];
15
+ toolNames: string[];
16
+ }
17
+
18
+ // ── Functions ─────────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Extracts the display name from a tool-call content item.
22
+ *
23
+ * Returns 'unknown' for non-toolCall items.
24
+ * The Pi SDK's ToolCall.name is always present — no fallback chain needed.
25
+ */
26
+ export function getToolCallName(c: { type: string }): string {
27
+ if (c.type !== "toolCall") return "unknown";
28
+ return (c as ToolCall).name;
29
+ }
30
+
31
+ /**
32
+ * Extract text parts and tool-call names from assistant message content items.
33
+ *
34
+ * Accepts any array whose elements carry a `type` discriminant — all Pi SDK
35
+ * content types (TextContent, ThinkingContent, ToolCall) satisfy this constraint.
36
+ * Pure data extraction — consumers apply their own presentation formatting.
37
+ * Skips items of unknown types (e.g. thinking blocks, images) and empty text.
38
+ */
39
+ export function extractAssistantContent(
40
+ content: ReadonlyArray<{ type: string }>,
41
+ ): AssistantContentParts {
42
+ const textParts: string[] = [];
43
+ const toolNames: string[] = [];
44
+ for (const c of content) {
45
+ if (c.type === "text") {
46
+ const text = (c as TextContent).text;
47
+ if (text) textParts.push(text);
48
+ } else if (c.type === "toolCall") {
49
+ toolNames.push(getToolCallName(c));
50
+ }
51
+ }
52
+ return { textParts, toolNames };
53
+ }
@@ -1,15 +1,20 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
2
1
  /**
3
2
  * context.ts — Extract parent conversation context for subagent inheritance.
4
3
  */
5
4
 
5
+ import type { TextContent } from "@earendil-works/pi-ai";
6
6
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
 
8
+ /** Type predicate: narrow an unknown content block to TextContent. */
9
+ function isTextContent(c: unknown): c is TextContent {
10
+ return typeof c === "object" && c !== null && (c as { type: string }).type === "text";
11
+ }
12
+
8
13
  /** Extract text from a message content block array. */
9
14
  export function extractText(content: unknown[]): string {
10
15
  return content
11
- .filter((c: any) => c.type === "text")
12
- .map((c: any) => c.text ?? "")
16
+ .filter(isTextContent)
17
+ .map((c) => c.text)
13
18
  .join("\n");
14
19
  }
15
20
 
package/src/types.ts CHANGED
@@ -3,10 +3,19 @@
3
3
  */
4
4
 
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
6
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
6
7
 
7
8
 
8
9
  export { AgentRecord } from "#src/lifecycle/agent-record";
9
- export type { ThinkingLevel };
10
+ export type { AgentSessionEvent, ThinkingLevel };
11
+
12
+ /**
13
+ * Narrow session interface for event subscription.
14
+ * Used by record-observer and ui-observer — only the subscribe method is needed.
15
+ */
16
+ export interface SubscribableSession {
17
+ subscribe(fn: (event: AgentSessionEvent) => void): () => void;
18
+ }
10
19
 
11
20
  /** Agent type: any string name (built-in defaults or user-defined). */
12
21
  export type SubagentType = string;
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { truncateToWidth } from "@earendil-works/pi-tui";
9
+ import { extractAssistantContent } from "#src/session/content-items";
9
10
  import { extractText } from "#src/session/context";
10
11
  import type { Theme } from "#src/ui/display";
11
12
  import { describeActivity } from "#src/ui/display";
@@ -20,6 +21,8 @@ export interface FormatterContext {
20
21
 
21
22
  // ── File-local types and guards ─────────────────────────────────────────────
22
23
 
24
+ export { getToolCallName } from "#src/session/content-items";
25
+
23
26
  /** Bash execution message — 'bashExecution' role is not in the SDK's AgentSession message role union. */
24
27
  export interface BashExecutionMessage {
25
28
  role: "bashExecution";
@@ -32,20 +35,6 @@ export function isBashExecution(msg: { role: string }): msg is BashExecutionMess
32
35
  return msg.role === "bashExecution";
33
36
  }
34
37
 
35
- /** Tool-call content item — SDK exposes this variant at runtime but doesn't export the narrow type. */
36
- interface ToolCallContent {
37
- type: "toolCall";
38
- name?: string;
39
- toolName?: string;
40
- }
41
-
42
- /** Extracts the tool name from a toolCall content item, falling back to 'unknown'. */
43
- export function getToolCallName(c: { type: string }): string {
44
- if (c.type !== "toolCall") return "unknown";
45
- const tc = c as ToolCallContent;
46
- return tc.name ?? tc.toolName ?? "unknown";
47
- }
48
-
49
38
  // ── formatUserMessage ─────────────────────────────────────────────────────────
50
39
 
51
40
  /**
@@ -141,17 +130,12 @@ export function formatAssistantMessage(
141
130
  ctx: FormatterContext,
142
131
  ): string[] {
143
132
  const { theme, wrapText } = ctx;
144
- const textParts: string[] = [];
145
- const toolCalls: string[] = [];
146
- for (const c of content) {
147
- if (c.type === "text" && c.text) textParts.push(c.text as string);
148
- else if (c.type === "toolCall") toolCalls.push(getToolCallName(c));
149
- }
133
+ const { textParts, toolNames } = extractAssistantContent(content);
150
134
  const lines: string[] = [theme.bold("[Assistant]")];
151
135
  if (textParts.length > 0) {
152
136
  lines.push(...wrapText(textParts.join("\n").trim(), width));
153
137
  }
154
- for (const name of toolCalls) {
138
+ for (const name of toolNames) {
155
139
  lines.push(truncateToWidth(theme.fg("muted", ` [Tool: ${name}]`), width));
156
140
  }
157
141
  return lines;
@@ -1,4 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
2
1
  /**
3
2
  * ui-observer.ts — Subscribes to session events and updates AgentActivityTracker state.
4
3
  *
@@ -7,13 +6,9 @@
7
6
  * turn count, lifetime usage).
8
7
  */
9
8
 
9
+ import type { SubscribableSession } from "#src/types";
10
10
  import type { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
11
11
 
12
- /** Narrow session interface — only the subscribe method needed by the observer. */
13
- interface SubscribableSession {
14
- subscribe(fn: (event: any) => void): () => void;
15
- }
16
-
17
12
  /**
18
13
  * Subscribe to session events and stream UI state into an AgentActivityTracker.
19
14
  *
@@ -34,7 +29,7 @@ export function subscribeUIObserver(
34
29
  tracker: AgentActivityTracker,
35
30
  onUpdate?: () => void,
36
31
  ): () => void {
37
- return session.subscribe((event: any) => {
32
+ return session.subscribe((event) => {
38
33
  if (event.type === "tool_execution_start") {
39
34
  tracker.onToolStart(event.toolName);
40
35
  onUpdate?.();
@@ -51,7 +46,7 @@ export function subscribeUIObserver(
51
46
 
52
47
  if (
53
48
  event.type === "message_update" &&
54
- event.assistantMessageEvent?.type === "text_delta"
49
+ event.assistantMessageEvent.type === "text_delta"
55
50
  ) {
56
51
  tracker.onMessageUpdate(event.assistantMessageEvent.delta);
57
52
  onUpdate?.();