@nklisch/pi-enhanced 0.2.0 → 0.2.2

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 (65) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/node_modules/@nklisch/pi-background-tasks/CHANGELOG.md +7 -0
  3. package/node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.test.ts +105 -0
  4. package/node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.ts +131 -44
  5. package/node_modules/@nklisch/pi-background-tasks/package.json +1 -1
  6. package/node_modules/@nklisch/pi-background-tasks/skills/background-tasks/SKILL.md +6 -7
  7. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  8. package/node_modules/@nklisch/pi-clearance/package.json +1 -1
  9. package/node_modules/@nklisch/pi-clearance/src/config/config-command-writer.ts +34 -17
  10. package/node_modules/@nklisch/pi-clearance/src/index.ts +34 -3
  11. package/node_modules/@nklisch/pi-clearance/src/packs/package-registration.ts +63 -36
  12. package/node_modules/@nklisch/pi-clearance/src/runtime/allow-request-message.ts +40 -1
  13. package/node_modules/@nklisch/pi-clearance/src/runtime/command-registry.ts +22 -2
  14. package/node_modules/@nklisch/pi-clearance/src/runtime/command-transforms.ts +50 -29
  15. package/node_modules/@nklisch/pi-clearance/src/runtime/config-commands/settings/native-ui.ts +233 -49
  16. package/node_modules/@nklisch/pi-clearance/src/runtime/policy-cache.ts +19 -5
  17. package/node_modules/@nklisch/pi-conveniences/extensions/context-window-footer.ts +48 -13
  18. package/node_modules/@nklisch/pi-conveniences/package.json +1 -1
  19. package/node_modules/@nklisch/pi-fff-compat/extensions/fff-compat-search.ts +49 -42
  20. package/node_modules/@nklisch/pi-fff-compat/extensions/finder-lifecycle.ts +92 -0
  21. package/node_modules/@nklisch/pi-fff-compat/package.json +2 -2
  22. package/node_modules/@nklisch/pi-model-modes/package.json +1 -1
  23. package/node_modules/@nklisch/pi-model-modes/src/command-message.ts +30 -0
  24. package/node_modules/@nklisch/pi-model-modes/src/commands.ts +31 -15
  25. package/node_modules/@nklisch/pi-model-modes/src/style-command.ts +25 -14
  26. package/node_modules/@nklisch/pi-plugins/README.md +2 -2
  27. package/node_modules/@nklisch/pi-plugins/dist/application/subagent-hook-runtime.js +4 -1
  28. package/node_modules/@nklisch/pi-plugins/dist/application/subagent-hook-runtime.js.map +1 -1
  29. package/node_modules/@nklisch/pi-plugins/dist/composition/background-update-coordinator.js +12 -3
  30. package/node_modules/@nklisch/pi-plugins/dist/composition/background-update-coordinator.js.map +1 -1
  31. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.d.ts +1 -0
  32. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.js +19 -11
  33. package/node_modules/@nklisch/pi-plugins/dist/infrastructure/process/command-runner.js.map +1 -1
  34. package/node_modules/@nklisch/pi-plugins/dist/pi/hooks/pi-hook-decision-adapter.js +36 -7
  35. package/node_modules/@nklisch/pi-plugins/dist/pi/hooks/pi-hook-decision-adapter.js.map +1 -1
  36. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-component.js +14 -3
  37. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-component.js.map +1 -1
  38. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-session.js +153 -54
  39. package/node_modules/@nklisch/pi-plugins/dist/pi/manager/plugin-manager-session.js.map +1 -1
  40. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-lifecycle.js +35 -8
  41. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-lifecycle.js.map +1 -1
  42. package/node_modules/@nklisch/pi-plugins/dist/runtime/mcp/pi-mcp-adapter-package.js +1 -1
  43. package/node_modules/@nklisch/pi-plugins/dist/runtime/mcp/pi-mcp-adapter-runtime.js +1 -1
  44. package/node_modules/@nklisch/pi-plugins/dist/runtime/subagents/pi-subagents-package.js +1 -1
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +7 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +22 -17
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/debug.ts +18 -0
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +22 -4
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +32 -8
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +8 -7
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +25 -6
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +6 -2
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +31 -16
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +70 -32
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +55 -50
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace-bracket.ts +6 -2
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/record-observer.ts +37 -34
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +15 -14
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +31 -26
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +51 -26
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +100 -46
  63. package/node_modules/@nklisch/pi-plugins/package.json +3 -3
  64. package/package.json +4 -4
  65. package/test/verify-bundle.mjs +8 -0
