@nklisch/pi-enhanced 0.2.7 → 0.3.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-x64-gnu.node +0 -0
  3. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  4. package/node_modules/@nklisch/pi-plugins/README.md +4 -2
  5. package/node_modules/@nklisch/pi-plugins/dist/hooks.js +81 -8
  6. package/node_modules/@nklisch/pi-plugins/dist/hooks.js.map +1 -1
  7. package/node_modules/@nklisch/pi-plugins/dist/mcp.js +32 -1
  8. package/node_modules/@nklisch/pi-plugins/dist/mcp.js.map +1 -1
  9. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +10 -4
  10. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +144 -10
  11. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
  12. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +40 -14
  13. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  14. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +27 -0
  15. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +76 -47
  16. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/public.d.ts +171 -140
  17. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/settings.d.ts +2 -2
  18. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/FORK-MAINTENANCE.md +8 -7
  19. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/VISION.md +12 -10
  20. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +118 -104
  21. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/comparison-with-upstream.md +3 -3
  22. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0004-reconsider-ui-direction.md +5 -0
  23. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  24. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +31 -4
  25. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/default-agents.ts +1 -1
  26. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/invocation-config.ts +7 -4
  27. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/index.ts +18 -9
  28. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/layered-settings.ts +2 -2
  29. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +10 -63
  30. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +122 -76
  31. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +3 -5
  32. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +4 -3
  33. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +2 -23
  34. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +177 -252
  35. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +90 -280
  36. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-state.ts +153 -239
  37. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +569 -700
  38. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace.ts +1 -1
  39. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/notification.ts +78 -122
  40. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/renderer.ts +15 -42
  41. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +44 -95
  42. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service-adapter.ts +157 -90
  43. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service.ts +60 -68
  44. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/conversation.ts +3 -3
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query-source.ts +28 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query.ts +488 -0
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/settings.ts +1 -1
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/agent-tool.ts +171 -249
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-tool.ts +51 -94
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/helpers.ts +9 -6
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/list-tool.ts +46 -0
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/parent-tool-registry.ts +17 -0
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/query-session-tool.ts +412 -0
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/result-renderer.ts +27 -111
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/resume-tool.ts +105 -0
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/spawn-config.ts +26 -8
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/steer-tool.ts +27 -111
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/stop-tool.ts +53 -0
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/types.ts +11 -5
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +33 -27
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/display.ts +6 -3
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigation.ts +121 -10
  63. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +430 -112
  64. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/subagents-settings.ts +1 -1
  65. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/widget-renderer.ts +8 -9
  66. package/node_modules/@nklisch/pi-plugins/package.json +2 -2
  67. package/package.json +1 -1
  68. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/background-spawner.ts +0 -81
  69. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +0 -149
  70. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-report.ts +0 -77
@@ -1,14 +1,4 @@
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
- */
1
+ /** Born-complete child session: turn driving, steering, and teardown. */
12
2
 
