@melihmucuk/pi-crew 1.0.13 → 1.0.15
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/README.md +19 -18
- package/agents/code-reviewer.md +52 -104
- package/agents/oracle.md +26 -52
- package/agents/planner.md +7 -7
- package/agents/quality-reviewer.md +90 -131
- package/agents/scout.md +3 -2
- package/agents/worker.md +8 -2
- package/extension/agent-discovery.ts +791 -0
- package/extension/bootstrap-session.ts +131 -0
- package/extension/index.ts +63 -0
- package/extension/integration/register-renderers.ts +77 -0
- package/extension/integration/register-tools.ts +39 -0
- package/extension/integration/tool-presentation.ts +50 -0
- package/extension/integration/tools/crew-abort.ts +126 -0
- package/extension/integration/tools/crew-done.ts +46 -0
- package/extension/integration/tools/crew-list.ts +92 -0
- package/extension/integration/tools/crew-respond.ts +59 -0
- package/extension/integration/tools/crew-spawn.ts +87 -0
- package/extension/integration/tools/tool-deps.ts +16 -0
- package/extension/integration.ts +13 -0
- package/extension/runtime/crew-runtime.ts +426 -0
- package/extension/runtime/delivery-coordinator.ts +131 -0
- package/extension/runtime/overflow-recovery.ts +211 -0
- package/extension/runtime/subagent-registry.ts +78 -0
- package/extension/runtime/subagent-state.ts +59 -0
- package/extension/status-widget.ts +107 -0
- package/extension/subagent-messages.ts +124 -0
- package/extension/tool-registry.ts +19 -0
- package/package.json +14 -14
- package/prompts/pi-crew-plan.md +46 -37
- package/prompts/pi-crew-review.md +3 -1
- package/skills/pi-crew/SKILL.md +129 -0
- package/dist/agent-discovery.d.ts +0 -29
- package/dist/agent-discovery.js +0 -527
- package/dist/bootstrap-session.d.ts +0 -21
- package/dist/bootstrap-session.js +0 -74
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -46
- package/dist/integration/register-command.d.ts +0 -3
- package/dist/integration/register-command.js +0 -51
- package/dist/integration/register-renderers.d.ts +0 -2
- package/dist/integration/register-renderers.js +0 -59
- package/dist/integration/register-tools.d.ts +0 -3
- package/dist/integration/register-tools.js +0 -25
- package/dist/integration/tool-presentation.d.ts +0 -27
- package/dist/integration/tool-presentation.js +0 -29
- package/dist/integration/tools/crew-abort.d.ts +0 -2
- package/dist/integration/tools/crew-abort.js +0 -79
- package/dist/integration/tools/crew-done.d.ts +0 -2
- package/dist/integration/tools/crew-done.js +0 -28
- package/dist/integration/tools/crew-list.d.ts +0 -2
- package/dist/integration/tools/crew-list.js +0 -74
- package/dist/integration/tools/crew-respond.d.ts +0 -2
- package/dist/integration/tools/crew-respond.js +0 -32
- package/dist/integration/tools/crew-spawn.d.ts +0 -2
- package/dist/integration/tools/crew-spawn.js +0 -48
- package/dist/integration/tools/tool-deps.d.ts +0 -9
- package/dist/integration/tools/tool-deps.js +0 -1
- package/dist/integration.d.ts +0 -3
- package/dist/integration.js +0 -8
- package/dist/runtime/crew-runtime.d.ts +0 -62
- package/dist/runtime/crew-runtime.js +0 -285
- package/dist/runtime/delivery-coordinator.d.ts +0 -26
- package/dist/runtime/delivery-coordinator.js +0 -86
- package/dist/runtime/overflow-recovery.d.ts +0 -3
- package/dist/runtime/overflow-recovery.js +0 -155
- package/dist/runtime/subagent-registry.d.ts +0 -14
- package/dist/runtime/subagent-registry.js +0 -58
- package/dist/runtime/subagent-state.d.ts +0 -35
- package/dist/runtime/subagent-state.js +0 -32
- package/dist/status-widget.d.ts +0 -3
- package/dist/status-widget.js +0 -84
- package/dist/subagent-messages.d.ts +0 -37
- package/dist/subagent-messages.js +0 -68
- package/dist/tool-registry.d.ts +0 -5
- package/dist/tool-registry.js +0 -13
- package/docs/architecture.md +0 -187
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { discoverAgents } from "../../agent-discovery.js";
|
|
4
|
+
import {
|
|
5
|
+
renderCrewCall,
|
|
6
|
+
renderCrewResult,
|
|
7
|
+
toolError,
|
|
8
|
+
toolSuccess,
|
|
9
|
+
} from "../tool-presentation.js";
|
|
10
|
+
import type { CrewToolDeps } from "./tool-deps.js";
|
|
11
|
+
|
|
12
|
+
export function registerCrewSpawnTool({
|
|
13
|
+
pi,
|
|
14
|
+
crew,
|
|
15
|
+
extensionDir,
|
|
16
|
+
notifyDiscoveryWarnings,
|
|
17
|
+
}: CrewToolDeps): void {
|
|
18
|
+
pi.registerTool({
|
|
19
|
+
name: "crew_spawn",
|
|
20
|
+
label: "Spawn Crew",
|
|
21
|
+
description:
|
|
22
|
+
"Spawn a non-blocking subagent that runs in an isolated session. The subagent works independently while your session stays interactive. Results are delivered back to your session as steering messages.",
|
|
23
|
+
parameters: Type.Object({
|
|
24
|
+
subagent: Type.String({ description: "Subagent name from crew_list" }),
|
|
25
|
+
task: Type.String({ description: "Task to delegate to the subagent" }),
|
|
26
|
+
}),
|
|
27
|
+
promptSnippet:
|
|
28
|
+
"Spawn a non-blocking subagent. Use crew_list first to see available subagents.",
|
|
29
|
+
promptGuidelines: [
|
|
30
|
+
"crew_spawn: Spawn a discovered subagent for one clearly delegated, self-contained task.",
|
|
31
|
+
"crew_spawn: Include only needed context: constraints, relevant files, acceptance criteria, and expected output.",
|
|
32
|
+
"crew_spawn: After spawning, ownership transfers to the subagent; do not work on that task yourself.",
|
|
33
|
+
"crew_spawn: Results arrive as steering messages; do not poll crew_list or fabricate results.",
|
|
34
|
+
"crew_spawn: Use the bundled pi-crew skill for detailed delegation patterns.",
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
38
|
+
const { agents, warnings } = discoverAgents(ctx.cwd);
|
|
39
|
+
notifyDiscoveryWarnings(ctx, warnings);
|
|
40
|
+
const subagent = agents.find(
|
|
41
|
+
(candidate) => candidate.name === params.subagent,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (!subagent) {
|
|
45
|
+
const available =
|
|
46
|
+
agents.map((candidate) => candidate.name).join(", ") || "none";
|
|
47
|
+
return toolError(
|
|
48
|
+
`Unknown subagent: "${params.subagent}". Available: ${available}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ownerSessionId = ctx.sessionManager.getSessionId();
|
|
53
|
+
const id = crew.spawn(
|
|
54
|
+
subagent,
|
|
55
|
+
params.task,
|
|
56
|
+
ctx.cwd,
|
|
57
|
+
ownerSessionId,
|
|
58
|
+
{
|
|
59
|
+
model: ctx.model,
|
|
60
|
+
modelRegistry: ctx.modelRegistry,
|
|
61
|
+
agentDir: getAgentDir(),
|
|
62
|
+
parentSessionFile: ctx.sessionManager.getSessionFile(),
|
|
63
|
+
onWarning: (msg) => ctx.ui.notify(msg, "warning"),
|
|
64
|
+
},
|
|
65
|
+
extensionDir,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return toolSuccess(
|
|
69
|
+
`Subagent '${subagent.name}' spawned as ${id}. Result will be delivered as a steering message when done.`,
|
|
70
|
+
{ id, agentName: subagent.name, task: params.task },
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
renderCall(args, theme, _context) {
|
|
75
|
+
return renderCrewCall(
|
|
76
|
+
theme,
|
|
77
|
+
"crew_spawn",
|
|
78
|
+
args.subagent || "...",
|
|
79
|
+
args.task,
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
renderResult(result, _options, theme, _context) {
|
|
84
|
+
return renderCrewResult(result, theme);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import type { AgentDiscoveryWarning } from "../../agent-discovery.js";
|
|
6
|
+
import type { CrewRuntime } from "../../runtime/crew-runtime.js";
|
|
7
|
+
|
|
8
|
+
export interface CrewToolDeps {
|
|
9
|
+
pi: ExtensionAPI;
|
|
10
|
+
crew: CrewRuntime;
|
|
11
|
+
extensionDir: string;
|
|
12
|
+
notifyDiscoveryWarnings: (
|
|
13
|
+
ctx: ExtensionContext,
|
|
14
|
+
warnings: AgentDiscoveryWarning[],
|
|
15
|
+
) => void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { CrewRuntime } from "./runtime/crew-runtime.js";
|
|
3
|
+
import { registerCrewMessageRenderers } from "./integration/register-renderers.js";
|
|
4
|
+
import { registerCrewTools } from "./integration/register-tools.js";
|
|
5
|
+
|
|
6
|
+
export function registerCrewIntegration(
|
|
7
|
+
pi: ExtensionAPI,
|
|
8
|
+
crew: CrewRuntime,
|
|
9
|
+
extensionDir: string,
|
|
10
|
+
): void {
|
|
11
|
+
registerCrewTools(pi, crew, extensionDir);
|
|
12
|
+
registerCrewMessageRenderers(pi);
|
|
13
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
|
|
3
|
+
import type { AgentSession, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import type { AgentConfig } from "../agent-discovery.js";
|
|
5
|
+
import type { BootstrapContext } from "../bootstrap-session.js";
|
|
6
|
+
import { bootstrapSession } from "../bootstrap-session.js";
|
|
7
|
+
import type { SubagentStatus } from "../subagent-messages.js";
|
|
8
|
+
import { type ActiveRuntimeBinding, DeliveryCoordinator } from "./delivery-coordinator.js";
|
|
9
|
+
import { runPromptWithOverflowRecovery } from "./overflow-recovery.js";
|
|
10
|
+
import { SubagentRegistry } from "./subagent-registry.js";
|
|
11
|
+
import {
|
|
12
|
+
type ActiveAgentSummary,
|
|
13
|
+
type SubagentState,
|
|
14
|
+
isAbortableStatus,
|
|
15
|
+
isAborted,
|
|
16
|
+
} from "./subagent-state.js";
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
ActiveAgentSummary,
|
|
20
|
+
} from "./subagent-state.js";
|
|
21
|
+
|
|
22
|
+
export interface AbortOwnedResult {
|
|
23
|
+
abortedIds: string[];
|
|
24
|
+
missingIds: string[];
|
|
25
|
+
foreignIds: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface AbortOptions {
|
|
29
|
+
reason: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SpawnContext {
|
|
33
|
+
model: Model<Api> | undefined;
|
|
34
|
+
modelRegistry: ModelRegistry;
|
|
35
|
+
agentDir: string;
|
|
36
|
+
parentSessionFile?: string;
|
|
37
|
+
onWarning?: (message: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toBootstrapContext(ctx: SpawnContext): BootstrapContext {
|
|
41
|
+
return {
|
|
42
|
+
model: ctx.model,
|
|
43
|
+
modelRegistry: ctx.modelRegistry,
|
|
44
|
+
agentDir: ctx.agentDir,
|
|
45
|
+
parentSessionFile: ctx.parentSessionFile,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface PromptOutcome {
|
|
50
|
+
status: Extract<SubagentStatus, "done" | "waiting" | "error" | "aborted">;
|
|
51
|
+
result?: string;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getLastAssistantMessage(
|
|
56
|
+
messages: AgentMessage[],
|
|
57
|
+
): AssistantMessage | undefined {
|
|
58
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
59
|
+
const msg = messages[i];
|
|
60
|
+
if (msg.role === "assistant") {
|
|
61
|
+
return msg as AssistantMessage;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getAssistantText(
|
|
68
|
+
message: AssistantMessage | undefined,
|
|
69
|
+
): string | undefined {
|
|
70
|
+
if (!message) return undefined;
|
|
71
|
+
|
|
72
|
+
const texts: string[] = [];
|
|
73
|
+
for (const part of message.content) {
|
|
74
|
+
if (part.type === "text") {
|
|
75
|
+
texts.push(part.text);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return texts.length > 0 ? texts.join("\n") : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getPromptOutcome(state: SubagentState): PromptOutcome {
|
|
83
|
+
const lastAssistant = getLastAssistantMessage(state.session!.messages);
|
|
84
|
+
const text = getAssistantText(lastAssistant);
|
|
85
|
+
|
|
86
|
+
if (lastAssistant?.stopReason === "error") {
|
|
87
|
+
return {
|
|
88
|
+
status: "error",
|
|
89
|
+
error: lastAssistant.errorMessage ?? text ?? "(no output)",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (lastAssistant?.stopReason === "aborted") {
|
|
94
|
+
return {
|
|
95
|
+
status: "aborted",
|
|
96
|
+
error: lastAssistant.errorMessage ?? text ?? "(no output)",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
status: state.agentConfig.interactive ? "waiting" : "done",
|
|
102
|
+
result: text ?? "(no output)",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Process-level singleton that owns all durable subagent state.
|
|
108
|
+
*
|
|
109
|
+
* This survives extension instance replacement caused by runtime
|
|
110
|
+
* teardown/recreation on /resume, /new, /fork (pi 0.65.0+).
|
|
111
|
+
* Each new extension instance rebinds delivery and widget hooks
|
|
112
|
+
* via activateSession/deactivateSession.
|
|
113
|
+
*/
|
|
114
|
+
class CrewRuntime {
|
|
115
|
+
private readonly registry = new SubagentRegistry();
|
|
116
|
+
private readonly delivery = new DeliveryCoordinator();
|
|
117
|
+
|
|
118
|
+
// Per-session refresh callbacks, keyed by ownerSessionId
|
|
119
|
+
private readonly refreshCallbacks = new Map<string, () => void>();
|
|
120
|
+
|
|
121
|
+
private refreshWidgetFor(sessionId: string): void {
|
|
122
|
+
this.refreshCallbacks.get(sessionId)?.();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
activateSession(
|
|
126
|
+
binding: ActiveRuntimeBinding,
|
|
127
|
+
refreshWidget?: () => void,
|
|
128
|
+
): void {
|
|
129
|
+
if (refreshWidget) {
|
|
130
|
+
this.refreshCallbacks.set(binding.sessionId, refreshWidget);
|
|
131
|
+
}
|
|
132
|
+
this.delivery.activateSession(
|
|
133
|
+
binding,
|
|
134
|
+
(ownerSessionId, excludeId) =>
|
|
135
|
+
this.registry.countRunningForOwner(ownerSessionId, excludeId),
|
|
136
|
+
);
|
|
137
|
+
refreshWidget?.();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
deactivateSession(sessionId: string): void {
|
|
141
|
+
this.delivery.deactivateSession(sessionId);
|
|
142
|
+
this.refreshCallbacks.delete(sessionId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
spawn(
|
|
146
|
+
agentConfig: AgentConfig,
|
|
147
|
+
task: string,
|
|
148
|
+
cwd: string,
|
|
149
|
+
ownerSessionId: string,
|
|
150
|
+
ctx: SpawnContext,
|
|
151
|
+
extensionResolvedPath: string,
|
|
152
|
+
): string {
|
|
153
|
+
const state = this.registry.create(agentConfig, task, ownerSessionId);
|
|
154
|
+
this.refreshWidgetFor(ownerSessionId);
|
|
155
|
+
void this.spawnSession(
|
|
156
|
+
state,
|
|
157
|
+
cwd,
|
|
158
|
+
ctx,
|
|
159
|
+
extensionResolvedPath,
|
|
160
|
+
);
|
|
161
|
+
return state.id;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private attachSessionListeners(
|
|
165
|
+
state: SubagentState,
|
|
166
|
+
session: AgentSession,
|
|
167
|
+
): void {
|
|
168
|
+
state.unsubscribe = session.subscribe((event) => {
|
|
169
|
+
if (event.type !== "turn_end") return;
|
|
170
|
+
|
|
171
|
+
state.turns++;
|
|
172
|
+
const msg = event.message;
|
|
173
|
+
if (msg.role === "assistant") {
|
|
174
|
+
const assistantMsg = msg as AssistantMessage;
|
|
175
|
+
state.contextTokens = assistantMsg.usage.totalTokens;
|
|
176
|
+
state.model = assistantMsg.model;
|
|
177
|
+
}
|
|
178
|
+
this.refreshWidgetFor(state.ownerSessionId);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private attachSpawnedSession(
|
|
183
|
+
state: SubagentState,
|
|
184
|
+
session: AgentSession,
|
|
185
|
+
): boolean {
|
|
186
|
+
if (!this.registry.hasState(state)) {
|
|
187
|
+
session.dispose();
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
state.session = session;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private settleAgent(
|
|
196
|
+
state: SubagentState,
|
|
197
|
+
nextStatus: SubagentStatus,
|
|
198
|
+
opts: { result?: string; error?: string },
|
|
199
|
+
): void {
|
|
200
|
+
state.status = nextStatus;
|
|
201
|
+
state.result = opts.result;
|
|
202
|
+
state.error = opts.error;
|
|
203
|
+
|
|
204
|
+
this.delivery.deliver(
|
|
205
|
+
state.ownerSessionId,
|
|
206
|
+
{
|
|
207
|
+
id: state.id,
|
|
208
|
+
agentName: state.agentConfig.name,
|
|
209
|
+
sessionFile: state.session?.sessionFile,
|
|
210
|
+
status: state.status,
|
|
211
|
+
result: state.result,
|
|
212
|
+
error: state.error,
|
|
213
|
+
},
|
|
214
|
+
(ownerSessionId, excludeId) =>
|
|
215
|
+
this.registry.countRunningForOwner(ownerSessionId, excludeId),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (state.status !== "waiting") {
|
|
219
|
+
this.disposeAgent(state);
|
|
220
|
+
} else {
|
|
221
|
+
this.refreshWidgetFor(state.ownerSessionId);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private disposeAgent(state: SubagentState): void {
|
|
226
|
+
state.unsubscribe?.();
|
|
227
|
+
state.promptAbortController = undefined;
|
|
228
|
+
state.session?.dispose();
|
|
229
|
+
this.registry.delete(state.id);
|
|
230
|
+
this.refreshWidgetFor(state.ownerSessionId);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async runPromptCycle(
|
|
234
|
+
state: SubagentState,
|
|
235
|
+
prompt: string,
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
if (isAborted(state)) return;
|
|
238
|
+
|
|
239
|
+
const abortController = new AbortController();
|
|
240
|
+
state.promptAbortController = abortController;
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const recovery = await runPromptWithOverflowRecovery(
|
|
244
|
+
state.session!,
|
|
245
|
+
prompt,
|
|
246
|
+
abortController.signal,
|
|
247
|
+
);
|
|
248
|
+
if (isAborted(state)) return;
|
|
249
|
+
|
|
250
|
+
const outcome = getPromptOutcome(state);
|
|
251
|
+
|
|
252
|
+
if (recovery === "failed" && outcome.status !== "error") {
|
|
253
|
+
this.settleAgent(state, "error", {
|
|
254
|
+
error: "Context overflow recovery failed",
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.settleAgent(state, outcome.status, outcome);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
if (isAborted(state)) return;
|
|
262
|
+
|
|
263
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
264
|
+
this.settleAgent(state, "error", { error });
|
|
265
|
+
} finally {
|
|
266
|
+
state.promptAbortController = undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private async spawnSession(
|
|
271
|
+
state: SubagentState,
|
|
272
|
+
cwd: string,
|
|
273
|
+
ctx: SpawnContext,
|
|
274
|
+
extensionResolvedPath: string,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
try {
|
|
277
|
+
if (isAborted(state)) return;
|
|
278
|
+
|
|
279
|
+
const { session, warnings } = await bootstrapSession({
|
|
280
|
+
agentConfig: state.agentConfig,
|
|
281
|
+
cwd,
|
|
282
|
+
ctx: toBootstrapContext(ctx),
|
|
283
|
+
extensionResolvedPath,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Emit bootstrap warnings to UI
|
|
287
|
+
for (const warning of warnings) {
|
|
288
|
+
ctx.onWarning?.(warning);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!this.attachSpawnedSession(state, session)) return;
|
|
292
|
+
|
|
293
|
+
this.attachSessionListeners(state, session);
|
|
294
|
+
await this.runPromptCycle(state, state.task);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (isAborted(state)) return;
|
|
297
|
+
|
|
298
|
+
if (state.status === "running") {
|
|
299
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
300
|
+
this.settleAgent(state, "error", { error });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
respond(
|
|
306
|
+
id: string,
|
|
307
|
+
message: string,
|
|
308
|
+
callerSessionId: string,
|
|
309
|
+
): { error?: string } {
|
|
310
|
+
const state = this.registry.get(id);
|
|
311
|
+
if (!state) return { error: `No subagent with id "${id}"` };
|
|
312
|
+
if (state.ownerSessionId !== callerSessionId) {
|
|
313
|
+
return { error: `Subagent "${id}" belongs to a different session` };
|
|
314
|
+
}
|
|
315
|
+
if (state.status !== "waiting") {
|
|
316
|
+
return {
|
|
317
|
+
error: `Subagent "${id}" is not waiting for a response (status: ${state.status})`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (!state.session)
|
|
321
|
+
return { error: `Subagent "${id}" has no active session` };
|
|
322
|
+
|
|
323
|
+
state.status = "running";
|
|
324
|
+
this.refreshWidgetFor(state.ownerSessionId);
|
|
325
|
+
void this.runPromptCycle(state, message);
|
|
326
|
+
return {};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
done(id: string, callerSessionId: string): { error?: string } {
|
|
330
|
+
const state = this.registry.get(id);
|
|
331
|
+
if (!state) return { error: `No active subagent with id "${id}"` };
|
|
332
|
+
if (state.ownerSessionId !== callerSessionId) {
|
|
333
|
+
return { error: `Subagent "${id}" belongs to a different session` };
|
|
334
|
+
}
|
|
335
|
+
if (state.status !== "waiting") {
|
|
336
|
+
return { error: `Subagent "${id}" is not in waiting state` };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
this.disposeAgent(state);
|
|
340
|
+
return {};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
abort(id: string, opts: AbortOptions): boolean {
|
|
344
|
+
const state = this.registry.get(id);
|
|
345
|
+
if (!state || !isAbortableStatus(state.status)) return false;
|
|
346
|
+
|
|
347
|
+
state.promptAbortController?.abort();
|
|
348
|
+
state.promptAbortController = undefined;
|
|
349
|
+
state.session?.abortCompaction();
|
|
350
|
+
state.session?.abortRetry();
|
|
351
|
+
state.session?.abort().catch(() => {});
|
|
352
|
+
this.settleAgent(state, "aborted", { error: opts.reason });
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
abortOwned(
|
|
357
|
+
ids: string[],
|
|
358
|
+
callerSessionId: string,
|
|
359
|
+
opts: AbortOptions,
|
|
360
|
+
): AbortOwnedResult {
|
|
361
|
+
const uniqueIds = Array.from(
|
|
362
|
+
new Set(ids.map((id) => id.trim()).filter(Boolean)),
|
|
363
|
+
);
|
|
364
|
+
const result: AbortOwnedResult = {
|
|
365
|
+
abortedIds: [],
|
|
366
|
+
missingIds: [],
|
|
367
|
+
foreignIds: [],
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
for (const id of uniqueIds) {
|
|
371
|
+
const state = this.registry.get(id);
|
|
372
|
+
if (!state || !isAbortableStatus(state.status)) {
|
|
373
|
+
result.missingIds.push(id);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (state.ownerSessionId !== callerSessionId) {
|
|
377
|
+
result.foreignIds.push(id);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (this.abort(id, opts)) {
|
|
381
|
+
result.abortedIds.push(id);
|
|
382
|
+
} else {
|
|
383
|
+
result.missingIds.push(id);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
abortAllOwned(
|
|
391
|
+
callerSessionId: string,
|
|
392
|
+
opts: AbortOptions,
|
|
393
|
+
): string[] {
|
|
394
|
+
const ids = this.registry.getOwnedAbortableIds(callerSessionId);
|
|
395
|
+
|
|
396
|
+
for (const id of ids) {
|
|
397
|
+
this.abort(id, opts);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return ids;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Abort all abortable subagents during shutdown cleanup.
|
|
405
|
+
* Called from SIGINT, session_shutdown(reason="quit"), and beforeExit fallback paths.
|
|
406
|
+
*/
|
|
407
|
+
abortAll(): void {
|
|
408
|
+
const allAgents = this.registry.getAllAbortable();
|
|
409
|
+
for (const state of allAgents) {
|
|
410
|
+
this.abort(state.id, { reason: "Aborted during shutdown" });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
getActiveSummariesForOwner(ownerSessionId: string): ActiveAgentSummary[] {
|
|
415
|
+
return this.registry.getActiveSummariesForOwner(ownerSessionId);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const crewRuntimeKey = Symbol.for("pi-crew.runtime");
|
|
420
|
+
const globalWithCrewRuntime = globalThis as typeof globalThis & Record<
|
|
421
|
+
symbol,
|
|
422
|
+
CrewRuntime | undefined
|
|
423
|
+
>;
|
|
424
|
+
|
|
425
|
+
export const crewRuntime = globalWithCrewRuntime[crewRuntimeKey] ??= new CrewRuntime();
|
|
426
|
+
export type { CrewRuntime };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SteeringPayload,
|
|
3
|
+
sendRemainingNote,
|
|
4
|
+
sendSteeringMessage,
|
|
5
|
+
type SendMessageFn,
|
|
6
|
+
} from "../subagent-messages.js";
|
|
7
|
+
|
|
8
|
+
export interface ActiveRuntimeBinding {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
isIdle: () => boolean;
|
|
11
|
+
sendMessage: SendMessageFn;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PendingMessage {
|
|
15
|
+
ownerSessionId: string;
|
|
16
|
+
payload: SteeringPayload;
|
|
17
|
+
queuedAt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class DeliveryCoordinator {
|
|
21
|
+
private binding: ActiveRuntimeBinding | undefined;
|
|
22
|
+
private pendingMessages: PendingMessage[] = [];
|
|
23
|
+
private flushScheduled = false;
|
|
24
|
+
|
|
25
|
+
activateSession(
|
|
26
|
+
binding: ActiveRuntimeBinding,
|
|
27
|
+
countRunningForOwner: (ownerSessionId: string, excludeId: string) => number,
|
|
28
|
+
): void {
|
|
29
|
+
this.binding = binding;
|
|
30
|
+
// Delay flush to next macrotask. session_start fires before pi-core
|
|
31
|
+
// calls _reconnectToAgent(), so synchronous delivery would emit agent
|
|
32
|
+
// events while the session listener is disconnected, losing JSONL persistence.
|
|
33
|
+
if (this.pendingMessages.some((entry) => entry.ownerSessionId === binding.sessionId)) {
|
|
34
|
+
this.flushScheduled = true;
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
this.flushScheduled = false;
|
|
37
|
+
this.flushPending(countRunningForOwner);
|
|
38
|
+
}, 0);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
deactivateSession(sessionId: string): void {
|
|
43
|
+
if (this.binding?.sessionId === sessionId) {
|
|
44
|
+
this.binding = undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
deliver(
|
|
49
|
+
ownerSessionId: string,
|
|
50
|
+
payload: SteeringPayload,
|
|
51
|
+
countRunningForOwner: (ownerSessionId: string, excludeId: string) => number,
|
|
52
|
+
): void {
|
|
53
|
+
if (!this.binding || ownerSessionId !== this.binding.sessionId || this.flushScheduled) {
|
|
54
|
+
this.pendingMessages.push({ ownerSessionId, payload, queuedAt: Date.now() });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.send(ownerSessionId, payload, countRunningForOwner);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Remove pending messages older than the TTL.
|
|
63
|
+
* Called during activateSession to prevent unbounded memory growth.
|
|
64
|
+
*/
|
|
65
|
+
private cleanStaleMessages(): void {
|
|
66
|
+
const maxAgeMs = 86_400_000; // 24 hours
|
|
67
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
68
|
+
this.pendingMessages = this.pendingMessages.filter(
|
|
69
|
+
(entry) => entry.queuedAt >= cutoff,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private flushPending(
|
|
74
|
+
countRunningForOwner: (ownerSessionId: string, excludeId: string) => number,
|
|
75
|
+
): void {
|
|
76
|
+
if (!this.binding) return;
|
|
77
|
+
const targetSessionId = this.binding.sessionId;
|
|
78
|
+
|
|
79
|
+
// Clean up stale messages first (older than TTL)
|
|
80
|
+
this.cleanStaleMessages();
|
|
81
|
+
|
|
82
|
+
const toDeliver: PendingMessage[] = [];
|
|
83
|
+
const remaining: PendingMessage[] = [];
|
|
84
|
+
|
|
85
|
+
for (const entry of this.pendingMessages) {
|
|
86
|
+
if (entry.ownerSessionId === targetSessionId) {
|
|
87
|
+
toDeliver.push(entry);
|
|
88
|
+
} else {
|
|
89
|
+
// Keep all other messages - they may be for sessions that will be reactivated later
|
|
90
|
+
remaining.push(entry);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Keep messages for other sessions
|
|
95
|
+
this.pendingMessages = remaining;
|
|
96
|
+
|
|
97
|
+
// Deliver messages for the active session
|
|
98
|
+
for (const entry of toDeliver) {
|
|
99
|
+
this.send(entry.ownerSessionId, entry.payload, countRunningForOwner);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Result messages always go first. If more subagents are still running and the
|
|
105
|
+
* owner is idle, queue the result without triggering, then queue the separate
|
|
106
|
+
* remaining note with triggerTurn so the next turn sees both in order.
|
|
107
|
+
*/
|
|
108
|
+
private send(
|
|
109
|
+
ownerSessionId: string,
|
|
110
|
+
payload: SteeringPayload,
|
|
111
|
+
countRunningForOwner: (ownerSessionId: string, excludeId: string) => number,
|
|
112
|
+
): void {
|
|
113
|
+
if (!this.binding || this.binding.sessionId !== ownerSessionId) {
|
|
114
|
+
this.pendingMessages.push({ ownerSessionId, payload, queuedAt: Date.now() });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const remaining = countRunningForOwner(ownerSessionId, payload.id);
|
|
119
|
+
const isIdle = this.binding.isIdle();
|
|
120
|
+
const triggerResultTurn = !(isIdle && remaining > 0);
|
|
121
|
+
|
|
122
|
+
sendSteeringMessage(payload, this.binding.sendMessage, {
|
|
123
|
+
isIdle,
|
|
124
|
+
triggerTurn: triggerResultTurn,
|
|
125
|
+
});
|
|
126
|
+
sendRemainingNote(remaining, this.binding.sendMessage, {
|
|
127
|
+
isIdle,
|
|
128
|
+
triggerTurn: isIdle && remaining > 0,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|