@@ -58,23 +58,28 @@ function loadFromDir(
58
58
  continue;
59
59
  }
60
60
 
61
- const { frontmatter: fm, body } = parseFrontmatter(content);
62
-
63
- agents.set(name, {
64
- name,
65
- displayName: str(fm.display_name),
66
- description: str(fm.description) ?? name,
67
- builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
68
- model: str(fm.model),
69
- thinking: str(fm.thinking) as ThinkingLevel | undefined,
70
- maxTurns: nonNegativeInt(fm.max_turns),
71
- systemPrompt: body.trim(),
72
- promptMode: fm.prompt_mode === "replace" ? "replace" : "append",
73
- inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
74
- runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
75
- enabled: fm.enabled !== false, // default true; explicitly false disables
76
- source,
77
- });
61
+ try {
62
+ const { frontmatter: fm, body } = parseFrontmatter(content);
63
+ agents.set(name, {
64
+ name,
65
+ displayName: str(fm.display_name),
66
+ description: str(fm.description) ?? name,
67
+ builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
68
+ model: str(fm.model),
69
+ thinking: str(fm.thinking) as ThinkingLevel | undefined,
70
+ maxTurns: nonNegativeInt(fm.max_turns),
71
+ systemPrompt: body.trim(),
72
+ promptMode: fm.prompt_mode === "replace" ? "replace" : "append",
73
+ inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
74
+ runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
75
+ enabled: fm.enabled !== false, // default true; explicitly false disables
76
+ source,
77
+ });
78
+ } catch (err) {
79
+ // One malformed user file must not hide valid agents from the same tier
80
+ // or lower-priority discovery locations.
81
+ debugLog("parse custom agent frontmatter", err);
82
+ }
78
83
  }
79
84
  }
80
85
 