13
3
  import {
14
4
  type AgentSession,
@@ -18,9 +8,9 @@ import {
18
8
  import type { Model } from "@earendil-works/pi-ai";
19
9
  import { debugLog, runDetached, runSafely } from "#src/debug";
20
10
  import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
21
- import {
22
- type SubagentLifecycleOutcome,
23
- type SubagentTurnLifecycle,
11
+ import type {
12
+ SubagentLifecycleOutcome,
13
+ SubagentTurnLifecycle,
24
14
  } from "#src/lifecycle/lifecycle-interceptor";
25
15
  import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
26
16
  import { getSessionContextPercent, type SessionStatsLike } from "#src/lifecycle/usage";
@@ -28,51 +18,34 @@ import { extractText } from "#src/session/context";
28
18
  import { getAgentConversation } from "#src/session/conversation";
29
19
  import type { SessionMessage, ThinkingLevel } from "#src/types";
30
20
 
31
- /** Outcome of one turn loop. */
21
+ export type TurnLimitReason = "turn_limit_graceful" | "turn_limit_hard";
22
+
32
23
  export interface TurnLoopResult {
33
24
  responseText: string;
34
- /** Normally-resolved provider failure from the final assistant turn. */
25
+ /** Provider failure reported by a normally-resolved assistant turn. */
35
26
  failure?: string;
36
- /** True if the agent was hard-aborted (max turns + grace exceeded). */
37
- aborted: boolean;
38
- /** True if the agent was steered to wrap up (soft turn limit) but finished in time. */
39
- steered: boolean;
40
- /** A lifecycle provider denied finalization; no child completion is emitted. */
41
- lifecycleAborted?: boolean;
27
+ /** Turn-limit or lifecycle result owned by the session loop. */
28
+ terminalReason?: TurnLimitReason | "lifecycle_abort";
42
29
  }
43
30
 
44
- /** Per-call options for the initial run's turn loop. */
45
31
  export interface TurnLoopOptions {
46
- /** Per-call max-turns override — highest precedence. */
47
32
  maxTurns?: number;
48
- /** Runtime-config fallback when neither per-call nor per-agent limit is set. */
49
33
  defaultMaxTurns?: number;
50
- /** Grace turns after the soft-limit steer message before a hard abort. */
51
34
  graceTurns?: number;
52
35
  signal?: AbortSignal;
53
- /** Present only for an execution that captured active lifecycle providers. */
54
36
  lifecycle?: SubagentTurnLifecycle;
55
37
  }
56
38
 
57
- /** Session-level facts known at creation, supplied by the factory. */
58
39
  export interface SubagentSessionMeta {
59
- /** Path to the persisted session JSONL file, if the session was persisted. */
60
40
  outputFile: string | undefined;
61
- /** Child session id — the registry key carried on session-created/disposed events. */
62
41
  sessionId: string;
63
- /** Child session directory — carried on the completed event as transcript location. */
64
42
  sessionDir: string;
65
43
  agentName: string;
66
- /** Per-agent max-turns from the resolved agent config — middle precedence. */
67
44
  agentMaxTurns: number | undefined;
68
- /** Parent context prepended to the run prompt, captured at spawn time. */
69
45
  parentContext: string | undefined;
70
46
  lifecycle: ChildLifecyclePublisher;
71
47
  }
72
48
 
73
- /**
74
- * One child AgentSession plus its turn-driving and teardown — born complete.
75
- */
76
49
  export class SubagentSession {
77
50
  private disposalPromise?: Promise<void>;
78
51
 
@@ -81,62 +54,32 @@ export class SubagentSession {
81
54
  private readonly meta: SubagentSessionMeta,
82
55
  ) {}
83
56
 
84
- /**
85
- * Wrapped session for lifecycle-internal use only.
86
- * @internal consumers outside lifecycle/ use the delegate methods below.
87
- */
88
- get session(): AgentSession {
89
- return this._session;
90
- }
91
-
92
- get outputFile(): string | undefined {
93
- return this.meta.outputFile;
94
- }
95
-
96
- /** Stable child session identity for lifecycle provider metadata. */
97
- get sessionId(): string {
98
- return this.meta.sessionId;
99
- }
57
+ /** @internal lifecycle-only access to SDK teardown hooks. */
58
+ get session(): AgentSession { return this._session; }
59
+ get outputFile(): string | undefined { return this.meta.outputFile; }
60
+ get sessionId(): string { return this.meta.sessionId; }
61
+ get model(): Model<any> | undefined { return this._session.model; }
62
+ get thinkingLevel(): ThinkingLevel { return this._session.thinkingLevel; }
100
63
 
101
- /** Current model selected by the child session. */
102
- get model(): Model<any> | undefined {
103
- return this._session.model;
104
- }
105
-
106
- /** Effective level after the child session applied defaults and clamping. */
107
- get thinkingLevel(): ThinkingLevel {
108
- return this._session.thinkingLevel;
109
- }
110
-
111
- /** Drive the initial run's turn loop; emits `completed` on accepted success. */
112
64
  async runTurnLoop(prompt: string, opts: TurnLoopOptions): Promise<TurnLoopResult> {
113
65
  const session = this._session;
114
-
115
- // Track turns for graceful max_turns enforcement.
116
66
  let turnCount = 0;
117
- const maxTurns = normalizeMaxTurns(
118
- opts.maxTurns ?? this.meta.agentMaxTurns ?? opts.defaultMaxTurns,
119
- );
67
+ const maxTurns = normalizeMaxTurns(opts.maxTurns ?? this.meta.agentMaxTurns ?? opts.defaultMaxTurns);
120
68
  let softLimitReached = false;
121
- let aborted = false;
69
+ let turnLimitReason: TurnLimitReason | undefined;
122
70
 
123
71
  const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
124
72
  runSafely("subagent turn-limit observer", () => {
125
- if (event.type === "turn_end") {
126
- turnCount++;
127
- if (maxTurns != null) {
128
- if (!softLimitReached && turnCount >= maxTurns) {
129
- softLimitReached = true;
130
- runDetached("turn-limit steer", () =>
131
- session.steer(
132
- "You have reached your turn limit. Wrap up immediately - provide your final answer now.",
133
- ),
134
- );
135
- } else if (softLimitReached && turnCount >= maxTurns + (opts.graceTurns ?? 5)) {
136
- aborted = true;
137
- runDetached("turn-limit abort", () => session.abort());
138
- }
139
- }
73
+ if (event.type !== "turn_end" || maxTurns == null) return;
74
+ turnCount++;
75
+ if (!softLimitReached && turnCount >= maxTurns) {
76
+ softLimitReached = true;
77
+ runDetached("turn-limit steer", () => session.steer(
78
+ "You have reached your turn limit. Wrap up immediately - provide your final answer now.",
79
+ ));
80
+ } else if (softLimitReached && turnCount >= maxTurns + (opts.graceTurns ?? 5)) {
81
+ turnLimitReason = "turn_limit_hard";
82
+ runDetached("turn-limit abort", () => session.abort());
140
83
  }
141
84
  });
142
85
  });
@@ -144,33 +87,26 @@ export class SubagentSession {
144
87
  const startIndex = session.messages.length;
145
88
  const collector = collectResponseText(session);
146
89
  const cleanupAbort = forwardAbortSignal(session, opts.signal);
147
-
148
- // Prepend parent context before lifecycle providers see the exact prompt.
149
- const effectivePrompt = this.meta.parentContext
150
- ? this.meta.parentContext + prompt
151
- : prompt;
90
+ const effectivePrompt = this.meta.parentContext ? this.meta.parentContext + prompt : prompt;
152
91
 
153
92
  try {
154
- if (!opts.lifecycle) {
155
- // Preserve the released no-provider order byte-for-byte: completion is
156
- // published immediately after prompt resolution and before extraction.
157
- await session.prompt(effectivePrompt);
158
- this.publishCompleted(aborted, softLimitReached);
159
- const responseText = collector.getText().trim() || getLastAssistantText(session, startIndex);
160
- return {
161
- responseText,
162
- failure: finalTurnError(session, startIndex),
163
- aborted,
164
- steered: softLimitReached,
165
- };
93
+ if (opts.lifecycle) {
94
+ return await this.driveLifecycleTurns(
95
+ effectivePrompt,
96
+ opts.lifecycle,
97
+ () => collector.getText().trim() || getLastAssistantText(session, startIndex),
98
+ () => finalTurnError(session, startIndex),
99
+ () => turnLimitReason,
100
+ () => softLimitReached,
101
+ );
166
102
  }
167
- return await this.driveLifecycleTurns(
168
- effectivePrompt,
169
- opts.lifecycle,
170
- () => collector.getText().trim() || getLastAssistantText(session, startIndex),
171
- () => finalTurnError(session, startIndex),
172
- () => ({ aborted, steered: softLimitReached }),
173
- );
103
+
104
+ await session.prompt(effectivePrompt);
105
+ const responseText = collector.getText().trim() || getLastAssistantText(session, startIndex);
106
+ const failure = finalTurnError(session, startIndex);
107
+ const terminalReason = failure ? undefined : turnLimitReason ?? (softLimitReached ? "turn_limit_graceful" : undefined);
108
+ if (!failure) this.publishCompleted(terminalReason ?? "completed");
109
+ return { responseText, failure, ...(terminalReason ? { terminalReason } : {}) };
174
110
  } finally {
175
111
  releaseTurnLoopHandles([
176
112
  ["subagent turn-limit unsubscribe", unsubTurns],
@@ -180,22 +116,16 @@ export class SubagentSession {
180
116
  }
181
117
  }
182
118
 
183
- /** Re-prompt the same session (resume); preserves the released no-provider path. */
184
- async resumeTurnLoop(
185
- prompt: string,
186
- signal?: AbortSignal,
187
- ): Promise<{ text: string; failure?: string }> {
188
- const session = this._session;
189
- const startIndex = session.messages.length;
190
- const collector = collectResponseText(session);
191
- const cleanupAbort = forwardAbortSignal(session, signal);
192
-
119
+ async resumeTurnLoop(prompt: string, signal?: AbortSignal): Promise<{ text: string; failure?: string; terminalReason?: TurnLimitReason }> {
120
+ const startIndex = this._session.messages.length;
121
+ const collector = collectResponseText(this._session);
122
+ const cleanupAbort = forwardAbortSignal(this._session, signal);
193
123
  try {
194
- await session.prompt(prompt);
195
- return {
196
- text: collector.getText().trim() || getLastAssistantText(session, startIndex),
197
- failure: finalTurnError(session, startIndex),
198
- };
124
+ await this._session.prompt(prompt);
125
+ const text = collector.getText().trim() || getLastAssistantText(this._session, startIndex);
126
+ const failure = finalTurnError(this._session, startIndex);
127
+ if (!failure) this.publishCompleted("completed");
128
+ return { text, failure };
199
129
  } finally {
200
130
  releaseTurnLoopHandles([
201
131
  ["subagent response collector unsubscribe", collector.unsubscribe],
@@ -204,10 +134,6 @@ export class SubagentSession {
204
134
  }
205
135
  }
206
136
 
207
- /**
208
- * Resume with active providers. This is separate from resumeTurnLoop so the
209
- * historical no-provider method signature and event behavior remain intact.
210
- */
211
137
  async resumeLifecycleTurnLoop(
212
138
  prompt: string,
213
139
  signal: AbortSignal | undefined,
@@ -222,7 +148,8 @@ export class SubagentSession {
222
148
  lifecycle,
223
149
  () => collector.getText().trim() || getLastAssistantText(this._session, startIndex),
224
150
  () => finalTurnError(this._session, startIndex),
225
- () => ({ aborted: false, steered: false }),
151
+ () => undefined,
152
+ () => false,
226
153
  );
227
154
  } finally {
228
155
  releaseTurnLoopHandles([
@@ -237,38 +164,35 @@ export class SubagentSession {
237
164
  lifecycle: SubagentTurnLifecycle,
238
165
  responseText: () => string,
239
166
  failure: () => string | undefined,
240
- outcome: () => Readonly<{ aborted: boolean; steered: boolean }>,
167
+ turnLimitReason: () => TurnLimitReason | undefined,
168
+ softLimitReached: () => boolean,
241
169
  ): Promise<TurnLoopResult> {
242
170
  const start = await lifecycle.beforeStart(initialPrompt);
243
- if (start?.action === "abort") {
244
- return this.lifecycleAbort(start.reason, outcome());
245
- }
171
+ if (start?.action === "abort") return this.lifecycleAbort(start.reason);
246
172
 
247
173
  let prompt = start?.prompt ?? initialPrompt;
248
174
  let continuationRound = 0;
249
175
  for (;;) {
250
176
  lifecycle.signal.throwIfAborted();
251
177
  await this._session.prompt(prompt);
252
- const turnOutcome = outcome();
253
178
  const proposedResult = responseText();
254
179
  const turnFailure = failure();
255
180
  if (turnFailure) {
256
- return { responseText: proposedResult, failure: turnFailure, ...turnOutcome };
181
+ // Provider failure is a record terminal reason, not a lifecycle outcome;
182
+ // do not publish a misleading child-completed event.
183
+ return { responseText: proposedResult, failure: turnFailure };
257
184
  }
185
+
186
+ const turnReason = turnLimitReason() ?? (softLimitReached() ? "turn_limit_graceful" : undefined);
258
187
  const completion = await lifecycle.beforeComplete(
259
188
  proposedResult,
260
- toLifecycleOutcome(turnOutcome),
189
+ turnReason === "turn_limit_hard" ? "turn_limit_hard" : turnReason === "turn_limit_graceful" ? "turn_limit_graceful" : "completed",
261
190
  continuationRound,
262
191
  );
263
- if (completion?.action === "abort") {
264
- return this.lifecycleAbort(completion.reason, turnOutcome);
265
- }
192
+ if (completion?.action === "abort") return this.lifecycleAbort(completion.reason);
266
193
  if (completion?.action === "continue") {
267
194
  if (continuationRound >= lifecycle.maxContinuationRounds) {
268
- return this.lifecycleAbort(
269
- `Lifecycle continuation limit of ${lifecycle.maxContinuationRounds} reached`,
270
- turnOutcome,
271
- );
195
+ return this.lifecycleAbort(`Lifecycle continuation limit of ${lifecycle.maxContinuationRounds} reached`);
272
196
  }
273
197
  continuationRound++;
274
198
  prompt = completion.prompt;
@@ -278,51 +202,28 @@ export class SubagentSession {
278
202
  const finalResult = completion?.action === "complete"
279
203
  ? completion.result ?? proposedResult
280
204
  : proposedResult;
281
- this.publishCompleted(turnOutcome.aborted, turnOutcome.steered);
282
- return {
283
- responseText: finalResult,
284
- aborted: turnOutcome.aborted,
285
- steered: turnOutcome.steered,
286
- };
205
+ this.publishCompleted(turnReason ?? "completed");
206
+ return { responseText: finalResult, ...(turnReason ? { terminalReason: turnReason } : {}) };
287
207
  }
288
208
  }
289
209
 
290
- private lifecycleAbort(
291
- reason: string,
292
- outcome: Readonly<{ aborted: boolean; steered: boolean }>,
293
- ): TurnLoopResult {
294
- // No child completion is emitted: the provider declined to accept a turn.
295
- return {
296
- responseText: reason,
297
- aborted: true,
298
- steered: outcome.steered,
299
- lifecycleAborted: true,
300
- };
210
+ private lifecycleAbort(reason: string): TurnLoopResult {
211
+ return { responseText: reason, terminalReason: "lifecycle_abort" };
301
212
  }
302
213
 
303
- private publishCompleted(aborted: boolean, steered: boolean): void {
214
+ private publishCompleted(terminalReason: "completed" | TurnLimitReason): void {
304
215
  this.meta.lifecycle.completed({
305
216
  sessionDir: this.meta.sessionDir,
306
217
  agentName: this.meta.agentName,
307
- aborted,
308
- steered,
218
+ terminalReason,
309
219
  });
310
220
  }
311
221
 
312
- /** Whether Pi has fully settled the child, including post-run continuation. */
313
- get isIdle(): boolean {
314
- return this._session.isIdle;
315
- }
222
+ get isIdle(): boolean { return this._session.isIdle; }
316
223
 
317
- /**
318
- * Wait for Pi's authoritative idle boundary before starting another prompt.
319
- * A record can already look terminal while AgentSession is still finishing an
320
- * extension-driven continuation, so domain status alone is insufficient.
321
- */
322
224
  async waitUntilIdle(signal?: AbortSignal): Promise<void> {
323
225
  if (this._session.isIdle) return;
324
226
  signal?.throwIfAborted();
325
-
326
227
  await new Promise<void>((resolve, reject) => {
327
228
  let settled = false;
328
229
  let unsubscribe: () => void = () => {};
@@ -342,67 +243,24 @@ export class SubagentSession {
342
243
  cleanup();
343
244
  reject(signal?.reason ?? new Error("Resume wait aborted"));
344
245
  };
345
-
346
246
  unsubscribe = this._session.subscribe((event: AgentSessionEvent) => {
347
247
  if (event.type === "agent_settled" || this._session.isIdle) finish();
348
248
  });
349
249
  signal?.addEventListener("abort", onAbort, { once: true });
350
-
351
- // Close the check/subscribe race: Pi may have settled between the first
352
- // isIdle read and listener registration.
353
250
  if (this._session.isIdle) finish();
354
251
  else if (signal?.aborted) onAbort();
355
252
  });
356
253
  }
357
254
 
358
- /** Deliver a steer to the live session. */
359
- async steer(message: string): Promise<void> {
360
- await this._session.steer(message);
361
- }
362
-
363
- /** Return the session's conversation as formatted text. */
364
- getConversation(): string {
365
- return getAgentConversation(this._session);
366
- }
367
-
368
- /** Return the session context window utilization (0-100), or null when unavailable. */
369
- getContextPercent(): number | null {
370
- return getSessionContextPercent(this._session);
371
- }
372
-
373
- /** Subscribe to session events. Satisfies `SubscribableSession`. */
374
- subscribe(fn: (event: AgentSessionEvent) => void): () => void {
375
- return this._session.subscribe(fn);
376
- }
377
-
378
- /** Return session token statistics. Satisfies `SessionLike`. */
379
- getSessionStats(): SessionStatsLike {
380
- return this._session.getSessionStats();
381
- }
382
-
383
- /** The session's message history. */
384
- get messages(): readonly unknown[] {
385
- return this._session.messages as readonly unknown[];
386
- }
387
-
388
- /** The session's message history, typed for Pi's session-rendering machinery. */
389
- get agentMessages(): readonly SessionMessage[] {
390
- return this._session.messages;
391
- }
392
-
393
- /** Resolve a registered tool definition by name, for Pi's tool-execution components. */
394
- getToolDefinition(name: string): ToolDefinition | undefined {
395
- return this._session.getToolDefinition(name);
396
- }
255
+ async steer(message: string): Promise<void> { await this._session.steer(message); }
256
+ getConversation(): string { return getAgentConversation(this._session); }
257
+ getContextPercent(): number | null { return getSessionContextPercent(this._session); }
258
+ subscribe(fn: (event: AgentSessionEvent) => void): () => void { return this._session.subscribe(fn); }
259
+ getSessionStats(): SessionStatsLike { return this._session.getSessionStats(); }
260
+ get messages(): readonly unknown[] { return this._session.messages as readonly unknown[]; }
261
+ get agentMessages(): readonly SessionMessage[] { return this._session.messages; }
262
+ getToolDefinition(name: string): ToolDefinition | undefined { return this._session.getToolDefinition(name); }
397
263
 
398
- /**
399
- * Tear down the child session exactly once.
400
- *
401
- * AgentSession.dispose() revokes extension contexts immediately but does not
402
- * emit the extension lifecycle event. Child-owned extensions need the same
403
- * awaited shutdown boundary as root-session replacement so they can cancel
404
- * detached work before their Pi API is invalidated.
405
- */
406
264
  dispose(): Promise<void> {
407
265
  this.disposalPromise ??= this.disposeOnce();
408
266
  return this.disposalPromise;
@@ -410,21 +268,15 @@ export class SubagentSession {
410
268
 
411
269
  private async disposeOnce(): Promise<void> {
412
270
  try {
413
- await this._session.extensionRunner.emit({
414
- type: "session_shutdown",
415
- reason: "quit",
416
- });
271
+ await this._session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
417
272
  } catch (error) {
418
- // A faulty extension must not prevent the SDK session from being revoked.
419
273
  debugLog("child extension session_shutdown", error);
420
274
  }
421
-
422
275
  try {
423
276
  this._session.dispose();
424
277
  } catch (error) {
425
278
  debugLog("child session dispose", error);
426
279
  }
427
-
428
280
  try {
429
281
  this.meta.lifecycle.disposed({ sessionId: this.meta.sessionId });
430
282
  } catch (error) {
@@ -433,39 +285,17 @@ export class SubagentSession {
433
285
  }
434
286
  }
435
287
 
436
- // ── Private turn-loop helpers ───────────────────────────────────────────────────
437
-
438
- function toLifecycleOutcome(
439
- outcome: Readonly<{ aborted: boolean; steered: boolean }>,
440
- ): SubagentLifecycleOutcome {
441
- if (outcome.aborted) return "aborted";
442
- if (outcome.steered) return "steered";
443
- return "completed";
444
- }
445
-
446
- /**
447
- * Subscribe to a session and collect the last assistant message text.
448
- * Returns an object with a `getText()` getter and an `unsubscribe` function.
449
- */
450
288
  function collectResponseText(session: AgentSession) {
451
289
  let text = "";
452
290
  const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
453
291
  runSafely("subagent response observer", () => {
454
- if (event.type === "message_start" && event.message?.role === "assistant") {
455
- text = "";
456
- }
457
- if (
458
- event.type === "message_update" &&
459
- event.assistantMessageEvent.type === "text_delta"
460
- ) {
461
- text += event.assistantMessageEvent.delta;
462
- }
292
+ if (event.type === "message_start" && event.message?.role === "assistant") text = "";
293
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") text += event.assistantMessageEvent.delta;
463
294
  });
464
295
  });
465
296
  return { getText: () => text, unsubscribe };
466
297
  }
467
298
 
468
- /** Get the last assistant text from the completed session history. */
469
299
  function getLastAssistantText(session: AgentSession, startIndex = 0): string {
470
300
  for (let i = session.messages.length - 1; i >= startIndex; i--) {
471
301
  const msg = session.messages[i];
@@ -476,45 +306,25 @@ function getLastAssistantText(session: AgentSession, startIndex = 0): string {
476
306
  return "";
477
307
  }
478
308
 
479
- /** Classify a normally-resolved failure from this invocation's final assistant turn. */
480
309
  function finalTurnError(session: AgentSession, startIndex = 0): string | undefined {
481
310
  for (let i = session.messages.length - 1; i >= startIndex; i--) {
482
311
  const msg = session.messages[i];
483
312
  if (msg.role !== "assistant") continue;
484
- if (msg.stopReason === "error") {
485
- return msg.errorMessage?.trim() || "provider error with no output";
486
- }
487
- if (msg.stopReason === "length" && !extractText(msg.content).trim()) {
488
- return "run hit the output token limit before producing any text";
489
- }
313
+ if (msg.stopReason === "error") return msg.errorMessage?.trim() || "provider error with no output";
314
+ if (msg.stopReason === "length" && !extractText(msg.content).trim()) return "run hit the output token limit before producing any text";
490
315
  return undefined;
491
316
  }
492
317
  return undefined;
493
318
  }
494
319
 
495
- /**
496
- * Release the turn loop's SDK-owned cleanup handles one by one. Each handle is
497
- * code outside this extension, so a throwing unsubscribe must neither skip the
498
- * remaining handles nor mask the primary turn-loop outcome settling above.
499
- */
500
320
  function releaseTurnLoopHandles(handles: ReadonlyArray<readonly [string, () => void]>): void {
501
- for (const [context, release] of handles) {
502
- runSafely(context, release);
503
- }
321
+ for (const [context, release] of handles) runSafely(context, release);
504
322
  }
505
323
 
506
- /**
507
- * Wire an AbortSignal to abort a session.
508
- * Returns a cleanup function to remove the listener.
509
- */
510
- function forwardAbortSignal(
511
- session: AgentSession,
512
- signal?: AbortSignal,
513
- ): () => void {
324
+ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
514
325
  if (!signal) return () => {};
515
- const onAbort = (): void => {
516
- runDetached("parent-signal abort", () => session.abort());
517
- };
326
+ const onAbort = (): void => runDetached("parent-signal abort", () => session.abort());
518
327
  signal.addEventListener("abort", onAbort, { once: true });
328
+ if (signal.aborted) onAbort();
519
329
  return () => signal.removeEventListener("abort", onAbort);
520
330
  }