@gotgenes/pi-subagents 13.0.0 → 13.2.0

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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * subagent-session.ts — The born-complete child-session value object (issue #265).
3
+ *
4
+ * A SubagentSession wraps one SDK AgentSession plus its turn-driving and teardown.
5
+ * It is born complete: `createSubagentSession()` returns a fully usable instance
6
+ * (session created, extensions bound, recursion guard applied), so the only thing
7
+ * left for `Agent` to do is coordinate — drive the turn loop, steer, dispose.
8
+ *
9
+ * Turn driving lives here, on the object that owns the AgentSession, rather than
10
+ * reaching through `subagentSession.session` from `Agent` (Law of Demeter).
11
+ */
12
+
13
+ import {
14
+ type AgentSession,
15
+ type AgentSessionEvent,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
18
+ import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
19
+ import { getSessionContextPercent, type SessionStatsLike } from "#src/lifecycle/usage";
20
+ import { extractText } from "#src/session/context";
21
+ import { getAgentConversation } from "#src/session/conversation";
22
+
23
+ /** Outcome of one turn loop. */
24
+ export interface TurnLoopResult {
25
+ responseText: string;
26
+ /** True if the agent was hard-aborted (max turns + grace exceeded). */
27
+ aborted: boolean;
28
+ /** True if the agent was steered to wrap up (soft turn limit) but finished in time. */
29
+ steered: boolean;
30
+ }
31
+
32
+ /** Per-call options for the initial run's turn loop. */
33
+ export interface TurnLoopOptions {
34
+ /** Per-call max-turns override — highest precedence. */
35
+ maxTurns?: number;
36
+ /** Runtime-config fallback when neither per-call nor per-agent limit is set. */
37
+ defaultMaxTurns?: number;
38
+ /** Grace turns after the soft-limit steer message before a hard abort. */
39
+ graceTurns?: number;
40
+ signal?: AbortSignal;
41
+ }
42
+
43
+ /** Session-level facts known at creation, supplied by the factory. */
44
+ export interface SubagentSessionMeta {
45
+ /** Path to the persisted session JSONL file, if the session was persisted. */
46
+ outputFile: string | undefined;
47
+ /** Child session directory — the registry key carried on lifecycle events. */
48
+ sessionDir: string;
49
+ agentName: string;
50
+ /** Per-agent max-turns from the resolved agent config — middle precedence. */
51
+ agentMaxTurns: number | undefined;
52
+ /** Parent context prepended to the run prompt, captured at spawn time. */
53
+ parentContext: string | undefined;
54
+ lifecycle: ChildLifecyclePublisher;
55
+ }
56
+
57
+ /**
58
+ * One child AgentSession plus its turn-driving and teardown — born complete.
59
+ */
60
+ export class SubagentSession {
61
+ constructor(
62
+ private readonly _session: AgentSession,
63
+ private readonly meta: SubagentSessionMeta,
64
+ ) {}
65
+
66
+ /**
67
+ * Wrapped session — for lifecycle-internal use only.
68
+ * @internal consumers outside lifecycle/ use the delegate methods below.
69
+ */
70
+ get session(): AgentSession {
71
+ return this._session;
72
+ }
73
+
74
+ get outputFile(): string | undefined {
75
+ return this.meta.outputFile;
76
+ }
77
+
78
+ /** Drive the initial run's turn loop; emits `completed` on success. */
79
+ async runTurnLoop(prompt: string, opts: TurnLoopOptions): Promise<TurnLoopResult> {
80
+ const session = this._session;
81
+
82
+ // Track turns for graceful max_turns enforcement.
83
+ let turnCount = 0;
84
+ const maxTurns = normalizeMaxTurns(
85
+ opts.maxTurns ?? this.meta.agentMaxTurns ?? opts.defaultMaxTurns,
86
+ );
87
+ let softLimitReached = false;
88
+ let aborted = false;
89
+
90
+ const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
91
+ if (event.type === "turn_end") {
92
+ turnCount++;
93
+ if (maxTurns != null) {
94
+ if (!softLimitReached && turnCount >= maxTurns) {
95
+ softLimitReached = true;
96
+ void session.steer(
97
+ "You have reached your turn limit. Wrap up immediately - provide your final answer now.",
98
+ );
99
+ } else if (softLimitReached && turnCount >= maxTurns + (opts.graceTurns ?? 5)) {
100
+ aborted = true;
101
+ void session.abort();
102
+ }
103
+ }
104
+ }
105
+ });
106
+
107
+ const collector = collectResponseText(session);
108
+ const cleanupAbort = forwardAbortSignal(session, opts.signal);
109
+
110
+ // Prepend parent context if it was captured at spawn time.
111
+ const effectivePrompt = this.meta.parentContext
112
+ ? this.meta.parentContext + prompt
113
+ : prompt;
114
+
115
+ try {
116
+ await session.prompt(effectivePrompt);
117
+ this.meta.lifecycle.completed({
118
+ sessionDir: this.meta.sessionDir,
119
+ agentName: this.meta.agentName,
120
+ aborted,
121
+ steered: softLimitReached,
122
+ });
123
+ } finally {
124
+ unsubTurns();
125
+ collector.unsubscribe();
126
+ cleanupAbort();
127
+ }
128
+
129
+ const responseText = collector.getText().trim() || getLastAssistantText(session);
130
+ return { responseText, aborted, steered: softLimitReached };
131
+ }
132
+
133
+ /** Re-prompt the same session (resume); does not emit `completed`. */
134
+ async resumeTurnLoop(prompt: string, signal?: AbortSignal): Promise<string> {
135
+ const session = this._session;
136
+ const collector = collectResponseText(session);
137
+ const cleanupAbort = forwardAbortSignal(session, signal);
138
+
139
+ try {
140
+ await session.prompt(prompt);
141
+ } finally {
142
+ collector.unsubscribe();
143
+ cleanupAbort();
144
+ }
145
+
146
+ return collector.getText().trim() || getLastAssistantText(session);
147
+ }
148
+
149
+ /** Deliver a steer to the live session. */
150
+ async steer(message: string): Promise<void> {
151
+ await this._session.steer(message);
152
+ }
153
+
154
+ /** Return the session's conversation as formatted text. */
155
+ getConversation(): string {
156
+ return getAgentConversation(this._session);
157
+ }
158
+
159
+ /** Return the session context window utilization (0-100), or null when unavailable. */
160
+ getContextPercent(): number | null {
161
+ return getSessionContextPercent(this._session);
162
+ }
163
+
164
+ /** Subscribe to session events. Satisfies `SubscribableSession`. */
165
+ subscribe(fn: (event: AgentSessionEvent) => void): () => void {
166
+ return this._session.subscribe(fn);
167
+ }
168
+
169
+ /** Return session token statistics. Satisfies `SessionLike`. */
170
+ getSessionStats(): SessionStatsLike {
171
+ return this._session.getSessionStats();
172
+ }
173
+
174
+ /** The session's message history. */
175
+ get messages(): readonly unknown[] {
176
+ return this._session.messages as readonly unknown[];
177
+ }
178
+
179
+ /** Tear down: session.dispose() + emit `disposed` (registry unregister). */
180
+ dispose(): void {
181
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
182
+ this._session.dispose?.();
183
+ this.meta.lifecycle.disposed({ sessionDir: this.meta.sessionDir });
184
+ }
185
+ }
186
+
187
+ // ── Private turn-loop helpers ───────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Subscribe to a session and collect the last assistant message text.
191
+ * Returns an object with a `getText()` getter and an `unsubscribe` function.
192
+ */
193
+ function collectResponseText(session: AgentSession) {
194
+ let text = "";
195
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
196
+ if (event.type === "message_start") {
197
+ text = "";
198
+ }
199
+ if (
200
+ event.type === "message_update" &&
201
+ event.assistantMessageEvent.type === "text_delta"
202
+ ) {
203
+ text += event.assistantMessageEvent.delta;
204
+ }
205
+ });
206
+ return { getText: () => text, unsubscribe };
207
+ }
208
+
209
+ /** Get the last assistant text from the completed session history. */
210
+ function getLastAssistantText(session: AgentSession): string {
211
+ for (let i = session.messages.length - 1; i >= 0; i--) {
212
+ const msg = session.messages[i];
213
+ if (msg.role !== "assistant") continue;
214
+ const text = extractText(msg.content).trim();
215
+ if (text) return text;
216
+ }
217
+ return "";
218
+ }
219
+
220
+ /**
221
+ * Wire an AbortSignal to abort a session.
222
+ * Returns a cleanup function to remove the listener.
223
+ */
224
+ function forwardAbortSignal(
225
+ session: AgentSession,
226
+ signal?: AbortSignal,
227
+ ): () => void {
228
+ if (!signal) return () => {};
229
+ const onAbort = (): void => {
230
+ void session.abort();
231
+ };
232
+ signal.addEventListener("abort", onAbort, { once: true });
233
+ return () => signal.removeEventListener("abort", onAbort);
234
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * turn-limits.ts — Pure turn-limit normalization for subagent execution.
3
+ *
4
+ * Extracted from agent-runner.ts (issue #265) so the turn-counting policy has a
5
+ * focused home independent of session assembly. Consumed by the Agent tool's
6
+ * spawn-config resolution and by the turn loop in SubagentSession.
7
+ */
8
+
9
+ /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
10
+ export function normalizeMaxTurns(n: number | undefined): number | undefined {
11
+ if (n == null || n === 0) return undefined;
12
+ return Math.max(1, n);
13
+ }
@@ -1,5 +1,5 @@
1
1
  import { debugLog } from "#src/debug";
2
- import { getLifetimeTotal, getSessionContextPercent } from "#src/lifecycle/usage";
2
+ import { getLifetimeTotal } from "#src/lifecycle/usage";
3
3
  import type { Agent } from "#src/types";
4
4
  import type { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
5
5
 
@@ -46,7 +46,7 @@ export function formatTaskNotification(record: Agent, resultMaxLen: number): str
46
46
  const status = getStatusLabel(record.status, record.error);
47
47
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
48
48
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
49
- const contextPercent = getSessionContextPercent(record.session);
49
+ const contextPercent = record.getContextPercent();
50
50
  const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
51
51
  const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
52
52
 
package/src/runtime.ts CHANGED
@@ -25,7 +25,7 @@ export interface WidgetLike {
25
25
  }
26
26
 
27
27
  /**
28
- * Narrow config subset read by AgentManager when constructing RunOptions execution fields.
28
+ * Narrow config subset read by Agent when driving the turn loop (defaultMaxTurns, graceTurns).
29
29
  * Kept separate so callers can satisfy it without depending on the full runtime.
30
30
  */
31
31
  export interface RunConfig {
@@ -90,13 +90,7 @@ export class SubagentsServiceAdapter implements SubagentsService {
90
90
  if (record?.status !== "running") {
91
91
  return false;
92
92
  }
93
- const session = record.session;
94
- if (!session) {
95
- // Session not ready yet — buffer on the agent for delivery once initialized
96
- record.queueSteer(message);
97
- return true;
98
- }
99
- await session.steer(message);
93
+ await record.steer(message);
100
94
  return true;
101
95
  }
102
96
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * conversation.ts — Render a subagent session's messages as formatted text.
3
+ *
4
+ * Extracted from agent-runner.ts (issue #265) into the session domain, where the
5
+ * other message-extraction helpers (content-items, context) live. Consumed by
6
+ * the get_subagent_result tool's verbose output.
7
+ */
8
+
9
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
10
+ import { extractAssistantContent } from "#src/session/content-items";
11
+ import { extractText } from "#src/session/context";
12
+
13
+ /**
14
+ * Get the subagent's conversation messages as formatted text.
15
+ */
16
+ export function getAgentConversation(session: AgentSession): string {
17
+ const parts: string[] = [];
18
+
19
+ for (const msg of session.messages) {
20
+ if (msg.role === "user") {
21
+ const text =
22
+ typeof msg.content === "string"
23
+ ? msg.content
24
+ : extractText(msg.content);
25
+ if (text.trim()) parts.push(`[User]: ${text.trim()}`);
26
+ } else if (msg.role === "assistant") {
27
+ const { textParts, toolNames } = extractAssistantContent(msg.content);
28
+ const attribution = formatAttribution(msg);
29
+ if (textParts.length > 0)
30
+ parts.push(`[Assistant${attribution}]: ${textParts.join("\n")}`);
31
+ if (toolNames.length > 0)
32
+ parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
33
+ } else if (msg.role === "toolResult") {
34
+ const text = extractText(msg.content);
35
+ const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
36
+ parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
37
+ }
38
+ }
39
+
40
+ return parts.join("\n\n");
41
+ }
42
+
43
+ /** Build a `(provider/model)` attribution suffix for assistant messages. */
44
+ function formatAttribution(msg: { provider?: string; model?: string }): string {
45
+ const { provider, model } = msg;
46
+ if (!provider && !model) return "";
47
+ if (provider && model) return ` (${provider}/${model})`;
48
+ return ` (${provider ?? model})`;
49
+ }
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * session-config.ts — Pure configuration assembler for agent sessions.
3
3
  *
4
- * `assembleSessionConfig()` is the pure core extracted from `runAgent()`.
5
- * It accepts resolved inputs (agent type, narrow context, run options, env info)
6
- * and returns everything `runAgent()` needs to create the SDK session — without
7
- * importing or constructing any Pi SDK types.
4
+ * `assembleSessionConfig()` is the pure assembly core called by
5
+ * `createSubagentSession()`. It accepts resolved inputs (agent type, narrow
6
+ * context, run options, env info) and returns everything the factory needs to
7
+ * create the SDK session — without importing or constructing any Pi SDK types.
8
8
  *
9
9
  * The only async IO in the assembly phase (`detectEnv`) is handled by the caller
10
10
  * before invoking this function, keeping the assembler synchronous.
@@ -22,7 +22,7 @@ import type { AgentPromptConfig, SubagentType, ThinkingLevel } from "#src/types"
22
22
  * Bundling the IO-touching (or promptly testable) function into a single
23
23
  * interface keeps the assembler free of direct module imports and makes it
24
24
  * trivially testable without `vi.mock()` — callers inject real implementations
25
- * at the edge (`agent-runner.ts`) or stubs in tests.
25
+ * at the edge (`create-subagent-session.ts`) or stubs in tests.
26
26
  */
27
27
  export interface AssemblerIO {
28
28
  buildAgentPrompt: (
@@ -57,7 +57,7 @@ export interface AssemblerContext {
57
57
  }
58
58
 
59
59
  /**
60
- * Narrow slice of RunOptions execution fields consumed by the assembler.
60
+ * Narrow slice of per-spawn execution fields consumed by the assembler.
61
61
  * All fields are optional — callers pass only what they have.
62
62
  */
63
63
  export interface AssemblerOptions {
@@ -70,7 +70,7 @@ export interface AssemblerOptions {
70
70
  }
71
71
 
72
72
  /**
73
- * Assembled configuration returned to `runAgent()`.
73
+ * Assembled configuration returned to `createSubagentSession()`.
74
74
  * Contains everything needed to create the SDK session and filter tools —
75
75
  * with no SDK object references.
76
76
  */
@@ -173,7 +173,7 @@ export function assembleSessionConfig(
173
173
  // Thinking level: explicit option > agent config > undefined (inherit)
174
174
  const thinkingLevel = options.thinkingLevel ?? agentConfig.thinking;
175
175
 
176
- // Per-agent max turns (combined with options.maxTurns and defaultMaxTurns by runAgent)
176
+ // Per-agent max turns (combined with per-call maxTurns and defaultMaxTurns by SubagentSession.runTurnLoop)
177
177
  const agentMaxTurns = agentConfig.maxTurns;
178
178
 
179
179
  return {
package/src/settings.ts CHANGED
@@ -8,7 +8,7 @@ export interface SubagentsSettings {
8
8
  maxConcurrent?: number;
9
9
  /**
10
10
  * 0 = unlimited — the extension's single source of truth for that convention:
11
- * `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
11
+ * `normalizeMaxTurns()` in turn-limits.ts treats 0 → `undefined`, and the
12
12
  * `/agents` → Settings input prompt explicitly says "0 = unlimited".
13
13
  */
14
14
  defaultMaxTurns?: number;
@@ -108,7 +108,7 @@ export class AgentTool {
108
108
  `Agent not found: "${params.resume}". It may have been cleaned up.`,
109
109
  );
110
110
  }
111
- if (!existing.session) {
111
+ if (!existing.isSessionReady()) {
112
112
  return textResult(
113
113
  `Agent "${params.resume}" has no active session to resume.`,
114
114
  );
@@ -53,9 +53,10 @@ export function spawnBackground(
53
53
  isBackground: true,
54
54
  invocation: execution.agentInvocation,
55
55
  observer: {
56
- onSessionCreated: (_agent, session) => {
57
- bgState.setSession(session);
58
- subscribeUIObserver(session, bgState);
56
+ onSessionCreated: (agent) => {
57
+ const sub = agent.subagentSession!;
58
+ bgState.setSession(sub);
59
+ subscribeUIObserver(sub, bgState);
59
60
  },
60
61
  },
61
62
  });
@@ -108,10 +108,11 @@ export async function runForeground(
108
108
  signal,
109
109
  parentSession: params.parentSession,
110
110
  observer: {
111
- onSessionCreated: (agent, session) => {
112
- fgState.setSession(session);
111
+ onSessionCreated: (agent) => {
112
+ const sub = agent.subagentSession!;
113
+ fgState.setSession(sub);
113
114
  recordRef = agent;
114
- unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
115
+ unsubUI = subscribeUIObserver(sub, fgState, streamUpdate);
115
116
  fgId = agent.id;
116
117
  agentActivity.set(agent.id, fgState);
117
118
  widget.ensureTimer();
@@ -1,8 +1,6 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "@sinclair/typebox";
3
3
  import type { AgentConfigLookup } from "#src/config/agent-types";
4
- import { getAgentConversation } from "#src/lifecycle/agent-runner";
5
- import { getSessionContextPercent } from "#src/lifecycle/usage";
6
4
  import { formatLifetimeTokens, textResult } from "#src/tools/helpers";
7
5
  import type { Agent } from "#src/types";
8
6
  import { formatDuration, getDisplayName } from "#src/ui/display";
@@ -53,7 +51,7 @@ export class GetResultTool {
53
51
  const displayName = getDisplayName(record.type, this.registry);
54
52
  const duration = formatDuration(record.startedAt, record.completedAt);
55
53
  const tokens = formatLifetimeTokens(record);
56
- const contextPercent = getSessionContextPercent(record.session);
54
+ const contextPercent = record.getContextPercent();
57
55
  const statsParts = [`Tool uses: ${record.toolUses}`];
58
56
  if (tokens) statsParts.push(tokens);
59
57
  if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
@@ -80,11 +78,9 @@ export class GetResultTool {
80
78
  }
81
79
 
82
80
  // Verbose: include full conversation
83
- if (params.verbose && record.session) {
84
- const conversation = getAgentConversation(record.session);
85
- if (conversation) {
86
- output += `\n\n--- Agent Conversation ---\n${conversation}`;
87
- }
81
+ const conversation = params.verbose ? record.getConversation() : undefined;
82
+ if (conversation) {
83
+ output += `\n\n--- Agent Conversation ---\n${conversation}`;
88
84
  }
89
85
 
90
86
  return textResult(output);
@@ -10,7 +10,7 @@
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
11
  import type { AgentTypeRegistry } from "#src/config/agent-types";
12
12
  import { resolveAgentInvocationConfig } from "#src/config/invocation-config";
13
- import { normalizeMaxTurns } from "#src/lifecycle/agent-runner";
13
+ import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
14
14
  import { resolveInvocationModel } from "#src/session/model-resolver";
15
15
  import type { AgentInvocation, SubagentType, ThinkingLevel } from "#src/types";
16
16
  import {
@@ -1,6 +1,5 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { getSessionContextPercent } from "#src/lifecycle/usage";
4
3
  import { formatLifetimeTokens, textResult } from "#src/tools/helpers";
5
4
  import type { Agent } from "#src/types";
6
5
 
@@ -40,21 +39,16 @@ export class SteerTool {
40
39
  `Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`,
41
40
  );
42
41
  }
43
- const session = record.session;
44
- if (!session) {
45
- // Session not ready yet — buffer on the agent for delivery once initialized
46
- record.queueSteer(params.message);
47
- this.events.emit("subagents:steered", { id: record.id, message: params.message });
48
- return textResult(
49
- `Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`,
50
- );
51
- }
52
-
53
42
  try {
54
- await session.steer(params.message);
43
+ const delivered = await record.steer(params.message);
55
44
  this.events.emit("subagents:steered", { id: record.id, message: params.message });
45
+ if (!delivered) {
46
+ return textResult(
47
+ `Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`,
48
+ );
49
+ }
56
50
  const tokens = formatLifetimeTokens(record);
57
- const contextPercent = getSessionContextPercent(session);
51
+ const contextPercent = record.getContextPercent();
58
52
  const stateParts: string[] = [];
59
53
  if (tokens) stateParts.push(tokens);
60
54
  stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
@@ -252,8 +252,7 @@ export class AgentsMenuHandler {
252
252
  }
253
253
 
254
254
  private async viewAgentConversation(ui: MenuUI, record: Agent): Promise<void> {
255
- const session = record.session;
256
- if (!session) {
255
+ if (!record.isSessionReady()) {
257
256
  ui.notify(
258
257
  `Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`,
259
258
  "info",
@@ -270,7 +269,6 @@ export class AgentsMenuHandler {
270
269
  (tui: any, theme: any, _keybindings: any, done: any) => {
271
270
  return new ConversationViewer({
272
271
  tui,
273
- session,
274
272
  record,
275
273
  activity,
276
274
  theme,
@@ -5,10 +5,9 @@
5
5
  * Subscribes to session events for real-time streaming updates.
6
6
  */
7
7
 
8
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
9
8
  import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
9
  import type { AgentConfigLookup } from "#src/config/agent-types";
11
- import { getLifetimeTotal, getSessionContextPercent } from "#src/lifecycle/usage";
10
+ import { getLifetimeTotal } from "#src/lifecycle/usage";
12
11
  import type { Agent } from "#src/types";
13
12
  import type { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
14
13
  import { buildInvocationTags, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel, type Theme } from "#src/ui/display";
@@ -24,7 +23,6 @@ export const VIEWPORT_HEIGHT_PCT = 70;
24
23
 
25
24
  export interface ConversationViewerOptions {
26
25
  tui: TUI;
27
- session: AgentSession;
28
26
  record: Agent;
29
27
  activity: AgentActivityTracker | undefined;
30
28
  theme: Theme;
@@ -41,7 +39,6 @@ export class ConversationViewer implements Component {
41
39
  private closed = false;
42
40
 
43
41
  private tui: TUI;
44
- private session: AgentSession;
45
42
  private record: Agent;
46
43
  private activity: AgentActivityTracker | undefined;
47
44
  private theme: Theme;
@@ -51,7 +48,6 @@ export class ConversationViewer implements Component {
51
48
 
52
49
  constructor({
53
50
  tui,
54
- session,
55
51
  record,
56
52
  activity,
57
53
  theme,
@@ -60,14 +56,13 @@ export class ConversationViewer implements Component {
60
56
  wrapText,
61
57
  }: ConversationViewerOptions) {
62
58
  this.tui = tui;
63
- this.session = session;
64
59
  this.record = record;
65
60
  this.activity = activity;
66
61
  this.theme = theme;
67
62
  this.done = done;
68
63
  this.registry = registry;
69
64
  this.wrapText = wrapText;
70
- this.unsubscribe = session.subscribe(() => {
65
+ this.unsubscribe = record.subscribeToUpdates(() => {
71
66
  if (this.closed) return;
72
67
  this.tui.requestRender();
73
68
  });
@@ -142,7 +137,7 @@ export class ConversationViewer implements Component {
142
137
  if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
143
138
  const tokens = getLifetimeTotal(this.record.lifetimeUsage);
144
139
  if (tokens > 0) {
145
- const percent = getSessionContextPercent(this.record.session);
140
+ const percent = this.record.getContextPercent();
146
141
  headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
147
142
  }
148
143
 
@@ -220,7 +215,7 @@ export class ConversationViewer implements Component {
220
215
 
221
216
  const th = this.theme;
222
217
  const ctx = { theme: th, wrapText: this.wrapText };
223
- const messages = this.session.messages;
218
+ const messages = this.record.messages;
224
219
 
225
220
  if (messages.length === 0) {
226
221
  return [th.fg("dim", "(waiting for first message...)")];
@@ -229,7 +224,7 @@ export class ConversationViewer implements Component {
229
224
  const lines: string[] = [];
230
225
  let needsSeparator = false;
231
226
  for (const msg of messages) {
232
- const formatted = formatMessage(msg as unknown as { role: string; [key: string]: unknown }, width, ctx);
227
+ const formatted = formatMessage(msg as { role: string; [key: string]: unknown }, width, ctx);
233
228
  if (!formatted) continue;
234
229
  if (needsSeparator) lines.push(th.fg("dim", "───"));
235
230
  lines.push(...formatted);