@@ -12,3 +12,21 @@ export function isDebug(): boolean {
12
12
  export function debugLog(context: string, err: unknown): void {
13
13
  if (isDebug()) console.warn(`[pi-subagents:debug] ${context}:`, err);
14
14
  }
15
+
16
+ /** Run a synchronous extension-owned callback without letting it escape its boundary. */
17
+ export function runSafely(context: string, action: () => void): void {
18
+ try {
19
+ action();
20
+ } catch (error) {
21
+ debugLog(context, error);
22
+ }
23
+ }
24
+
25
+ /** Start extension-owned async work and consume both synchronous and rejected failures. */
26
+ export function runDetached(context: string, action: () => PromiseLike<unknown> | void): void {
27
+ try {
28
+ void Promise.resolve(action()).catch((error: unknown) => debugLog(context, error));
29
+ } catch (error) {
30
+ debugLog(context, error);
31
+ }
32
+ }
@@ -10,6 +10,8 @@
10
10
  * Pi SDK imports — `index.ts` wires it to `pi.events.emit`.
11
11
  */
12
12
 
13
+ import { debugLog } from "#src/debug";
14
+
13
15
  /** Emitted at the start of a child run, before the session is created. */
14
16
  export const SUBAGENT_CHILD_SPAWNING = "subagents:child:spawning";
15
17
 
@@ -75,16 +77,32 @@ export function createChildLifecyclePublisher(
75
77
  ): ChildLifecyclePublisher {
76
78
  return {
77
79
  spawning(event) {
78
- emit(SUBAGENT_CHILD_SPAWNING, event);
80
+ publish(emit, "child lifecycle spawning", SUBAGENT_CHILD_SPAWNING, event);
79
81
  },
80
82
  sessionCreated(event) {
81
- emit(SUBAGENT_CHILD_SESSION_CREATED, event);
83
+ publish(emit, "child lifecycle session-created", SUBAGENT_CHILD_SESSION_CREATED, event);
82
84
  },
83
85
  completed(event) {
84
- emit(SUBAGENT_CHILD_COMPLETED, event);
86
+ publish(emit, "child lifecycle completed", SUBAGENT_CHILD_COMPLETED, event);
85
87
  },
86
88
  disposed(event) {
87
- emit(SUBAGENT_CHILD_DISPOSED, event);
89
+ publish(emit, "child lifecycle disposed", SUBAGENT_CHILD_DISPOSED, event);
88
90
  },
89
91
  };
90
92
  }
93
+
94
+ /** EventEmitter listeners are outside the extension runner; isolate their throws. */
95
+ function publish(
96
+ emit: LifecycleEmit,
97
+ channelContext: string,
98
+ channel: string,
99
+ data: unknown,
100
+ ): void {
101
+ try {
102
+ // Pi's event bus is synchronous for child lifecycle hooks by design.
103
+ // Keep that ordering while preventing a subscriber from escaping into Pi.
104
+ emit(channel, data);
105
+ } catch (error) {
106
+ debugLog(channelContext, error);
107
+ }
108
+ }
@@ -9,6 +9,8 @@
9
9
  * task runs, or resolves early if clear() drops it before it starts.
10
10
  */
11
11
 
12
+ import { debugLog } from "#src/debug";
13
+
12
14
  export class ConcurrencyLimiter {
13
15
  private active = 0;
14
16
  private readonly pending: Array<{ start: () => void; settle: () => void }> = [];
@@ -17,7 +19,12 @@ export class ConcurrencyLimiter {
17
19
 
18
20
  /** Whether a newly scheduled task will wait behind work already admitted. */
19
21
  isSaturated(): boolean {
20
- return this.active >= this.getLimit();
22
+ try {
23
+ return this.active >= this.getLimit();
24
+ } catch (error) {
25
+ debugLog("concurrency limiter limit", error);
26
+ return false;
27
+ }
21
28
  }
22
29
 
23
30
  /**
@@ -30,12 +37,25 @@ export class ConcurrencyLimiter {
30
37
  this.pending.push({
31
38
  start: () => {
32
39
  this.active++;
33
- task()
40
+ let taskPromise: Promise<void>;
41
+ try {
42
+ taskPromise = Promise.resolve(task());
43
+ } catch (error) {
44
+ taskPromise = Promise.reject(error);
45
+ }
46
+ taskPromise
34
47
  .then(resolve, reject)
35
48
  .finally(() => {
36
49
  this.active--;
37
- this.recheck();
38
- });
50
+ try {
51
+ this.recheck();
52
+ } catch (error) {
53
+ // A failing dynamic limit must not strand the slot or
54
+ // reject the detached cleanup promise.
55
+ debugLog("concurrency limiter recheck", error);
56
+ }
57
+ })
58
+ .catch((error: unknown) => debugLog("concurrency limiter cleanup", error));
39
59
  },
40
60
  settle: resolve,
41
61
  });
@@ -45,10 +65,14 @@ export class ConcurrencyLimiter {
45
65
 
46
66
  /** Start pending tasks until the limit is reached. Call when the limit may have grown. */
47
67
  recheck(): void {
48
- while (this.active < this.getLimit()) {
49
- const next = this.pending.shift();
50
- if (!next) break;
51
- next.start();
68
+ try {
69
+ while (this.active < this.getLimit()) {
70
+ const next = this.pending.shift();
71
+ if (!next) break;
72
+ next.start();
73
+ }
74
+ } catch (error) {
75
+ debugLog("concurrency limiter recheck", error);
52
76
  }
53
77
  }
54
78
 
@@ -227,14 +227,15 @@ export async function createSubagentSession(
227
227
  lifecycle: deps.lifecycle,
228
228
  });
229
229
 
230
- // Publish session-created before bindExtensions() so observers (e.g. the
231
- // permission system) can register the child synchronously and have their
232
- // entry in place for the first permission check during child extension
233
- // initialization. The event bus dispatches synchronously, so a synchronous
234
- // subscriber completes before this returns.
235
- deps.lifecycle.sessionCreated({ sessionId, parentSessionId });
236
-
237
230
  try {
231
+ // Publish session-created before bindExtensions() so observers (e.g. the
232
+ // permission system) can register the child synchronously and have their
233
+ // entry in place for the first permission check during child extension
234
+ // initialization. The event bus dispatches synchronously, so a synchronous
235
+ // subscriber completes before this returns. Keep publication in the same
236
+ // cleanup boundary as binding: a failing subscriber must not leak the
237
+ // already-created child session.
238
+ deps.lifecycle.sessionCreated({ sessionId, parentSessionId });
238
239
  // Bind extensions so that session_start fires and extensions can initialize.
239
240
  await session.bindExtensions({});
240
241
  // Apply recursion guard after bindExtensions so extension-registered tools
@@ -7,6 +7,8 @@
7
7
  * continuation bound; it does not expose sessions, records, models, or queues.
8
8
  */
9
9
 
10
+ import { debugLog } from "#src/debug";
11
+
10
12
  /** The fixed bound prevents a provider from turning completion into an unbounded loop. */
11
13
  export const MAX_LIFECYCLE_CONTINUATION_ROUNDS = 3;
12
14
 
@@ -137,7 +139,7 @@ export class LifecycleInterceptorRegistry {
137
139
  if (this.shutdownController.signal.aborted) {
138
140
  throw new Error("Subagent lifecycle registry is disposed");
139
141
  }
140
- if (typeof interceptor !== "object") {
142
+ if (interceptor === null || typeof interceptor !== "object") {
141
143
  throw new TypeError("A lifecycle interceptor object is required");
142
144
  }
143
145
 
@@ -193,10 +195,7 @@ export class LifecycleInterceptorRegistry {
193
195
  private async shutdown(): Promise<void> {
194
196
  this.shutdownController.abort(new DOMException("Subagent lifecycle registry disposed", "AbortError"));
195
197
  for (const record of this.registrations) this.unregister(record);
196
- const finalizers = this.registrations.flatMap((record) =>
197
- record.finalizer === undefined ? [] : [record.finalizer],
198
- );
199
- await Promise.all(finalizers);
198
+ await Promise.all(this.registrations.map((record) => this.consumeFinalizer(record)));
200
199
  }
201
200
 
202
201
  /**
@@ -206,8 +205,18 @@ export class LifecycleInterceptorRegistry {
206
205
  * unregister another interceptor while the same ordered snapshot is running.
207
206
  */
208
207
  private disposeRegistration(record: RegistrationRecord): Promise<void> {
208
+ const wasInFlight = record.inFlight > 0;
209
209
  this.unregister(record);
210
- return Promise.resolve();
210
+ // A callback may unregister another provider from inside the same ordered
211
+ // snapshot. Do not await its idle finalizer there; consume it independently
212
+ // while preserving the synchronous interceptor ordering contract. External
213
+ // disposal still gets a completion promise once the provider is idle.
214
+ const finalizer = this.consumeFinalizer(record);
215
+ if (wasInFlight) {
216
+ void finalizer;
217
+ return Promise.resolve();
218
+ }
219
+ return finalizer;
211
220
  }
212
221
 
213
222
  private unregister(record: RegistrationRecord): void {
@@ -218,6 +227,16 @@ export class LifecycleInterceptorRegistry {
218
227
  })();
219
228
  }
220
229
 
230
+ /** Consume disposer failures so detached manager shutdown cannot become an unhandled rejection. */
231
+ private async consumeFinalizer(record: RegistrationRecord): Promise<void> {
232
+ if (!record.finalizer) return;
233
+ try {
234
+ await record.finalizer;
235
+ } catch (error) {
236
+ debugLog("lifecycle interceptor dispose", error);
237
+ }
238
+ }
239
+
221
240
  private async waitForIdle(record: RegistrationRecord): Promise<void> {
222
241
  if (record.inFlight === 0) return;
223
242
  await new Promise<void>((resolve) => record.idleWaiters.push(resolve));
@@ -6,6 +6,8 @@
6
6
  * both atomically when the run ends or the agent is resumed.
7
7
  */
8
8
 
9
+ import { runSafely } from "#src/debug";
10
+
9
11
  /** Owns the per-run observer-unsubscribe and signal-detach handles. */
10
12
  export class RunListeners {
11
13
  private unsub?: () => void;
@@ -29,9 +31,11 @@ export class RunListeners {
29
31
 
30
32
  /** Release the observer + signal handles. Idempotent. */
31
33
  release(): void {
32
- this.unsub?.();
34
+ const unsub = this.unsub;
33
35
  this.unsub = undefined;
34
- this.detach?.();
36
+ const detach = this.detach;
35
37
  this.detach = undefined;
38
+ runSafely("subagent observer unsubscribe", () => unsub?.());
39
+ runSafely("subagent abort listener detach", () => detach?.());
36
40
  }
37
41
  }
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
- import { debugLog } from "#src/debug";
11
+ import { debugLog, runSafely } from "#src/debug";
12
12
  import type { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
13
13
  import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
14
14
  import {
@@ -102,7 +102,15 @@ export class SubagentManager {
102
102
  this.observer = options.observer;
103
103
  this.getRunConfig = options.getRunConfig;
104
104
  // Periodically release heavy terminal sessions according to retention policy.
105
- this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
105
+ // Timer callbacks sit outside Pi's extension runner, so a malformed setting
106
+ // or disposal failure must remain diagnostic rather than escape into Node.
107
+ this.cleanupInterval = setInterval(() => {
108
+ try {
109
+ this.cleanup();
110
+ } catch (error) {
111
+ debugLog("retention cleanup", error);
112
+ }
113
+ }, 60_000);
106
114
  this.cleanupInterval.unref();
107
115
  }
108
116
 
@@ -133,23 +141,29 @@ export class SubagentManager {
133
141
  /** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
134
142
  private buildObserver(options: AgentSpawnConfig): SubagentLifecycleObserver {
135
143
  return {
136
- onStarted: (agent) => {
137
- this.observer?.onSubagentStarted(agent);
138
- },
144
+ onStarted: (agent) => runSafely(
145
+ "onSubagentStarted observer",
146
+ () => this.observer?.onSubagentStarted(agent),
147
+ ),
139
148
  onSessionCreated: options.observer?.onSessionCreated
140
- ? (agent) => options.observer!.onSessionCreated!(agent)
149
+ ? (agent) => runSafely(
150
+ "onSessionCreated observer",
151
+ () => options.observer!.onSessionCreated!(agent),
152
+ )
141
153
  : undefined,
142
154
  onRunFinished: (agent) => {
143
155
  if (options.isBackground) {
144
- try { this.observer?.onSubagentCompleted(agent); } catch (err) { debugLog("onSubagentCompleted observer", err); }
156
+ runSafely("onSubagentCompleted observer", () => this.observer?.onSubagentCompleted(agent));
145
157
  }
146
158
  },
147
- onResumedFinished: (agent) => {
148
- try { this.observer?.onSubagentResumed?.(agent); } catch (err) { debugLog("onSubagentResumed observer", err); }
149
- },
150
- onCompacted: (agent, info) => {
151
- this.observer?.onSubagentCompacted(agent, info);
152
- },
159
+ onResumedFinished: (agent) => runSafely(
160
+ "onSubagentResumed observer",
161
+ () => this.observer?.onSubagentResumed?.(agent),
162
+ ),
163
+ onCompacted: (agent, info) => runSafely(
164
+ "onSubagentCompacted observer",
165
+ () => this.observer?.onSubagentCompacted(agent, info),
166
+ ),
153
167
  };
154
168
  }
155
169
 
@@ -202,7 +216,7 @@ export class SubagentManager {
202
216
  this.agents.set(id, record);
203
217
 
204
218
  if (options.isBackground) {
205
- this.observer?.onSubagentCreated(record);
219
+ runSafely("onSubagentCreated observer", () => this.observer?.onSubagentCreated(record));
206
220
  }
207
221
 
208
222
  if (options.isBackground && !options.bypassQueue) {
@@ -370,8 +384,9 @@ export class SubagentManager {
370
384
  dispose() {
371
385
  clearInterval(this.cleanupInterval);
372
386
  // Lifecycle callbacks observe the shutdown signal before their registration
373
- // disposer runs. Existing no-provider teardown stays synchronous.
374
- void this.lifecycleInterceptors.dispose();
387
+ // disposer runs. Existing no-provider teardown stays synchronous. Consume
388
+ // async disposer failures because manager disposal is intentionally sync.
389
+ void this.lifecycleInterceptors.dispose().catch((error) => debugLog("lifecycle interceptor shutdown", error));
375
390
  // Drop pending thunks
376
391
  this.limiter.clear();
377
392
  for (const record of this.agents.values()) {
@@ -16,6 +16,7 @@ import {
16
16
  type ToolDefinition,
17
17
  } from "@earendil-works/pi-coding-agent";
18
18
  import type { Model } from "@earendil-works/pi-ai";
19
+ import { debugLog, runDetached, runSafely } from "#src/debug";
19
20
  import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
20
21
  import {
21
22
  type SubagentLifecycleOutcome,
@@ -73,6 +74,8 @@ export interface SubagentSessionMeta {
73
74
  * One child AgentSession plus its turn-driving and teardown — born complete.
74
75
  */
75
76
  export class SubagentSession {
77
+ private disposed = false;
78
+
76
79
  constructor(
77
80
  private readonly _session: AgentSession,
78
81
  private readonly meta: SubagentSessionMeta,
@@ -118,20 +121,24 @@ export class SubagentSession {
118
121
  let aborted = false;
119
122
 
120
123
  const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
121
- if (event.type === "turn_end") {
122
- turnCount++;
123
- if (maxTurns != null) {
124
- if (!softLimitReached && turnCount >= maxTurns) {
125
- softLimitReached = true;
126
- void session.steer(
127
- "You have reached your turn limit. Wrap up immediately - provide your final answer now.",
128
- );
129
- } else if (softLimitReached && turnCount >= maxTurns + (opts.graceTurns ?? 5)) {
130
- aborted = true;
131
- void session.abort();
124
+ 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
+ }
132
139
  }
133
140
  }
134
- }
141
+ });
135
142
  });
136
143
 
137
144
  const startIndex = session.messages.length;
@@ -165,9 +172,11 @@ export class SubagentSession {
165
172
  () => ({ aborted, steered: softLimitReached }),
166
173
  );
167
174
  } finally {
168
- unsubTurns();
169
- collector.unsubscribe();
170
- cleanupAbort();
175
+ releaseTurnLoopHandles([
176
+ ["subagent turn-limit unsubscribe", unsubTurns],
177
+ ["subagent response collector unsubscribe", collector.unsubscribe],
178
+ ["subagent abort forwarder detach", cleanupAbort],
179
+ ]);
171
180
  }
172
181
  }
173
182
 
@@ -188,8 +197,10 @@ export class SubagentSession {
188
197
  failure: finalTurnError(session, startIndex),
189
198
  };
190
199
  } finally {
191
- collector.unsubscribe();
192
- cleanupAbort();
200
+ releaseTurnLoopHandles([
201
+ ["subagent response collector unsubscribe", collector.unsubscribe],
202
+ ["subagent abort forwarder detach", cleanupAbort],
203
+ ]);
193
204
  }
194
205
  }
195
206
 
@@ -214,8 +225,10 @@ export class SubagentSession {
214
225
  () => ({ aborted: false, steered: false }),
215
226
  );
216
227
  } finally {
217
- collector.unsubscribe();
218
- cleanupAbort();
228
+ releaseTurnLoopHandles([
229
+ ["subagent response collector unsubscribe", collector.unsubscribe],
230
+ ["subagent abort forwarder detach", cleanupAbort],
231
+ ]);
219
232
  }
220
233
  }
221
234
 
@@ -338,9 +351,21 @@ export class SubagentSession {
338
351
 
339
352
  /** Tear down: session.dispose() + emit `disposed` (registry unregister). */
340
353
  dispose(): void {
341
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
342
- this._session.dispose?.();
343
- this.meta.lifecycle.disposed({ sessionId: this.meta.sessionId });
354
+ if (this.disposed) return;
355
+ this.disposed = true;
356
+
357
+ try {
358
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
359
+ this._session.dispose?.();
360
+ } catch (error) {
361
+ debugLog("child session dispose", error);
362
+ }
363
+
364
+ try {
365
+ this.meta.lifecycle.disposed({ sessionId: this.meta.sessionId });
366
+ } catch (error) {
367
+ debugLog("child lifecycle disposed", error);
368
+ }
344
369
  }
345
370
  }
346
371
 
@@ -361,15 +386,17 @@ function toLifecycleOutcome(
361
386
  function collectResponseText(session: AgentSession) {
362
387
  let text = "";
363
388
  const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
364
- if (event.type === "message_start" && event.message?.role === "assistant") {
365
- text = "";
366
- }
367
- if (
368
- event.type === "message_update" &&
369
- event.assistantMessageEvent.type === "text_delta"
370
- ) {
371
- text += event.assistantMessageEvent.delta;
372
- }
389
+ runSafely("subagent response observer", () => {
390
+ if (event.type === "message_start" && event.message?.role === "assistant") {
391
+ text = "";
392
+ }
393
+ if (
394
+ event.type === "message_update" &&
395
+ event.assistantMessageEvent.type === "text_delta"
396
+ ) {
397
+ text += event.assistantMessageEvent.delta;
398
+ }
399
+ });
373
400
  });
374
401
  return { getText: () => text, unsubscribe };
375
402
  }
@@ -401,6 +428,17 @@ function finalTurnError(session: AgentSession, startIndex = 0): string | undefin
401
428
  return undefined;
402
429
  }
403
430
 
431
+ /**
432
+ * Release the turn loop's SDK-owned cleanup handles one by one. Each handle is
433
+ * code outside this extension, so a throwing unsubscribe must neither skip the
434
+ * remaining handles nor mask the primary turn-loop outcome settling above.
435
+ */
436
+ function releaseTurnLoopHandles(handles: ReadonlyArray<readonly [string, () => void]>): void {
437
+ for (const [context, release] of handles) {
438
+ runSafely(context, release);
439
+ }
440
+ }
441
+
404
442
  /**
405
443
  * Wire an AbortSignal to abort a session.
406
444
  * Returns a cleanup function to remove the listener.
@@ -411,7 +449,7 @@ function forwardAbortSignal(
411
449
  ): () => void {
412
450
  if (!signal) return () => {};
413
451
  const onAbort = (): void => {
414
- void session.abort();
452
+ runDetached("parent-signal abort", () => session.abort());
415
453
  };
416
454
  signal.addEventListener("abort", onAbort, { once: true });
417
455
  return () => signal.removeEventListener("abort", onAbort);