@nklisch/pi-enhanced 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.3
4
+
5
+ ### Fixed
6
+
7
+ - Rebundle `@nklisch/pi-plugins` 0.4.2 with clean child-session shutdown and serialized, Pi-idle-aware subagent resume.
8
+
3
9
  ## v0.2.2
4
10
 
5
11
  ### Fixed
@@ -62,7 +62,7 @@ Marketplace-derived network access is origin-authorized and DNS-pinned. Credenti
62
62
  The current production package pins:
63
63
 
64
64
  - `@nklisch/pi-mcp-adapter@2.21.0-nklisch.1`
65
- - `@nklisch/pi-subagents@18.1.0-nklisch.2`
65
+ - `@nklisch/pi-subagents@18.1.0-nklisch.3`
66
66
 
67
67
  Executable dependencies are checked against their declared package shape before import: exact name and version, license, engine and peer ranges, required exports, and declared Pi resources. The MCP and subagent packages are transitive dependencies: one top-level `pi install npm:@nklisch/pi-plugins` installs and activates both through verified wrappers, so they do not need separate `pi install` entries. The bundled subagent loader reuses Pi's already-loaded coding-agent, AI, and TUI module identities rather than installing a second Pi runtime tree. Package, API, runtime-range, or behavioral drift makes the affected capability unavailable rather than partially activating it. Subagent lifecycle conformance remains a separate qualification contract.
68
68
 
@@ -7,7 +7,7 @@ import { createJiti } from "jiti/static";
7
7
  import { probePublishedPackage, } from "../published-package-receipt.js";
