@nklisch/pi-enhanced 0.2.1 → 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 +12 -0
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
- package/node_modules/@nklisch/pi-fff-compat/package.json +2 -2
- package/node_modules/@nklisch/pi-plugins/README.md +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/runtime/subagents/pi-subagents-package.js +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +7 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +3 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +15 -9
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/handlers/lifecycle.ts +8 -9
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +4 -3
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +35 -26
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +71 -7
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +92 -12
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/agent-tool.ts +12 -5
- package/node_modules/@nklisch/pi-plugins/package.json +2 -2
- package/package.json +2 -2
- package/test/verify-bundle.mjs +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## v0.2.2
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Point the bundled FFF compatibility resource at its extension entrypoint instead of its source directory, preventing Pi from trying to load the finder lifecycle helper as an extension.
|
|
14
|
+
|
|
3
15
|
## v0.2.1
|
|
4
16
|
|
|
5
17
|
### Fixed
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nklisch/pi-fff-compat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Fast FFF-backed file search through Pi-native find/grep semantics — glob-only file lookup and exact regex/literal grep with no fuzzy fallback.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "nklisch"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
],
|
|
31
31
|
"pi": {
|
|
32
32
|
"extensions": [
|
|
33
|
-
"./extensions"
|
|
33
|
+
"./extensions/fff-compat-search.ts"
|
|
34
34
|
]
|
|
35
35
|
},
|
|
36
36
|
"private": false,
|
|
@@ -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.
|
|
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.
|
|
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 -->
|
|
220
|
-
stopped -->
|
|
221
|
-
error -->
|
|
222
|
-
aborted -->
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
@@ -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 —
|
|
246
|
-
// session
|
|
247
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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
|
-
/**
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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.
|
|
375
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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. */
|
package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/agent-tool.ts
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"pi": {
|
|
30
30
|
"extensions": [
|
|
31
31
|
"./node_modules/@nklisch/pi-conveniences/extensions",
|
|
32
|
-
"./node_modules/@nklisch/pi-fff-compat/extensions",
|
|
32
|
+
"./node_modules/@nklisch/pi-fff-compat/extensions/fff-compat-search.ts",
|
|
33
33
|
"./node_modules/@nklisch/pi-model-modes/extensions/index.ts",
|
|
34
34
|
"./node_modules/@nklisch/pi-background-tasks/extensions/background-tasks.ts",
|
|
35
35
|
"./node_modules/@nklisch/pi-plugins/dist/pi/production-subagents-extension.js",
|
package/test/verify-bundle.mjs
CHANGED
|
@@ -34,6 +34,14 @@ const declared = [
|
|
|
34
34
|
].map((p) => p.replace(/^\.\//, ""));
|
|
35
35
|
|
|
36
36
|
const failures = [];
|
|
37
|
+
const fffCompatEntrypoint = "./node_modules/@nklisch/pi-fff-compat/extensions/fff-compat-search.ts";
|
|
38
|
+
if (!manifest.pi.extensions.includes(fffCompatEntrypoint)) {
|
|
39
|
+
failures.push(`${fffCompatEntrypoint} (FFF compatibility extension entrypoint not declared)`);
|
|
40
|
+
}
|
|
41
|
+
if (manifest.pi.extensions.includes("./node_modules/@nklisch/pi-fff-compat/extensions")) {
|
|
42
|
+
failures.push("node_modules/@nklisch/pi-fff-compat/extensions (directory would load non-extension helpers)");
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
for (const declaredPath of declared) {
|
|
38
46
|
// A declared path may be a directory, a single file, or a SKILL.md path.
|
|
39
47
|
// It is satisfied when the tarball contains the path itself or anything
|