@nklisch/pi-enhanced 0.2.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-x64-gnu.node +0 -0
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
- package/node_modules/@nklisch/pi-plugins/README.md +4 -2
- package/node_modules/@nklisch/pi-plugins/dist/hooks.js +81 -8
- package/node_modules/@nklisch/pi-plugins/dist/hooks.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/mcp.js +32 -1
- package/node_modules/@nklisch/pi-plugins/dist/mcp.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +10 -4
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +144 -10
- package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +40 -14
- package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +19 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +75 -47
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/public.d.ts +171 -140
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/settings.d.ts +2 -2
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/FORK-MAINTENANCE.md +8 -7
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/VISION.md +12 -10
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +111 -104
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/comparison-with-upstream.md +3 -3
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0004-reconsider-ui-direction.md +5 -0
- 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/config/custom-agents.ts +31 -4
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/default-agents.ts +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/invocation-config.ts +7 -4
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/index.ts +18 -9
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/layered-settings.ts +2 -2
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +10 -63
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +122 -76
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +3 -5
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +4 -3
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +2 -23
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +177 -252
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +90 -280
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-state.ts +153 -239
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +569 -700
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace.ts +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/notification.ts +78 -122
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/renderer.ts +15 -42
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +44 -95
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service-adapter.ts +157 -90
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service.ts +60 -68
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/conversation.ts +3 -3
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query-source.ts +28 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query.ts +327 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/settings.ts +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/agent-tool.ts +171 -249
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-tool.ts +51 -94
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/helpers.ts +9 -6
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/list-tool.ts +46 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/parent-tool-registry.ts +17 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/query-session-tool.ts +330 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/result-renderer.ts +27 -111
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/resume-tool.ts +105 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/spawn-config.ts +26 -8
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/steer-tool.ts +27 -111
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/stop-tool.ts +53 -0
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/types.ts +11 -5
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +33 -27
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/display.ts +6 -3
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigation.ts +121 -10
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +430 -112
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/subagents-settings.ts +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/widget-renderer.ts +8 -9
- package/node_modules/@nklisch/pi-plugins/package.json +2 -2
- package/package.json +1 -1
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/background-spawner.ts +0 -81
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +0 -149
- package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-report.ts +0 -77
|
@@ -1,52 +1,39 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* subagent-manager.ts - Tracks subagents, background execution, resume support.
|
|
3
|
-
*
|
|
4
|
-
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
5
|
-
* Excess agents are scheduled on a ConcurrencyLimiter and auto-started as running
|
|
6
|
-
* agents complete. Foreground agents bypass the limiter (they block the parent anyway).
|
|
7
|
-
*/
|
|
1
|
+
/** Central parent-only registry for child run admission and lifecycle ownership. */
|
|
8
2
|
|
|
9
3
|
import { randomUUID } from "node:crypto";
|
|
10
4
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
5
|
import { debugLog, runDetached, runSafely } from "#src/debug";
|
|
12
|
-
import type { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
|
|
6
|
+
import type { AdmissionHandle, ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
|
|
13
7
|
import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
|
|
14
|
-
import {
|
|
8
|
+
import type {
|
|
15
9
|
LifecycleInterceptorRegistry,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
SubagentExecutionAdmission,
|
|
11
|
+
SubagentExecutionOrigin,
|
|
12
|
+
SubagentLifecycleInterceptor,
|
|
13
|
+
SubagentLifecycleRegistration,
|
|
20
14
|
} from "#src/lifecycle/lifecycle-interceptor";
|
|
15
|
+
import { LifecycleInterceptorRegistry as InterceptorRegistry } from "#src/lifecycle/lifecycle-interceptor";
|
|
21
16
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
22
17
|
import { Subagent, type SubagentLifecycleObserver } from "#src/lifecycle/subagent";
|
|
23
18
|
import type { SubagentSession } from "#src/lifecycle/subagent-session";
|
|
24
|
-
import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
19
|
+
import { SubagentState, type SubagentStatus, type SubagentStopReason } from "#src/lifecycle/subagent-state";
|
|
25
20
|
import type { WorkspaceProvider } from "#src/lifecycle/workspace";
|
|
26
|
-
|
|
27
21
|
import type { RunConfig } from "#src/runtime";
|
|
28
|
-
import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
|
|
22
|
+
import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentMode, SubagentType, ThinkingLevel } from "#src/types";
|
|
29
23
|
|
|
30
|
-
/** Observer interface for agent lifecycle notifications. */
|
|
31
24
|
export interface SubagentManagerObserver {
|
|
32
25
|
onSubagentStarted(record: Subagent): void;
|
|
33
26
|
onSubagentCompleted(record: Subagent): void;
|
|
34
|
-
/** Fires when a retained session begins a resumed turn. */
|
|
35
27
|
onSubagentResumedStarted?(record: Subagent): void;
|
|
36
28
|
onSubagentResumed?(record: Subagent): void;
|
|
37
|
-
/** Fires when clearCompleted removes a terminal record from the parent session. */
|
|
38
29
|
onSubagentCleared?(record: Subagent): void;
|
|
39
30
|
onSubagentCompacted(record: Subagent, info: CompactionInfo): void;
|
|
40
|
-
/** Fires synchronously after a background agent record is created (before run). */
|
|
41
31
|
onSubagentCreated(record: Subagent): void;
|
|
42
32
|
}
|
|
43
33
|
|
|
44
34
|
export interface SubagentManagerOptions {
|
|
45
|
-
/** Assembly factory that produces a born-complete SubagentSession per spawn. */
|
|
46
35
|
createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
47
|
-
/** Concurrency limiter — schedules background run thunks FIFO against the limit. */
|
|
48
36
|
limiter: ConcurrencyLimiter;
|
|
49
|
-
/** Base working directory handed to a workspace provider (the parent cwd). */
|
|
50
37
|
baseCwd: string;
|
|
51
38
|
getRunConfig?: () => RunConfig;
|
|
52
39
|
observer?: SubagentManagerObserver;
|
|
@@ -58,329 +45,225 @@ export interface AgentSpawnConfig {
|
|
|
58
45
|
maxTurns?: number;
|
|
59
46
|
inheritContext?: boolean;
|
|
60
47
|
thinkingLevel?: ThinkingLevel;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* Skip the maxConcurrent queue check for this spawn - start immediately even
|
|
64
|
-
* if the configured concurrency limit would otherwise queue it. Useful for
|
|
65
|
-
* callers (e.g. cross-extension RPC) that must not be deferred by the queue.
|
|
66
|
-
*/
|
|
67
|
-
bypassQueue?: boolean;
|
|
68
|
-
/** Resolved invocation snapshot captured for UI display. */
|
|
48
|
+
mode: SubagentMode;
|
|
49
|
+
timeoutSeconds?: number;
|
|
69
50
|
invocation?: AgentInvocation;
|
|
70
|
-
/** Parent abort signal - when aborted, the subagent is also stopped. */
|
|
71
51
|
signal?: AbortSignal;
|
|
72
|
-
/** Per-subagent lifecycle observer — replaces onSessionCreated callback. */
|
|
73
52
|
observer?: SubagentLifecycleObserver;
|
|
74
|
-
/** Parent session identity - grouped fields that travel together from the tool boundary. */
|
|
75
53
|
parentSession?: ParentSessionInfo;
|
|
76
|
-
/** Identity known to a service caller without changing its existing session setup. */
|
|
77
54
|
lifecycleParentSession?: ParentSessionInfo;
|
|
78
|
-
/** Which supported public entry path created this execution. */
|
|
79
55
|
origin?: SubagentExecutionOrigin;
|
|
56
|
+
/** Internal hook used by joined tool delivery to stream after record creation. */
|
|
57
|
+
onCreated?: (record: Subagent) => void;
|
|
80
58
|
}
|
|
81
59
|
|
|
60
|
+
export type DeliveryOutcome =
|
|
61
|
+
| { kind: "detached"; agentId: string; runId: number }
|
|
62
|
+
| { kind: "joined"; record: Subagent };
|
|
63
|
+
|
|
64
|
+
export type ResumeOutcome = DeliveryOutcome | { kind: "not_found"; agentId: string } | { kind: "wrong_state"; agentId: string; status: SubagentStatus };
|
|
65
|
+
|
|
66
|
+
export type StopOutcome =
|
|
67
|
+
| { kind: "stopped"; agentId: string; runId: number; reason: SubagentStopReason; record: Subagent }
|
|
68
|
+
| { kind: "stop_pending"; agentId: string; runId: number; reason: SubagentStopReason; record: Subagent }
|
|
69
|
+
| { kind: "already_terminal"; agentId: string; runId: number; record: Subagent }
|
|
70
|
+
| { kind: "not_found"; agentId: string };
|
|
71
|
+
|
|
72
|
+
export type ManagerSteerOutcome =
|
|
73
|
+
| { kind: "not_found"; agentId: string }
|
|
74
|
+
| ({ kind: "rejected"; status: SubagentStatus; runId: number })
|
|
75
|
+
| ({ kind: "delivered" | "buffered"; runId: number });
|
|
76
|
+
|
|
82
77
|
export class SubagentManager {
|
|
83
|
-
private agents = new Map<string, Subagent>();
|
|
84
|
-
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
78
|
+
private readonly agents = new Map<string, Subagent>();
|
|
79
|
+
private readonly cleanupInterval: ReturnType<typeof setInterval>;
|
|
85
80
|
private readonly observer?: SubagentManagerObserver;
|
|
86
81
|
private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
87
82
|
private readonly limiter: ConcurrencyLimiter;
|
|
88
83
|
private readonly baseCwd: string;
|
|
89
|
-
private getRunConfig?: () => RunConfig;
|
|
90
|
-
private
|
|
91
|
-
private readonly lifecycleInterceptors = new
|
|
84
|
+
private readonly getRunConfig?: () => RunConfig;
|
|
85
|
+
private workspace?: WorkspaceProvider;
|
|
86
|
+
private readonly lifecycleInterceptors: LifecycleInterceptorRegistry = new InterceptorRegistry();
|
|
92
87
|
private disposalPromise?: Promise<void>;
|
|
93
88
|
|
|
94
|
-
/** The registered workspace provider, or undefined when none is registered. */
|
|
95
|
-
get workspaceProvider(): WorkspaceProvider | undefined {
|
|
96
|
-
return this._workspaceProvider;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
89
|
constructor(options: SubagentManagerOptions) {
|
|
100
90
|
this.createSubagentSession = options.createSubagentSession;
|
|
101
91
|
this.limiter = options.limiter;
|
|
102
92
|
this.baseCwd = options.baseCwd;
|
|
103
93
|
this.observer = options.observer;
|
|
104
94
|
this.getRunConfig = options.getRunConfig;
|
|
105
|
-
|
|
106
|
-
// Timer callbacks sit outside Pi's extension runner, so a malformed setting
|
|
107
|
-
// or disposal failure must remain diagnostic rather than escape into Node.
|
|
108
|
-
this.cleanupInterval = setInterval(() => {
|
|
109
|
-
runDetached("retention cleanup", () => this.cleanup());
|
|
110
|
-
}, 60_000);
|
|
95
|
+
this.cleanupInterval = setInterval(() => runDetached("retention cleanup", () => this.cleanup()), 60_000);
|
|
111
96
|
this.cleanupInterval.unref();
|
|
112
97
|
}
|
|
113
98
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
* registered (chaining is out of scope — see ADR 0002). Returns a disposer
|
|
117
|
-
* that clears the slot only if this provider is still the active one.
|
|
118
|
-
*/
|
|
99
|
+
get workspaceProvider(): WorkspaceProvider | undefined { return this.workspace; }
|
|
100
|
+
|
|
119
101
|
registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
|
|
120
|
-
if (this.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
this._workspaceProvider = provider;
|
|
126
|
-
return () => {
|
|
127
|
-
if (this._workspaceProvider === provider) this._workspaceProvider = undefined;
|
|
128
|
-
};
|
|
102
|
+
if (this.workspace) throw new Error("A WorkspaceProvider is already registered; only one is supported.");
|
|
103
|
+
this.workspace = provider;
|
|
104
|
+
return () => { if (this.workspace === provider) this.workspace = undefined; };
|
|
129
105
|
}
|
|
130
106
|
|
|
131
|
-
|
|
132
|
-
registerLifecycleInterceptor(
|
|
133
|
-
interceptor: SubagentLifecycleInterceptor,
|
|
134
|
-
): SubagentLifecycleRegistration {
|
|
107
|
+
registerLifecycleInterceptor(interceptor: SubagentLifecycleInterceptor): SubagentLifecycleRegistration {
|
|
135
108
|
return this.lifecycleInterceptors.register(interceptor);
|
|
136
109
|
}
|
|
137
110
|
|
|
138
|
-
/** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
|
|
139
111
|
private buildObserver(options: AgentSpawnConfig): SubagentLifecycleObserver {
|
|
140
112
|
return {
|
|
141
|
-
onStarted: (
|
|
142
|
-
"onSubagentStarted observer",
|
|
143
|
-
() => this.observer?.onSubagentStarted(agent),
|
|
144
|
-
),
|
|
113
|
+
onStarted: (record) => runSafely("onSubagentStarted observer", () => this.observer?.onSubagentStarted(record)),
|
|
145
114
|
onSessionCreated: options.observer?.onSessionCreated
|
|
146
|
-
? (
|
|
147
|
-
"onSessionCreated observer",
|
|
148
|
-
() => options.observer!.onSessionCreated!(agent),
|
|
149
|
-
)
|
|
115
|
+
? (record) => runSafely("onSessionCreated observer", () => options.observer!.onSessionCreated!(record))
|
|
150
116
|
: undefined,
|
|
151
|
-
onRunFinished: (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
},
|
|
156
|
-
onResumedFinished: (agent) => runSafely(
|
|
157
|
-
"onSubagentResumed observer",
|
|
158
|
-
() => this.observer?.onSubagentResumed?.(agent),
|
|
159
|
-
),
|
|
160
|
-
onCompacted: (agent, info) => runSafely(
|
|
161
|
-
"onSubagentCompacted observer",
|
|
162
|
-
() => this.observer?.onSubagentCompacted(agent, info),
|
|
163
|
-
),
|
|
117
|
+
onRunFinished: (record) => runSafely("onSubagentCompleted observer", () => this.observer?.onSubagentCompleted(record)),
|
|
118
|
+
onResumedStarted: (record) => runSafely("onSubagentResumedStarted observer", () => this.observer?.onSubagentResumedStarted?.(record)),
|
|
119
|
+
onResumedFinished: (record) => runSafely("onSubagentResumed observer", () => this.observer?.onSubagentResumed?.(record)),
|
|
120
|
+
onCompacted: (record, info) => runSafely("onSubagentCompacted observer", () => this.observer?.onSubagentCompacted(record, info)),
|
|
164
121
|
};
|
|
165
122
|
}
|
|
166
123
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
* If the concurrency limit is reached, the agent is queued.
|
|
170
|
-
*/
|
|
171
|
-
spawn(
|
|
172
|
-
snapshot: ParentSnapshot,
|
|
173
|
-
type: SubagentType,
|
|
174
|
-
prompt: string,
|
|
175
|
-
options: AgentSpawnConfig,
|
|
176
|
-
): string {
|
|
124
|
+
/** Synchronous creation plus shared FIFO admission. */
|
|
125
|
+
spawn(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: AgentSpawnConfig): Subagent {
|
|
177
126
|
const id = randomUUID().slice(0, 17);
|
|
178
|
-
const admission: SubagentExecutionAdmission =
|
|
179
|
-
? "queued"
|
|
180
|
-
: "immediate";
|
|
127
|
+
const admission: SubagentExecutionAdmission = this.limiter.isSaturated() ? "queued" : "immediate";
|
|
181
128
|
const record = new Subagent({
|
|
182
129
|
id,
|
|
183
130
|
type,
|
|
184
131
|
description: options.description,
|
|
185
132
|
invocation: options.invocation,
|
|
186
|
-
state: new SubagentState(
|
|
187
|
-
status: options.isBackground ? "queued" : "running",
|
|
188
|
-
startedAt: Date.now(),
|
|
189
|
-
}),
|
|
133
|
+
state: new SubagentState(),
|
|
190
134
|
execution: {
|
|
191
135
|
createSubagentSession: this.createSubagentSession,
|
|
192
136
|
snapshot,
|
|
193
137
|
prompt,
|
|
194
138
|
baseCwd: this.baseCwd,
|
|
139
|
+
mode: options.mode,
|
|
140
|
+
timeoutSeconds: options.timeoutSeconds,
|
|
195
141
|
observer: this.buildObserver(options),
|
|
196
142
|
getRunConfig: this.getRunConfig,
|
|
197
|
-
getWorkspaceProvider: () => this.
|
|
143
|
+
getWorkspaceProvider: () => this.workspace,
|
|
198
144
|
model: options.model,
|
|
199
145
|
maxTurns: options.maxTurns,
|
|
200
146
|
thinkingLevel: options.thinkingLevel,
|
|
201
147
|
parentSession: options.parentSession,
|
|
202
148
|
lifecycleParentSession: options.lifecycleParentSession,
|
|
203
|
-
signal: options.signal,
|
|
204
149
|
lifecycleInterceptors: this.lifecycleInterceptors,
|
|
205
150
|
executionPath: {
|
|
206
|
-
phase: "initial",
|
|
207
151
|
origin: options.origin ?? "service",
|
|
208
|
-
mode: options.isBackground ? "background" : "foreground",
|
|
209
152
|
admission,
|
|
210
153
|
},
|
|
211
154
|
},
|
|
212
155
|
});
|
|
213
156
|
this.agents.set(id, record);
|
|
157
|
+
runSafely("onSubagentCreated hook", () => options.onCreated?.(record));
|
|
158
|
+
runSafely("onSubagentCreated observer", () => this.observer?.onSubagentCreated(record));
|
|
214
159
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// Schedule on the limiter — scheduleVia captures the limiter promise
|
|
221
|
-
// eagerly, so a queued agent is awaitable from spawn; guardedRun guards
|
|
222
|
-
// against abort-while-queued when the slot frees.
|
|
223
|
-
record.scheduleVia((thunk) => this.limiter.schedule(thunk));
|
|
224
|
-
return id;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
record.start();
|
|
228
|
-
return id;
|
|
160
|
+
// A joined caller is armed before it can enter the queue. Detached callers
|
|
161
|
+
// deliberately omit the signal so parent tool settlement cannot stop them.
|
|
162
|
+
if (options.mode === "joined") record.armParentSignal(options.signal);
|
|
163
|
+
record.scheduleVia((task) => this.limiter.schedule(task));
|
|
164
|
+
return record;
|
|
229
165
|
}
|
|
230
166
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
type
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const id = this.spawn(snapshot, type, prompt, { ...options, isBackground: false });
|
|
242
|
-
const record = this.agents.get(id)!;
|
|
243
|
-
await record.promise;
|
|
244
|
-
return record;
|
|
167
|
+
async launch(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: AgentSpawnConfig): Promise<DeliveryOutcome> {
|
|
168
|
+
// An already-cancelled detached call is rejected before creating work;
|
|
169
|
+
// once accepted, detached execution is independent of later cancellation.
|
|
170
|
+
if (options.signal?.aborted && options.mode === "detached") {
|
|
171
|
+
throw options.signal.reason instanceof Error ? options.signal.reason : new Error("Detached launch was already cancelled");
|
|
172
|
+
}
|
|
173
|
+
const record = this.spawn(snapshot, type, prompt, options);
|
|
174
|
+
if (options.mode === "detached") return { kind: "detached", agentId: record.id, runId: record.runId };
|
|
175
|
+
await record.settlement;
|
|
176
|
+
return { kind: "joined", record };
|
|
245
177
|
}
|
|
246
178
|
|
|
247
|
-
/**
|
|
248
|
-
* Resume an existing agent session with a new prompt.
|
|
249
|
-
* Delegates to Subagent.resume(), which owns the observer subscription lifecycle.
|
|
250
|
-
*/
|
|
251
179
|
async resume(
|
|
252
180
|
id: string,
|
|
253
181
|
prompt: string,
|
|
182
|
+
mode: SubagentMode,
|
|
183
|
+
timeoutSeconds: number | undefined,
|
|
254
184
|
signal?: AbortSignal,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
} catch (err) {
|
|
262
|
-
debugLog("onSubagentResumedStarted observer", err);
|
|
185
|
+
onReserved?: (record: Subagent) => void,
|
|
186
|
+
): Promise<ResumeOutcome> {
|
|
187
|
+
const record = this.agents.get(id);
|
|
188
|
+
if (!record) return { kind: "not_found", agentId: id };
|
|
189
|
+
if (signal?.aborted && mode === "detached") {
|
|
190
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("Detached resume was already cancelled");
|
|
263
191
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
listAgents(): Subagent[] {
|
|
273
|
-
return [...this.agents.values()].sort(
|
|
274
|
-
(a, b) => b.startedAt - a.startedAt,
|
|
192
|
+
const admission: SubagentExecutionAdmission = this.limiter.isSaturated() ? "queued" : "immediate";
|
|
193
|
+
const result = record.reserveResume(
|
|
194
|
+
prompt,
|
|
195
|
+
mode,
|
|
196
|
+
timeoutSeconds,
|
|
197
|
+
(task) => this.limiter.schedule(task),
|
|
198
|
+
mode === "joined" ? signal : undefined,
|
|
199
|
+
admission,
|
|
275
200
|
);
|
|
201
|
+
if (!result.accepted) return { kind: "wrong_state", agentId: id, status: result.status ?? record.status };
|
|
202
|
+
runSafely("onSubagentResumed reserved hook", () => onReserved?.(record));
|
|
203
|
+
if (mode === "detached") return { kind: "detached", agentId: id, runId: result.runId };
|
|
204
|
+
await record.settlement;
|
|
205
|
+
return { kind: "joined", record };
|
|
276
206
|
}
|
|
277
207
|
|
|
278
|
-
|
|
208
|
+
async stop(id: string, settlementTimeoutSeconds = 5, waitSignal?: AbortSignal): Promise<StopOutcome> {
|
|
279
209
|
const record = this.agents.get(id);
|
|
280
|
-
if (!record) return
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
210
|
+
if (!record) return { kind: "not_found", agentId: id };
|
|
211
|
+
const runId = record.runId;
|
|
212
|
+
if (!record.isActive()) return { kind: "already_terminal", agentId: id, runId, record };
|
|
213
|
+
|
|
214
|
+
// The child stop request is deliberately independent of the caller's wait:
|
|
215
|
+
// aborting the parent tool must not undo cancellation, but it may return the
|
|
216
|
+
// bounded stop report early when the child is uncooperative.
|
|
217
|
+
record.requestStop("explicit_stop");
|
|
218
|
+
const settled = await waitWithTimeout(record.settlement, settlementTimeoutSeconds * 1000, waitSignal);
|
|
219
|
+
const reason = record.stopReason ?? "explicit_stop";
|
|
220
|
+
return settled
|
|
221
|
+
? { kind: "stopped", agentId: id, runId, reason, record }
|
|
222
|
+
: { kind: "stop_pending", agentId: id, runId, reason, record };
|
|
290
223
|
}
|
|
291
224
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
this.agents.delete(id);
|
|
297
|
-
await record.disposeSession();
|
|
225
|
+
async steer(id: string, message: string): Promise<ManagerSteerOutcome> {
|
|
226
|
+
const record = this.agents.get(id);
|
|
227
|
+
if (!record) return { kind: "not_found", agentId: id };
|
|
228
|
+
return record.steer(message);
|
|
298
229
|
}
|
|
299
230
|
|
|
300
|
-
|
|
301
|
-
const now = Date.now();
|
|
302
|
-
const config = this.getRunConfig?.();
|
|
303
|
-
const consumedMinutes = config?.consumedSessionRetentionMinutes ?? 10;
|
|
304
|
-
const unconsumedMinutes = config?.unconsumedSessionRetentionMinutes ?? 720;
|
|
231
|
+
getRecord(id: string): Subagent | undefined { return this.agents.get(id); }
|
|
305
232
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (record.isActive() || !record.isSessionReady()) continue;
|
|
309
|
-
const anchor = record.consumed
|
|
310
|
-
? record.consumedAt ?? record.completedAt
|
|
311
|
-
: record.completedAt;
|
|
312
|
-
if (anchor == null) continue;
|
|
313
|
-
const retentionMinutes = record.consumed ? consumedMinutes : unconsumedMinutes;
|
|
314
|
-
if (anchor + retentionMinutes * 60_000 > now) continue;
|
|
315
|
-
// Keep the lightweight terminal record and result for the whole parent
|
|
316
|
-
// session; only release the heavy in-memory child session.
|
|
317
|
-
releases.push(record.releaseSession());
|
|
318
|
-
}
|
|
319
|
-
await Promise.all(releases);
|
|
233
|
+
listAgents(): Subagent[] {
|
|
234
|
+
return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
320
235
|
}
|
|
321
236
|
|
|
322
|
-
/**
|
|
323
|
-
* Remove all completed/stopped/errored records immediately.
|
|
324
|
-
* Called on session start/switch so tasks from a prior session don't persist.
|
|
325
|
-
*/
|
|
326
237
|
async clearCompleted(): Promise<void> {
|
|
327
238
|
const disposals: Promise<void>[] = [];
|
|
328
239
|
for (const [id, record] of this.agents) {
|
|
329
240
|
if (record.isActive()) continue;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
debugLog("onSubagentCleared observer", err);
|
|
334
|
-
}
|
|
335
|
-
disposals.push(this.removeRecord(id, record));
|
|
241
|
+
runSafely("onSubagentCleared observer", () => this.observer?.onSubagentCleared?.(record));
|
|
242
|
+
this.agents.delete(id);
|
|
243
|
+
disposals.push(record.disposeSession());
|
|
336
244
|
}
|
|
337
245
|
await Promise.all(disposals);
|
|
338
246
|
}
|
|
339
247
|
|
|
340
|
-
|
|
341
|
-
// fallow-ignore-next-line unused-class-member
|
|
342
|
-
hasRunning(): boolean {
|
|
343
|
-
return [...this.agents.values()].some((record) => record.isActive());
|
|
344
|
-
}
|
|
248
|
+
hasRunning(): boolean { return [...this.agents.values()].some((record) => record.isActive()); }
|
|
345
249
|
|
|
346
|
-
/**
|
|
347
|
-
// fallow-ignore-next-line unused-class-member
|
|
250
|
+
/** Parent interruption is a cancellation request, not an immediate terminal transition. */
|
|
348
251
|
abortAll(): number {
|
|
349
252
|
let count = 0;
|
|
350
253
|
for (const record of this.agents.values()) {
|
|
351
|
-
if (record.
|
|
352
|
-
record.stopQueued();
|
|
353
|
-
count++;
|
|
354
|
-
} else if (record.abort()) {
|
|
355
|
-
count++;
|
|
356
|
-
}
|
|
254
|
+
if (record.requestStop("parent_cancelled")) count++;
|
|
357
255
|
}
|
|
358
|
-
// Drop pending thunks (their promises resolve).
|
|
359
|
-
this.limiter.clear();
|
|
360
256
|
return count;
|
|
361
257
|
}
|
|
362
258
|
|
|
363
|
-
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
364
|
-
// fallow-ignore-next-line unused-class-member
|
|
365
259
|
async waitForAll(): Promise<void> {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
let pending = this.pendingPromises();
|
|
370
|
-
while (pending.length > 0) {
|
|
260
|
+
for (;;) {
|
|
261
|
+
const pending = [...this.agents.values()].filter((record) => record.isActive()).map((record) => record.settlement);
|
|
262
|
+
if (pending.length === 0) return;
|
|
371
263
|
await Promise.allSettled(pending);
|
|
372
|
-
pending = this.pendingPromises();
|
|
373
264
|
}
|
|
374
265
|
}
|
|
375
266
|
|
|
376
|
-
/** Promises of all running/queued agents that have one. */
|
|
377
|
-
private pendingPromises(): Promise<void>[] {
|
|
378
|
-
return [...this.agents.values()]
|
|
379
|
-
.filter((record) => record.isActive())
|
|
380
|
-
.map(r => r.promise)
|
|
381
|
-
.filter((p): p is Promise<void> => p != null);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
267
|
dispose(): Promise<void> {
|
|
385
268
|
this.disposalPromise ??= this.disposeOnce();
|
|
386
269
|
return this.disposalPromise;
|
|
@@ -388,19 +271,61 @@ export class SubagentManager {
|
|
|
388
271
|
|
|
389
272
|
private async disposeOnce(): Promise<void> {
|
|
390
273
|
clearInterval(this.cleanupInterval);
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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();
|
|
274
|
+
// Abort first so in-flight lifecycle callbacks receive the same cooperative
|
|
275
|
+
// signal as provider and tool work before their registrations are retired.
|
|
276
|
+
this.abortAll();
|
|
277
|
+
const interceptorsDisposed = await waitWithTimeout(this.lifecycleInterceptors.dispose(), 5_000);
|
|
278
|
+
if (!interceptorsDisposed) debugLog("lifecycle interceptor shutdown timeout", new Error("A lifecycle interceptor did not cooperate before shutdown"));
|
|
279
|
+
const settled = await waitWithTimeout(this.waitForAll(), 5_000);
|
|
280
|
+
if (!settled) debugLog("manager shutdown settlement timeout", new Error("One or more subagents did not cooperate before shutdown"));
|
|
402
281
|
const records = [...this.agents.values()];
|
|
403
282
|
this.agents.clear();
|
|
404
283
|
await Promise.all(records.map((record) => record.disposeSession()));
|
|
405
284
|
}
|
|
285
|
+
|
|
286
|
+
private async cleanup(): Promise<void> {
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
const config = this.getRunConfig?.();
|
|
289
|
+
const consumedRetentionMinutes = config?.consumedSessionRetentionMinutes ?? 10;
|
|
290
|
+
const unconsumedRetentionMinutes = config?.unconsumedSessionRetentionMinutes ?? 720;
|
|
291
|
+
const releases: Promise<void>[] = [];
|
|
292
|
+
for (const record of this.agents.values()) {
|
|
293
|
+
if (record.isActive() || !record.isSessionReady() || record.completedAt == null) continue;
|
|
294
|
+
// Consumption changes the retention clock: once a result has actually
|
|
295
|
+
// been delivered, retain it for the shorter consumed window and anchor
|
|
296
|
+
// that window at delivery. Unconsumed sessions retain the existing
|
|
297
|
+
// longer grace period from completion so a parent can still resume them.
|
|
298
|
+
const retentionMinutes = record.consumed ? consumedRetentionMinutes : unconsumedRetentionMinutes;
|
|
299
|
+
const anchor = record.consumed ? (record.consumedAt ?? record.completedAt) : record.completedAt;
|
|
300
|
+
if (anchor + retentionMinutes * 60_000 > now) continue;
|
|
301
|
+
releases.push(record.releaseSession());
|
|
302
|
+
}
|
|
303
|
+
await Promise.all(releases);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function waitWithTimeout(promise: Promise<void>, timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
308
|
+
if (timeoutMs <= 0 || signal?.aborted) return false;
|
|
309
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
310
|
+
let removeAbort: (() => void) | undefined;
|
|
311
|
+
const timeout = new Promise<false>((resolve) => {
|
|
312
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
313
|
+
});
|
|
314
|
+
const callerAbort = signal
|
|
315
|
+
? new Promise<false>((resolve) => {
|
|
316
|
+
const onAbort = (): void => resolve(false);
|
|
317
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
318
|
+
removeAbort = () => signal.removeEventListener("abort", onAbort);
|
|
319
|
+
})
|
|
320
|
+
: undefined;
|
|
321
|
+
try {
|
|
322
|
+
return await Promise.race([
|
|
323
|
+
promise.then(() => true as const),
|
|
324
|
+
timeout,
|
|
325
|
+
...(callerAbort ? [callerAbort] : []),
|
|
326
|
+
]);
|
|
327
|
+
} finally {
|
|
328
|
+
if (timer) clearTimeout(timer);
|
|
329
|
+
removeAbort?.();
|
|
330
|
+
}
|
|
406
331
|
}
|