@yandy0725/pi-subagents 0.1.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.
Files changed (61) hide show
  1. package/README.md +155 -0
  2. package/README.zh.md +155 -0
  3. package/index.ts +1 -0
  4. package/package.json +49 -0
  5. package/src/config/agent-types.ts +127 -0
  6. package/src/config/custom-agents.ts +109 -0
  7. package/src/config/default-agents.ts +117 -0
  8. package/src/config/invocation-config.ts +30 -0
  9. package/src/debug.ts +14 -0
  10. package/src/handlers/index.ts +3 -0
  11. package/src/handlers/interrupt.ts +49 -0
  12. package/src/handlers/lifecycle.ts +63 -0
  13. package/src/handlers/tool-start.ts +32 -0
  14. package/src/index.ts +186 -0
  15. package/src/layered-settings.ts +105 -0
  16. package/src/lifecycle/child-lifecycle.ts +88 -0
  17. package/src/lifecycle/concurrency-limiter.ts +55 -0
  18. package/src/lifecycle/create-subagent-session.ts +240 -0
  19. package/src/lifecycle/parent-snapshot.ts +45 -0
  20. package/src/lifecycle/run-listeners.ts +37 -0
  21. package/src/lifecycle/subagent-manager.ts +353 -0
  22. package/src/lifecycle/subagent-session.ts +232 -0
  23. package/src/lifecycle/subagent-state.ts +216 -0
  24. package/src/lifecycle/subagent.ts +498 -0
  25. package/src/lifecycle/turn-limits.ts +13 -0
  26. package/src/lifecycle/usage.ts +65 -0
  27. package/src/lifecycle/workspace-bracket.ts +59 -0
  28. package/src/lifecycle/workspace.ts +47 -0
  29. package/src/observation/composite-subagent-observer.ts +49 -0
  30. package/src/observation/notification-state.ts +27 -0
  31. package/src/observation/notification.ts +186 -0
  32. package/src/observation/record-observer.ts +75 -0
  33. package/src/observation/renderer.ts +63 -0
  34. package/src/observation/subagent-events-observer.ts +94 -0
  35. package/src/runtime.ts +77 -0
  36. package/src/service/service-adapter.ts +131 -0
  37. package/src/service/service.ts +123 -0
  38. package/src/session/content-items.ts +51 -0
  39. package/src/session/context.ts +78 -0
  40. package/src/session/conversation.ts +44 -0
  41. package/src/session/env.ts +40 -0
  42. package/src/session/model-resolver.ts +121 -0
  43. package/src/session/prompts.ts +83 -0
  44. package/src/session/session-config.ts +172 -0
  45. package/src/session/session-dir.ts +38 -0
  46. package/src/settings.ts +227 -0
  47. package/src/tools/agent-tool.ts +220 -0
  48. package/src/tools/background-spawner.ts +66 -0
  49. package/src/tools/foreground-runner.ts +114 -0
  50. package/src/tools/get-result-tool.ts +120 -0
  51. package/src/tools/helpers.ts +105 -0
  52. package/src/tools/result-renderer.ts +109 -0
  53. package/src/tools/spawn-config.ts +150 -0
  54. package/src/tools/steer-tool.ts +90 -0
  55. package/src/types.ts +115 -0
  56. package/src/ui/agent-widget.ts +311 -0
  57. package/src/ui/display.ts +174 -0
  58. package/src/ui/session-navigation.ts +147 -0
  59. package/src/ui/session-navigator.ts +406 -0
  60. package/src/ui/subagents-settings.ts +77 -0
  61. package/src/ui/widget-renderer.ts +296 -0