8
8
  export const PI_SUBAGENTS_RECEIPT = Object.freeze({
9
9
  packageName: "@nklisch/pi-subagents",
10
- version: "18.1.0-nklisch.2",
10
+ version: "18.1.0-nklisch.3",
11
11
  license: "MIT",
12
12
  nodeEngine: ">=22",
13
13
  piPeerRange: ">=0.80.5",
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [18.1.0-nklisch.3] - 2026-08-24
9
+
10
+ ### Fixed
11
+
12
+ - Emit and await child `session_shutdown` before disposing `AgentSession`, so child extensions cancel detached work before Pi invalidates their contexts.
13
+ - Serialize resume admission and wait through aborted-turn wind-down and Pi's authoritative idle boundary, preventing false `Agent is already processing` failures and concurrent resume races.
14
+
8
15
  ## [18.1.0-nklisch.2] - 2026-08-23
9
16
 
10
17
  ### Fixed
@@ -268,9 +268,11 @@ Excess agents are automatically queued and start as running agents complete. The
268
268
 
269
269
  Foreground agents bypass the queue — they block the parent anyway. Completion nudges are held while the parent is running and flushed at the parent run boundary, preventing a pulled result from also arriving as a duplicate notification.
270
270
 
271
+ Resume requests are serialized per retained session. A second request while a turn is genuinely active gets a deterministic busy result; after an abort or extension-driven continuation, resume waits for both the prior invocation and Pi's authoritative idle boundary instead of racing `AgentSession.prompt()`. Session teardown likewise emits and awaits `session_shutdown` before Pi revokes child extension contexts, allowing child-owned processes and timers to stop cleanly.
272
+
271
273
  ## Persistent Settings
272
274
 
273
- Runtime tuning values set via `/subagents:settings` persist across pi restarts. Terminal records remain available for the whole parent session. Their heavy live sessions are released after the consumed or unconsumed retention window; the result and persisted transcript pointer remain available.
275
+ Runtime tuning values set via `/subagents:settings` persist across pi restarts. Terminal records remain available for the whole parent session. Their heavy live sessions are released after the consumed or unconsumed retention window; the result and persisted transcript pointer remain available, but a released session cannot resume without starting a new subagent. A completion notification, foreground result, or `get_subagent_result` collection marks a result consumed, so the shorter consumed window normally governs after delivery.
274
276
  Two files, merged on load:
275
277
 
276
278
  - **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults.
@@ -126,9 +126,11 @@ classDiagram
126
126
  +subscribeToUpdates(fn): unsub | undefined
127
127
  +messages: readonly unknown[]
128
128
  +effectiveThinkingLevel: ThinkingLevel
129
+ -executionInFlight: boolean
130
+ -resumeReserved: boolean
129
131
  +completeRun(result)
130
132
  +failRun(err)
131
- +disposeSession()
133
+ +disposeSession(): Promise~void~
132
134
  }
133
135
 
134
136
  class SubagentState {
@@ -216,10 +218,12 @@ stateDiagram-v2
216
218
  running --> stopped : max turns reached
217
219
  running --> steered : steer message injected
218
220
  steered --> running : continues with message
219
- completed --> running : resetForResume
220
- stopped --> running : resetForResume
221
- error --> running : resetForResume
222
- aborted --> running : resetForResume
221
+ completed --> resume_wait : reserve resume
222
+ stopped --> resume_wait : reserve after abort
223
+ error --> resume_wait : reserve resume
224
+ aborted --> resume_wait : reserve resume
225
+ resume_wait --> running : prior execution settled + Pi idle
226
+ resume_wait --> stopped : abort resume wait
223
227
  completed --> [*]
224
228
  error --> [*]
225
229
  aborted --> [*]
@@ -233,11 +237,13 @@ stateDiagram-v2
233
237
  ```
234
238
 
235
239
  Note: `markStopped` always succeeds regardless of current status.
236
- Other terminal transitions guard against overwriting `stopped` — once an agent is stopped, only `resetForResume` can return it to `running`.
240
+ Other terminal transitions guard against overwriting `stopped` — once an agent is stopped, only an admitted resume can return it to `running`.
237
241
 
238
- Terminal outcomes also carry orthogonal consumption state. Foreground delivery, `get_subagent_result`, and a queued completion notification mark the outcome consumed. Records remain available for the parent session; only their heavy live child sessions are released after the configured consumed or unconsumed retention window. Released records keep their result and persisted transcript pointer but cannot resume.
242
+ Resume admission is a record-owned lease. A genuinely running or already-reserved record rejects a second resume before reaching Pi. A stopped record may still own a winding-down prompt, and Pi may still be finishing post-run continuation after the domain result looks terminal; the lease therefore spans the prior execution promise and Pi's authoritative `agent_settled`/`isIdle` boundary. Retention, abort, result waiting, and manager shutdown all treat the lease as active.
239
243
 
240
- Completion notifications are held while the parent agent run is active and flushed on `agent_settled`, rechecking consumption before enqueueing a follow-up. Child session creation shares the parent model runtime and leaves extension-tool registration open; a denylist removes disallowed built-ins and recursive orchestration tools across registry refreshes.
244
+ Terminal outcomes also carry orthogonal consumption state. Foreground delivery, `get_subagent_result`, and a queued completion notification mark the outcome consumed. Records remain available for the parent session; only their heavy live child sessions are released after the configured consumed or unconsumed retention window. Released records keep their result and persisted transcript pointer but cannot resume. Release detaches the session synchronously before awaiting teardown, so it cannot race a new resume.
245
+
246
+ Completion notifications are held while the parent agent run is active and flushed on `agent_settled`, rechecking consumption before enqueueing a follow-up. Child session creation shares the parent model runtime and leaves extension-tool registration open; a denylist removes disallowed built-ins and recursive orchestration tools across registry refreshes. Child teardown mirrors Pi's managed root lifecycle: it emits and awaits `session_shutdown` before `AgentSession.dispose()` revokes extension contexts, then publishes the child `disposed` event.
241
247
 
242
248
  ## Execution flow
243
249
 
@@ -391,7 +397,7 @@ They declare this package as an optional peer dependency and use dynamic import
391
397
  - `SubagentManager` — spawn, abort, resume, collection management, observer wiring.
392
398
  - `ConcurrencyLimiter` — background admission gate: schedules run thunks FIFO against a configurable concurrency limit.
393
399
  - `createSubagentSession` — assembly factory: session creation and extension binding; returns a born-complete `SubagentSession`.
394
- - `SubagentSession` — the born-complete child session: drives the turn loop (`runTurnLoop`/`resumeTurnLoop`), steers, and disposes (firing `disposed` at true session disposal, so resume executions are registry-detected).
400
+ - `SubagentSession` — the born-complete child session: drives turn loops, exposes Pi's idle boundary, steers, and performs idempotent asynchronous teardown (`session_shutdown` `AgentSession.dispose()` child `disposed`).
395
401
  - `child-lifecycle` — publishes the child-execution lifecycle (`spawning`, `session-created` before `bindExtensions()`, `completed`, `disposed`) on `pi.events`.
396
402
  Reactive consumers subscribe: `@gotgenes/pi-permission-system` registers each child session on `session-created` and unregisters it on `disposed`.
397
403
  This replaced the former outbound `permission-bridge` (#261, [ADR-0002]) — the core no longer looks up a named consumer.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nklisch/pi-subagents",
3
- "version": "18.1.0-nklisch.2",
3
+ "version": "18.1.0-nklisch.3",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -9,9 +9,9 @@ import type { SessionContext } from "#src/types";
9
9
 
10
10
  /** Narrow manager interface — only the methods lifecycle handlers call. */
11
11
  export interface LifecycleManager {
12
- clearCompleted(): void;
12
+ clearCompleted(): Promise<void>;
13
13
  abortAll(): void;
14
- dispose(): void;
14
+ dispose(): Promise<void>;
15
15
  }
16
16
 
17
17
  /** Narrow runtime interface — only the methods lifecycle handlers call. */
@@ -37,13 +37,13 @@ export class SessionLifecycleHandler {
37
37
  private readonly unpublishService: () => void,
38
38
  ) {}
39
39
 
40
- handleSessionStart(_event: unknown, ctx: unknown): void {
40
+ async handleSessionStart(_event: unknown, ctx: unknown): Promise<void> {
41
41
  this.runtime.setSessionContext(ctx as SessionContext);
42
- this.manager.clearCompleted();
42
+ await this.manager.clearCompleted();
43
43
  }
44
44
 
45
- handleSessionBeforeSwitch(): void {
46
- this.manager.clearCompleted();
45
+ async handleSessionBeforeSwitch(): Promise<void> {
46
+ await this.manager.clearCompleted();
47
47
  }
48
48
 
49
49
  // Cleanup order matters:
@@ -52,12 +52,11 @@ export class SessionLifecycleHandler {
52
52
  // 3. Dispose notifications — prevent terminal transitions from enqueueing follow-ups
53
53
  // 4. Abort all agents — stop running and queued work
54
54
  // 5. Dispose manager — final cleanup
55
- handleSessionShutdown(): Promise<void> {
55
+ async handleSessionShutdown(): Promise<void> {
56
56
  this.unpublishService();
57
57
  this.runtime.clearSessionContext();
58
58
  this.disposeNotifications();
59
59
  this.manager.abortAll();
60
- this.manager.dispose();
61
- return Promise.resolve();
60
+ await this.manager.dispose();
62
61
  }
63
62
  }
@@ -242,9 +242,10 @@ export async function createSubagentSession(
242
242
  // are included in the post-bind active set.
243
243
  applyRecursionGuard(session);
244
244
  } catch (err) {
245
- // Binding failed after session-created — dispose (emit disposed +
246
- // session.dispose()) before rethrowing so registration is never leaked.
247
- subagentSession.dispose();
245
+ // Binding failed after session-created — shut extensions down, dispose the
246
+ // SDK session, and unregister the child before rethrowing. Awaiting keeps a
247
+ // partially-bound extension from escaping with detached work.
248
+ await subagentSession.dispose();
248
249
  throw err;
249
250
  }
250
251
 
@@ -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, runSafely } from "#src/debug";
11
+ import { debugLog, runDetached, 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 {
@@ -89,6 +89,7 @@ export class SubagentManager {
89
89
  private getRunConfig?: () => RunConfig;
90
90
  private _workspaceProvider?: WorkspaceProvider;
91
91
  private readonly lifecycleInterceptors = new LifecycleInterceptorRegistry();
92
+ private disposalPromise?: Promise<void>;
92
93
 
93
94
  /** The registered workspace provider, or undefined when none is registered. */
94
95
  get workspaceProvider(): WorkspaceProvider | undefined {
@@ -105,11 +106,7 @@ export class SubagentManager {
105
106
  // Timer callbacks sit outside Pi's extension runner, so a malformed setting
106
107
  // or disposal failure must remain diagnostic rather than escape into Node.
107
108
  this.cleanupInterval = setInterval(() => {
108
- try {
109
- this.cleanup();
110
- } catch (error) {
111
- debugLog("retention cleanup", error);
112
- }
109
+ runDetached("retention cleanup", () => this.cleanup());
113
110
  }, 60_000);
114
111
  this.cleanupInterval.unref();
115
112
  }
@@ -293,17 +290,20 @@ export class SubagentManager {
293
290
  }
294
291
 
295
292
  /** Dispose a record's session and remove it from the map. */
296
- private removeRecord(id: string, record: Subagent): void {
297
- record.disposeSession();
293
+ private async removeRecord(id: string, record: Subagent): Promise<void> {
294
+ // Remove first so no caller can acquire a record while its extensions are
295
+ // shutting down asynchronously.
298
296
  this.agents.delete(id);
297
+ await record.disposeSession();
299
298
  }
300
299
 
301
- private cleanup() {
300
+ private async cleanup(): Promise<void> {
302
301
  const now = Date.now();
303
302
  const config = this.getRunConfig?.();
304
303
  const consumedMinutes = config?.consumedSessionRetentionMinutes ?? 10;
305
304
  const unconsumedMinutes = config?.unconsumedSessionRetentionMinutes ?? 720;
306
305
 
306
+ const releases: Promise<void>[] = [];
307
307
  for (const record of this.agents.values()) {
308
308
  if (record.isActive() || !record.isSessionReady()) continue;
309
309
  const anchor = record.consumed
@@ -314,33 +314,33 @@ export class SubagentManager {
314
314
  if (anchor + retentionMinutes * 60_000 > now) continue;
315
315
  // Keep the lightweight terminal record and result for the whole parent
316
316
  // session; only release the heavy in-memory child session.
317
- record.releaseSession();
317
+ releases.push(record.releaseSession());
318
318
  }
319
+ await Promise.all(releases);
319
320
  }
320
321
 
321
322
  /**
322
323
  * Remove all completed/stopped/errored records immediately.
323
324
  * Called on session start/switch so tasks from a prior session don't persist.
324
325
  */
325
- clearCompleted(): void {
326
+ async clearCompleted(): Promise<void> {
327
+ const disposals: Promise<void>[] = [];
326
328
  for (const [id, record] of this.agents) {
327
- if (record.status === "running" || record.status === "queued") continue;
329
+ if (record.isActive()) continue;
328
330
  try {
329
331
  this.observer?.onSubagentCleared?.(record);
330
332
  } catch (err) {
331
333
  debugLog("onSubagentCleared observer", err);
332
334
  }
333
- this.removeRecord(id, record);
335
+ disposals.push(this.removeRecord(id, record));
334
336
  }
335
-
337
+ await Promise.all(disposals);
336
338
  }
337
339
 
338
340
  /** Whether any agents are still running or queued. */
339
341
  // fallow-ignore-next-line unused-class-member
340
342
  hasRunning(): boolean {
341
- return [...this.agents.values()].some(
342
- r => r.status === "running" || r.status === "queued",
343
- );
343
+ return [...this.agents.values()].some((record) => record.isActive());
344
344
  }
345
345
 
346
346
  /** Abort all running and queued agents immediately. */
@@ -376,22 +376,31 @@ export class SubagentManager {
376
376
  /** Promises of all running/queued agents that have one. */
377
377
  private pendingPromises(): Promise<void>[] {
378
378
  return [...this.agents.values()]
379
- .filter(r => r.status === "running" || r.status === "queued")
379
+ .filter((record) => record.isActive())
380
380
  .map(r => r.promise)
381
381
  .filter((p): p is Promise<void> => p != null);
382
382
  }
383
383
 
384
- dispose() {
384
+ dispose(): Promise<void> {
385
+ this.disposalPromise ??= this.disposeOnce();
386
+ return this.disposalPromise;
387
+ }
388
+
389
+ private async disposeOnce(): Promise<void> {
385
390
  clearInterval(this.cleanupInterval);
386
391
  // Lifecycle callbacks observe the shutdown signal before their registration
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));
390
- // Drop pending thunks
391
- this.limiter.clear();
392
- for (const record of this.agents.values()) {
393
- record.disposeSession();
392
+ // disposer runs. Await their finalizers before child extension teardown.
393
+ try {
394
+ await this.lifecycleInterceptors.dispose();
395
+ } catch (error) {
396
+ debugLog("lifecycle interceptor shutdown", error);
394
397
  }
398
+
399
+ // Drop pending thunks and make every record unreachable before awaiting
400
+ // extension shutdown. No new resume can race teardown from this point.
401
+ this.limiter.clear();
402
+ const records = [...this.agents.values()];
395
403
  this.agents.clear();
404
+ await Promise.all(records.map((record) => record.disposeSession()));
396
405
  }
397
406
  }
@@ -74,7 +74,7 @@ export interface SubagentSessionMeta {
74
74
  * One child AgentSession plus its turn-driving and teardown — born complete.
75
75
  */
76
76
  export class SubagentSession {
77
- private disposed = false;
77
+ private disposalPromise?: Promise<void>;
78
78
 
79
79
  constructor(
80
80
  private readonly _session: AgentSession,
@@ -309,6 +309,52 @@ export class SubagentSession {
309
309
  });
310
310
  }
311
311
 
312
+ /** Whether Pi has fully settled the child, including post-run continuation. */
313
+ get isIdle(): boolean {
314
+ return this._session.isIdle;
315
+ }
316
+
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
+ async waitUntilIdle(signal?: AbortSignal): Promise<void> {
323
+ if (this._session.isIdle) return;
324
+ signal?.throwIfAborted();
325
+
326
+ await new Promise<void>((resolve, reject) => {
327
+ let settled = false;
328
+ let unsubscribe: () => void = () => {};
329
+ const cleanup = (): void => {
330
+ signal?.removeEventListener("abort", onAbort);
331
+ unsubscribe();
332
+ };
333
+ const finish = (): void => {
334
+ if (settled) return;
335
+ settled = true;
336
+ cleanup();
337
+ resolve();
338
+ };
339
+ const onAbort = (): void => {
340
+ if (settled) return;
341
+ settled = true;
342
+ cleanup();
343
+ reject(signal?.reason ?? new Error("Resume wait aborted"));
344
+ };
345
+
346
+ unsubscribe = this._session.subscribe((event: AgentSessionEvent) => {
347
+ if (event.type === "agent_settled" || this._session.isIdle) finish();
348
+ });
349
+ 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
+ if (this._session.isIdle) finish();
354
+ else if (signal?.aborted) onAbort();
355
+ });
356
+ }
357
+
312
358
  /** Deliver a steer to the live session. */
313
359
  async steer(message: string): Promise<void> {
314
360
  await this._session.steer(message);
@@ -349,14 +395,32 @@ export class SubagentSession {
349
395
  return this._session.getToolDefinition(name);
350
396
  }
351
397
 
352
- /** Tear down: session.dispose() + emit `disposed` (registry unregister). */
353
- dispose(): void {
354
- if (this.disposed) return;
355
- this.disposed = true;
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
+ dispose(): Promise<void> {
407
+ this.disposalPromise ??= this.disposeOnce();
408
+ return this.disposalPromise;
409
+ }
410
+
411
+ private async disposeOnce(): Promise<void> {
412
+ try {
413
+ await this._session.extensionRunner.emit({
414
+ type: "session_shutdown",
415
+ reason: "quit",
416
+ });
417
+ } catch (error) {
418
+ // A faulty extension must not prevent the SDK session from being revoked.
419
+ debugLog("child extension session_shutdown", error);
420
+ }
356
421
 
357
422
  try {
358
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
359
- this._session.dispose?.();
423
+ this._session.dispose();
360
424
  } catch (error) {
361
425
  debugLog("child session dispose", error);
362
426
  }
@@ -55,6 +55,14 @@ export type SteerOutcome =
55
55
  | { kind: "buffered" }
56
56
  | { kind: "rejected"; status: SubagentStatus };
57
57
 
58
+ /** A second prompt was requested while this record already owned an execution. */
59
+ export class SubagentBusyError extends Error {
60
+ constructor(agentId: string) {
61
+ super(`Subagent "${agentId}" is still processing a turn. Wait for it to settle before resuming.`);
62
+ this.name = "SubagentBusyError";
63
+ }
64
+ }
65
+
58
66
  /**
59
67
  * The execution machinery a Subagent needs to run. A single mandatory
60
68
  * collaborator: production (SubagentManager.spawn) always supplies it, so run()
@@ -140,6 +148,11 @@ export class Subagent {
140
148
  private _effectiveThinkingLevel: ThinkingLevel;
141
149
  private readonly listeners = new RunListeners();
142
150
  private readonly workspaceBracket: WorkspaceBracket;
151
+ /** True while run()/runResume() owns the child prompt boundary. */
152
+ private executionInFlight = false;
153
+ /** Synchronous admission lease spanning wind-down, Pi idle, and resume. */
154
+ private resumeReserved = false;
155
+ private pendingResumeAbort?: AbortController;
143
156
 
144
157
  subagentSession?: SubagentSession;
145
158
  private releasedOutputFile?: string;
@@ -167,7 +180,7 @@ export class Subagent {
167
180
  }
168
181
 
169
182
  isActive(): boolean {
170
- return this.status === "queued" || this.status === "running";
183
+ return this.status === "queued" || this.status === "running" || this.resumeReserved;
171
184
  }
172
185
 
173
186
  isRunning(): boolean {
@@ -263,6 +276,7 @@ export class Subagent {
263
276
  * captured internally).
264
277
  */
265
278
  async run(): Promise<void> {
279
+ this.executionInFlight = true;
266
280
  this.markRunning(Date.now());
267
281
  try {
268
282
  // Observer callbacks are extension-owned sinks, not part of the agent's
@@ -322,6 +336,8 @@ export class Subagent {
322
336
  // One outer failure path guarantees terminal state, listener release,
323
337
  // workspace cleanup, and the manager's completion funnel.
324
338
  this.failRun(err);
339
+ } finally {
340
+ this.executionInFlight = false;
325
341
  }
326
342
  }
327
343
 
@@ -371,8 +387,65 @@ export class Subagent {
371
387
  : "Subagent not configured for resume — missing session",
372
388
  ));
373
389
  }
374
- this._promise = this.runResume(subagentSession, prompt, signal);
375
- return this._promise;
390
+ if (this.status === "queued" || this.status === "running" || this.resumeReserved) {
391
+ return Promise.reject(new SubagentBusyError(this.id));
392
+ }
393
+
394
+ // Reserve synchronously before the first await. This closes both the
395
+ // concurrent-resume race and retention's release-while-waiting race.
396
+ this.resumeReserved = true;
397
+ const previousExecution = this.executionInFlight ? this._promise : undefined;
398
+ const resumeAbort = new AbortController();
399
+ this.pendingResumeAbort = resumeAbort;
400
+ const resumed = this.resumeWhenReady(
401
+ subagentSession,
402
+ previousExecution,
403
+ prompt,
404
+ resumeAbort,
405
+ signal,
406
+ );
407
+ this._promise = resumed;
408
+ return resumed;
409
+ }
410
+
411
+ private async resumeWhenReady(
412
+ subagentSession: SubagentSession,
413
+ previousExecution: Promise<void> | undefined,
414
+ prompt: string,
415
+ resumeAbort: AbortController,
416
+ signal?: AbortSignal,
417
+ ): Promise<void> {
418
+ const waitSignal = signal
419
+ ? AbortSignal.any([resumeAbort.signal, signal])
420
+ : resumeAbort.signal;
421
+ let started = false;
422
+ try {
423
+ // abort() marks a record stopped before AgentSession.prompt() necessarily
424
+ // settles. Wait for both the record-owned invocation and Pi's stronger
425
+ // idle boundary instead of racing a new prompt into the old one.
426
+ if (previousExecution) await previousExecution;
427
+ waitSignal.throwIfAborted();
428
+ await subagentSession.waitUntilIdle(waitSignal);
429
+ waitSignal.throwIfAborted();
430
+ if (this.subagentSession !== subagentSession) {
431
+ throw new Error("Subagent session was released while waiting to resume");
432
+ }
433
+
434
+ started = true;
435
+ this.executionInFlight = true;
436
+ this.resetForResume(Date.now(), resumeAbort);
437
+ await this.runResume(subagentSession, prompt, signal);
438
+ } catch (error) {
439
+ if (waitSignal.aborted) this.markStopped();
440
+ else this.markError(error);
441
+ if (!started) {
442
+ runSafely("subagent onResumedFinished observer", () => this.execution.observer?.onResumedFinished?.(this));
443
+ }
444
+ } finally {
445
+ if (started) this.executionInFlight = false;
446
+ if (this.pendingResumeAbort === resumeAbort) this.pendingResumeAbort = undefined;
447
+ this.resumeReserved = false;
448
+ }
376
449
  }
377
450
 
378
451
  private async runResume(
@@ -380,7 +453,6 @@ export class Subagent {
380
453
  prompt: string,
381
454
  signal?: AbortSignal,
382
455
  ): Promise<void> {
383
- this.resetForResume(Date.now());
384
456
  const executionSignal = signal
385
457
  ? AbortSignal.any([this.abortController.signal, signal])
386
458
  : this.abortController.signal;
@@ -420,7 +492,7 @@ export class Subagent {
420
492
  /** Wait for the current queued, running, or resumed execution without cancelling it. */
421
493
  async waitUntilSettled(signal: AbortSignal): Promise<void> {
422
494
  const run = this._promise;
423
- if (!run || !this.isActive() || signal.aborted) return;
495
+ if (!run || (!this.executionInFlight && !this.resumeReserved) || signal.aborted) return;
424
496
  await settleOrAbort(run, signal);
425
497
  }
426
498
 
@@ -535,8 +607,9 @@ export class Subagent {
535
607
  * then no-ops on the queued-status guard.
536
608
  */
537
609
  abort(): boolean {
538
- if (this.status !== "running") return false;
610
+ if (this.status !== "running" && !this.resumeReserved) return false;
539
611
  this.abortController.abort();
612
+ this.pendingResumeAbort?.abort();
540
613
  this.markStopped();
541
614
  return true;
542
615
  }
@@ -561,8 +634,8 @@ export class Subagent {
561
634
  }
562
635
 
563
636
  /** Reset for resume: running status, new startedAt, clear completedAt/result/error/listeners. */
564
- resetForResume(startedAt: number): void {
565
- this.abortController = new AbortController();
637
+ resetForResume(startedAt: number, controller = new AbortController()): void {
638
+ this.abortController = controller;
566
639
  this.state.resetForResume(startedAt);
567
640
  this.listeners.release();
568
641
  }
@@ -598,18 +671,25 @@ export class Subagent {
598
671
  }
599
672
 
600
673
  /** Dispose the wrapped session, firing the `disposed` lifecycle event. */
601
- disposeSession(): void {
602
- this.subagentSession?.dispose();
674
+ async disposeSession(): Promise<void> {
675
+ const session = this.subagentSession;
676
+ if (!session) return;
677
+ // Detach first: callers must not admit a resume while asynchronous
678
+ // extension shutdown is in progress.
679
+ this.subagentSession = undefined;
680
+ await session.dispose();
603
681
  }
604
682
 
605
683
  /** Release heavy session state while preserving the transcript pointer and record. */
606
- releaseSession(): void {
684
+ async releaseSession(): Promise<void> {
607
685
  const session = this.subagentSession;
608
686
  if (!session) return;
609
687
  this.releasedOutputFile = session.outputFile;
610
- session.dispose();
688
+ // The record becomes non-resumable atomically at release admission, not
689
+ // after extension shutdown finishes.
611
690
  this.subagentSession = undefined;
612
691
  this._sessionReleased = true;
692
+ await session.dispose();
613
693
  }
614
694
 
615
695
  /** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */
@@ -7,6 +7,7 @@ import { AgentTypeRegistry } from "#src/config/agent-types";
7
7
  import { THINKING_LEVELS_DESCRIPTION } from "#src/config/thinking-levels";
8
8
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
9
9
  import type { AgentSpawnConfig } from "#src/lifecycle/subagent-manager";
10
+ import { SubagentBusyError } from "#src/lifecycle/subagent";
10
11
  import { spawnBackground } from "#src/tools/background-spawner";
11
12
  import { runForeground } from "#src/tools/foreground-runner";
12
13
  import { buildAgentGuidelines, buildDetails, buildTypeListText, textResult } from "#src/tools/helpers";
@@ -97,11 +98,17 @@ export class AgentTool {
97
98
  : `Agent "${params.resume}" has no active session to resume.`,
98
99
  );
99
100
  }
100
- const record = await this.manager.resume(
101
- params.resume as string,
102
- params.prompt as string,
103
- signal ?? new AbortController().signal,
104
- );
101
+ let record: Subagent | undefined;
102
+ try {
103
+ record = await this.manager.resume(
104
+ params.resume as string,
105
+ params.prompt as string,
106
+ signal ?? new AbortController().signal,
107
+ );
108
+ } catch (error) {
109
+ if (error instanceof SubagentBusyError) return textResult(error.message);
110
+ throw error;
111
+ }
105
112
  if (!record) {
106
113
  return textResult(`Failed to resume agent "${params.resume}".`);
107
114
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nklisch/pi-plugins",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "private": false,
5
5
  "description": "Native marketplace and plugin lifecycle management for Pi, with Claude Code and Codex compatibility.",
6
6
  "license": "MIT",
@@ -68,7 +68,7 @@
68
68
  ],
69
69
  "dependencies": {
70
70
  "@nklisch/pi-mcp-adapter": "2.21.0-nklisch.1",
71
- "@nklisch/pi-subagents": "18.1.0-nklisch.2",
71
+ "@nklisch/pi-subagents": "18.1.0-nklisch.3",
72
72
  "jiti": "2.7.0",
73
73
  "semver": "7.8.1",
74
74
  "smol-toml": "^1.7.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nklisch/pi-enhanced",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Pi, enhanced — one install for nklisch's full harness: policy-gated command review, plugin marketplace, subagents, background tasks, research tools, search, model modes, and a curated UX set.",
5
5
  "author": {
6
6
  "name": "nklisch"