@@ -0,0 +1,353 @@
1
+ /**
2
+ * subagent-manager.ts - Tracks subagents, background execution, resume support.
3
+ *
4
+ * Background agents are subject to a configurable concurrency limit (default: 4).
5
+ * Excess agents are scheduled on a ConcurrencyLimiter and auto-started as running
6
+ * agents complete. Foreground agents bypass the limiter (they block the parent anyway).
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import type { Model } from "@earendil-works/pi-ai";
11
+ import { debugLog } from "../debug";
12
+ import type { ConcurrencyLimiter } from "../lifecycle/concurrency-limiter";
13
+ import type { CreateSubagentSessionParams } from "../lifecycle/create-subagent-session";
14
+ import type { ParentSnapshot } from "../lifecycle/parent-snapshot";
15
+ import { Subagent, type SubagentLifecycleObserver } from "../lifecycle/subagent";
16
+ import type { SubagentSession } from "../lifecycle/subagent-session";
17
+ import { SubagentState, type SubagentStatus } from "../lifecycle/subagent-state";
18
+ import type { WorkspaceProvider } from "../lifecycle/workspace";
19
+
20
+ import type { RunConfig } from "../runtime";
21
+ import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "../types";
22
+
23
+ /**
24
+ * A lightweight snapshot of a subagent evicted by the 10-minute cleanup sweep.
25
+ *
26
+ * The sweep frees the heavy in-memory session (its message history included);
27
+ * this descriptor retains only the fields the session navigator needs to label
28
+ * the agent in the picker, plus the persisted `outputFile` to source its
29
+ * transcript from disk. Carries no messages, so memory stays bounded.
30
+ */
31
+ export interface EvictedSubagent {
32
+ readonly id: string;
33
+ readonly type: SubagentType;
34
+ readonly description: string;
35
+ readonly status: SubagentStatus;
36
+ readonly startedAt: number;
37
+ readonly completedAt: number | undefined;
38
+ readonly toolUses: number;
39
+ readonly outputFile: string;
40
+ }
41
+
42
+ /** Observer interface for agent lifecycle notifications. */
43
+ export interface SubagentManagerObserver {
44
+ onSubagentStarted(record: Subagent): void;
45
+ onSubagentCompleted(record: Subagent): void;
46
+ onSubagentCompacted(record: Subagent, info: CompactionInfo): void;
47
+ /** Fires synchronously after a background agent record is created (before run). */
48
+ onSubagentCreated(record: Subagent): void;
49
+ }
50
+
51
+ export interface SubagentManagerOptions {
52
+ /** Assembly factory that produces a born-complete SubagentSession per spawn. */
53
+ createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
54
+ /** Concurrency limiter — schedules background run thunks FIFO against the limit. */
55
+ limiter: ConcurrencyLimiter;
56
+ /** Base working directory handed to a workspace provider (the parent cwd). */
57
+ baseCwd: string;
58
+ getRunConfig?: () => RunConfig;
59
+ observer?: SubagentManagerObserver;
60
+ }
61
+
62
+ export interface AgentSpawnConfig {
63
+ description: string;
64
+ model?: Model<any>;
65
+ maxTurns?: number;
66
+ inheritContext?: boolean;
67
+ thinkingLevel?: ThinkingLevel;
68
+ isBackground?: boolean;
69
+ /**
70
+ * Skip the maxConcurrent queue check for this spawn - start immediately even
71
+ * if the configured concurrency limit would otherwise queue it. Useful for
72
+ * callers (e.g. cross-extension RPC) that must not be deferred by the queue.
73
+ */
74
+ bypassQueue?: boolean;
75
+ /** Resolved invocation snapshot captured for UI display. */
76
+ invocation?: AgentInvocation;
77
+ /** Parent abort signal - when aborted, the subagent is also stopped. */
78
+ signal?: AbortSignal;
79
+ /** Per-subagent lifecycle observer — replaces onSessionCreated callback. */
80
+ observer?: SubagentLifecycleObserver;
81
+ /** Parent session identity - grouped fields that travel together from the tool boundary. */
82
+ parentSession?: ParentSessionInfo;
83
+ }
84
+
85
+ export class SubagentManager {
86
+ private agents = new Map<string, Subagent>();
87
+ /** Descriptors of agents removed by the cleanup sweep, keyed by id — navigable from disk. */
88
+ private readonly evicted = new Map<string, EvictedSubagent>();
89
+ private cleanupInterval: ReturnType<typeof setInterval>;
90
+ private readonly observer?: SubagentManagerObserver;
91
+ private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
92
+ private readonly limiter: ConcurrencyLimiter;
93
+ private readonly baseCwd: string;
94
+ private getRunConfig?: () => RunConfig;
95
+ private _workspaceProvider?: WorkspaceProvider;
96
+
97
+ /** The registered workspace provider, or undefined when none is registered. */
98
+ get workspaceProvider(): WorkspaceProvider | undefined {
99
+ return this._workspaceProvider;
100
+ }
101
+
102
+ constructor(options: SubagentManagerOptions) {
103
+ this.createSubagentSession = options.createSubagentSession;
104
+ this.limiter = options.limiter;
105
+ this.baseCwd = options.baseCwd;
106
+ this.observer = options.observer;
107
+ this.getRunConfig = options.getRunConfig;
108
+ // Cleanup completed agents after 10 minutes (but keep sessions for resume)
109
+ this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
110
+ this.cleanupInterval.unref();
111
+ }
112
+
113
+ /**
114
+ * Register the single workspace provider. Throws if one is already
115
+ * registered (chaining is out of scope — see ADR 0002). Returns a disposer
116
+ * that clears the slot only if this provider is still the active one.
117
+ */
118
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
119
+ if (this._workspaceProvider) {
120
+ throw new Error("A WorkspaceProvider is already registered; only one is supported.");
121
+ }
122
+ this._workspaceProvider = provider;
123
+ return () => {
124
+ if (this._workspaceProvider === provider) this._workspaceProvider = undefined;
125
+ };
126
+ }
127
+
128
+ /** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
129
+ private buildObserver(options: AgentSpawnConfig): SubagentLifecycleObserver {
130
+ return {
131
+ onStarted: (agent) => {
132
+ this.observer?.onSubagentStarted(agent);
133
+ },
134
+ onSessionCreated: options.observer?.onSessionCreated
135
+ ? (agent) => options.observer!.onSessionCreated!(agent)
136
+ : undefined,
137
+ onRunFinished: (agent) => {
138
+ if (options.isBackground) {
139
+ try {
140
+ this.observer?.onSubagentCompleted(agent);
141
+ } catch (err) {
142
+ debugLog("onSubagentCompleted observer", err);
143
+ }
144
+ }
145
+ },
146
+ onCompacted: (agent, info) => {
147
+ this.observer?.onSubagentCompacted(agent, info);
148
+ },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Spawn an agent and return its ID immediately (for background use).
154
+ * If the concurrency limit is reached, the agent is queued.
155
+ */
156
+ spawn(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: AgentSpawnConfig): string {
157
+ const id = randomUUID().slice(0, 17);
158
+ const record = new Subagent({
159
+ id,
160
+ type,
161
+ description: options.description,
162
+ invocation: options.invocation,
163
+ state: new SubagentState({
164
+ status: options.isBackground ? "queued" : "running",
165
+ startedAt: Date.now(),
166
+ }),
167
+ execution: {
168
+ createSubagentSession: this.createSubagentSession,
169
+ snapshot,
170
+ prompt,
171
+ baseCwd: this.baseCwd,
172
+ observer: this.buildObserver(options),
173
+ getRunConfig: this.getRunConfig,
174
+ getWorkspaceProvider: () => this._workspaceProvider,
175
+ model: options.model,
176
+ maxTurns: options.maxTurns,
177
+ thinkingLevel: options.thinkingLevel,
178
+ parentSession: options.parentSession,
179
+ signal: options.signal,
180
+ },
181
+ });
182
+ this.agents.set(id, record);
183
+
184
+ if (options.isBackground) {
185
+ this.observer?.onSubagentCreated(record);
186
+ }
187
+
188
+ if (options.isBackground && !options.bypassQueue) {
189
+ // Schedule on the limiter — scheduleVia captures the limiter promise
190
+ // eagerly, so a queued agent is awaitable from spawn; guardedRun guards
191
+ // against abort-while-queued when the slot frees.
192
+ record.scheduleVia((thunk) => this.limiter.schedule(thunk));
193
+ return id;
194
+ }
195
+
196
+ record.start();
197
+ return id;
198
+ }
199
+
200
+ /**
201
+ * Spawn an agent and wait for completion (foreground use).
202
+ * Foreground agents bypass the concurrency queue.
203
+ */
204
+ async spawnAndWait(
205
+ snapshot: ParentSnapshot,
206
+ type: SubagentType,
207
+ prompt: string,
208
+ options: Omit<AgentSpawnConfig, "isBackground">,
209
+ ): Promise<Subagent> {
210
+ const id = this.spawn(snapshot, type, prompt, { ...options, isBackground: false });
211
+ const record = this.agents.get(id)!;
212
+ await record.promise;
213
+ return record;
214
+ }
215
+
216
+ /**
217
+ * Resume an existing agent session with a new prompt.
218
+ * Delegates to Subagent.resume(), which owns the observer subscription lifecycle.
219
+ */
220
+ async resume(id: string, prompt: string, signal?: AbortSignal): Promise<Subagent | undefined> {
221
+ const agent = this.agents.get(id);
222
+ if (!agent?.isSessionReady()) return undefined;
223
+ await agent.resume(prompt, signal);
224
+ return agent;
225
+ }
226
+
227
+ getRecord(id: string): Subagent | undefined {
228
+ return this.agents.get(id);
229
+ }
230
+
231
+ listAgents(): Subagent[] {
232
+ return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
233
+ }
234
+
235
+ /** Descriptors of agents evicted by the cleanup sweep, most recent first. */
236
+ listEvicted(): EvictedSubagent[] {
237
+ return [...this.evicted.values()].sort((a, b) => b.startedAt - a.startedAt);
238
+ }
239
+
240
+ abort(id: string): boolean {
241
+ const record = this.agents.get(id);
242
+ if (!record) return false;
243
+
244
+ // A queued agent has not started; mark it stopped. Its scheduled thunk
245
+ // becomes a no-op (status guard) when its slot finally opens.
246
+ if (record.status === "queued") {
247
+ record.markStopped();
248
+ return true;
249
+ }
250
+
251
+ return record.abort();
252
+ }
253
+
254
+ /** Dispose a record's session and remove it from the map. */
255
+ private removeRecord(id: string, record: Subagent): void {
256
+ record.disposeSession();
257
+ this.agents.delete(id);
258
+ }
259
+
260
+ private cleanup() {
261
+ const cutoff = Date.now() - 10 * 60_000;
262
+ for (const [id, record] of this.agents) {
263
+ if (record.status === "running" || record.status === "queued") continue;
264
+ if ((record.completedAt ?? 0) >= cutoff) continue;
265
+ // Retain a navigable descriptor before freeing the heavy session. Only an
266
+ // agent with a persisted file can be sourced from disk after eviction.
267
+ if (record.outputFile) this.evicted.set(id, toEvictedSubagent(record, record.outputFile));
268
+ this.removeRecord(id, record);
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Remove all completed/stopped/errored records immediately.
274
+ * Called on session start/switch so tasks from a prior session don't persist.
275
+ */
276
+ clearCompleted(): void {
277
+ for (const [id, record] of this.agents) {
278
+ if (record.status === "running" || record.status === "queued") continue;
279
+ this.removeRecord(id, record);
280
+ }
281
+ // Evicted descriptors belong to the session that swept them — a new session starts empty.
282
+ this.evicted.clear();
283
+ }
284
+
285
+ /** Whether any agents are still running or queued. */
286
+ // fallow-ignore-next-line unused-class-member
287
+ hasRunning(): boolean {
288
+ return [...this.agents.values()].some((r) => r.status === "running" || r.status === "queued");
289
+ }
290
+
291
+ /** Abort all running and queued agents immediately. */
292
+ // fallow-ignore-next-line unused-class-member
293
+ abortAll(): number {
294
+ let count = 0;
295
+ for (const record of this.agents.values()) {
296
+ if (record.status === "queued") {
297
+ record.markStopped();
298
+ count++;
299
+ } else if (record.abort()) {
300
+ count++;
301
+ }
302
+ }
303
+ // Drop pending thunks (their promises resolve).
304
+ this.limiter.clear();
305
+ return count;
306
+ }
307
+
308
+ /** Wait for all running and queued agents to complete (including queued ones). */
309
+ // fallow-ignore-next-line unused-class-member
310
+ async waitForAll(): Promise<void> {
311
+ // Every spawned agent has a settled-on-completion promise (the limiter starts
312
+ // queued ones as slots free), so a single allSettled covers the queued case.
313
+ // The loop only catches agents spawned during the wait.
314
+ let pending = this.pendingPromises();
315
+ while (pending.length > 0) {
316
+ await Promise.allSettled(pending);
317
+ pending = this.pendingPromises();
318
+ }
319
+ }
320
+
321
+ /** Promises of all running/queued agents that have one. */
322
+ private pendingPromises(): Promise<void>[] {
323
+ return [...this.agents.values()]
324
+ .filter((r) => r.status === "running" || r.status === "queued")
325
+ .map((r) => r.promise)
326
+ .filter((p): p is Promise<void> => p != null);
327
+ }
328
+
329
+ dispose() {
330
+ clearInterval(this.cleanupInterval);
331
+ // Drop pending thunks
332
+ this.limiter.clear();
333
+ for (const record of this.agents.values()) {
334
+ record.disposeSession();
335
+ }
336
+ this.agents.clear();
337
+ this.evicted.clear();
338
+ }
339
+ }
340
+
341
+ /** Capture an evicted agent's navigable fields from its record. */
342
+ function toEvictedSubagent(record: Subagent, outputFile: string): EvictedSubagent {
343
+ return {
344
+ id: record.id,
345
+ type: record.type,
346
+ description: record.description,
347
+ status: record.status,
348
+ startedAt: record.startedAt,
349
+ completedAt: record.completedAt,
350
+ toolUses: record.toolUses,
351
+ outputFile,
352
+ };
353
+ }
@@ -0,0 +1,232 @@
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 `Subagent` 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 `Subagent` (Law of Demeter).
11
+ */
12
+
13
+ import type { AgentSession, AgentSessionEvent, ToolDefinition } from "@earendil-works/pi-coding-agent";
14
+ import type { ChildLifecyclePublisher } from "../lifecycle/child-lifecycle";
15
+ import { normalizeMaxTurns } from "../lifecycle/turn-limits";
16
+ import { getSessionContextPercent, type SessionStatsLike } from "../lifecycle/usage";
17
+ import { extractText } from "../session/context";
18
+ import { getAgentConversation } from "../session/conversation";
19
+ import type { SessionMessage } from "../types";
20
+
21
+ /** Outcome of one turn loop. */
22
+ export interface TurnLoopResult {
23
+ responseText: string;
24
+ /** True if the agent was hard-aborted (max turns + grace exceeded). */
25
+ aborted: boolean;
26
+ /** True if the agent was steered to wrap up (soft turn limit) but finished in time. */
27
+ steered: boolean;
28
+ }
29
+
30
+ /** Per-call options for the initial run's turn loop. */
31
+ export interface TurnLoopOptions {
32
+ /** Per-call max-turns override — highest precedence. */
33
+ maxTurns?: number;
34
+ /** Runtime-config fallback when neither per-call nor per-agent limit is set. */
35
+ defaultMaxTurns?: number;
36
+ /** Grace turns after the soft-limit steer message before a hard abort. */
37
+ graceTurns?: number;
38
+ signal?: AbortSignal;
39
+ }
40
+
41
+ /** Session-level facts known at creation, supplied by the factory. */
42
+ export interface SubagentSessionMeta {
43
+ /** Path to the persisted session JSONL file, if the session was persisted. */
44
+ outputFile: string | undefined;
45
+ /** Child session id — the registry key carried on session-created/disposed events. */
46
+ sessionId: string;
47
+ /** Child session directory — carried on the completed event as transcript location. */
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(opts.maxTurns ?? this.meta.agentMaxTurns ?? opts.defaultMaxTurns);
85
+ let softLimitReached = false;
86
+ let aborted = false;
87
+
88
+ const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
89
+ if (event.type === "turn_end") {
90
+ turnCount++;
91
+ if (maxTurns != null) {
92
+ if (!softLimitReached && turnCount >= maxTurns) {
93
+ softLimitReached = true;
94
+ void session.steer("You have reached your turn limit. Wrap up immediately - provide your final answer now.");
95
+ } else if (softLimitReached && turnCount >= maxTurns + (opts.graceTurns ?? 5)) {
96
+ aborted = true;
97
+ void session.abort();
98
+ }
99
+ }
100
+ }
101
+ });
102
+
103
+ const collector = collectResponseText(session);
104
+ const cleanupAbort = forwardAbortSignal(session, opts.signal);
105
+
106
+ // Prepend parent context if it was captured at spawn time.
107
+ const effectivePrompt = this.meta.parentContext ? this.meta.parentContext + prompt : prompt;
108
+
109
+ try {
110
+ await session.prompt(effectivePrompt);
111
+ this.meta.lifecycle.completed({
112
+ sessionDir: this.meta.sessionDir,
113
+ agentName: this.meta.agentName,
114
+ aborted,
115
+ steered: softLimitReached,
116
+ });
117
+ } finally {
118
+ unsubTurns();
119
+ collector.unsubscribe();
120
+ cleanupAbort();
121
+ }
122
+
123
+ const responseText = collector.getText().trim() || getLastAssistantText(session);
124
+ return { responseText, aborted, steered: softLimitReached };
125
+ }
126
+
127
+ /** Re-prompt the same session (resume); does not emit `completed`. */
128
+ async resumeTurnLoop(prompt: string, signal?: AbortSignal): Promise<string> {
129
+ const session = this._session;
130
+ const collector = collectResponseText(session);
131
+ const cleanupAbort = forwardAbortSignal(session, signal);
132
+
133
+ try {
134
+ await session.prompt(prompt);
135
+ } finally {
136
+ collector.unsubscribe();
137
+ cleanupAbort();
138
+ }
139
+
140
+ return collector.getText().trim() || getLastAssistantText(session);
141
+ }
142
+
143
+ /** Deliver a steer to the live session. */
144
+ async steer(message: string): Promise<void> {
145
+ await this._session.steer(message);
146
+ }
147
+
148
+ /** Return the session's conversation as formatted text. */
149
+ getConversation(): string {
150
+ return getAgentConversation(this._session);
151
+ }
152
+
153
+ /** Return the session context window utilization (0-100), or null when unavailable. */
154
+ getContextPercent(): number | null {
155
+ return getSessionContextPercent(this._session);
156
+ }
157
+
158
+ /** Subscribe to session events. Satisfies `SubscribableSession`. */
159
+ subscribe(fn: (event: AgentSessionEvent) => void): () => void {
160
+ return this._session.subscribe(fn);
161
+ }
162
+
163
+ /** Return session token statistics. Satisfies `SessionLike`. */
164
+ getSessionStats(): SessionStatsLike {
165
+ return this._session.getSessionStats();
166
+ }
167
+
168
+ /** The session's message history. */
169
+ get messages(): readonly unknown[] {
170
+ return this._session.messages as readonly unknown[];
171
+ }
172
+
173
+ /** The session's message history, typed for Pi's session-rendering machinery. */
174
+ get agentMessages(): readonly SessionMessage[] {
175
+ return this._session.messages;
176
+ }
177
+
178
+ /** Resolve a registered tool definition by name, for Pi's tool-execution components. */
179
+ getToolDefinition(name: string): ToolDefinition | undefined {
180
+ return this._session.getToolDefinition(name);
181
+ }
182
+
183
+ /** Tear down: session.dispose() + emit `disposed` (registry unregister). */
184
+ dispose(): void {
185
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
186
+ this._session.dispose?.();
187
+ this.meta.lifecycle.disposed({ sessionId: this.meta.sessionId });
188
+ }
189
+ }
190
+
191
+ // ── Private turn-loop helpers ───────────────────────────────────────────────────
192
+
193
+ /**
194
+ * Subscribe to a session and collect the last assistant message text.
195
+ * Returns an object with a `getText()` getter and an `unsubscribe` function.
196
+ */
197
+ function collectResponseText(session: AgentSession) {
198
+ let text = "";
199
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
200
+ if (event.type === "message_start") {
201
+ text = "";
202
+ }
203
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
204
+ text += event.assistantMessageEvent.delta;
205
+ }
206
+ });
207
+ return { getText: () => text, unsubscribe };
208
+ }
209
+
210
+ /** Get the last assistant text from the completed session history. */
211
+ function getLastAssistantText(session: AgentSession): string {
212
+ for (let i = session.messages.length - 1; i >= 0; i--) {
213
+ const msg = session.messages[i];
214
+ if (msg.role !== "assistant") continue;
215
+ const text = extractText(msg.content).trim();
216
+ if (text) return text;
217
+ }
218
+ return "";
219
+ }
220
+
221
+ /**
222
+ * Wire an AbortSignal to abort a session.
223
+ * Returns a cleanup function to remove the listener.
224
+ */
225
+ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
226
+ if (!signal) return () => {};
227
+ const onAbort = (): void => {
228
+ void session.abort();
229
+ };
230
+ signal.addEventListener("abort", onAbort, { once: true });
231
+ return () => signal.removeEventListener("abort", onAbort);
232
+ }