@melihmucuk/pi-crew 1.0.17 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/agents/code-reviewer.md +16 -11
  2. package/agents/quality-reviewer.md +8 -17
  3. package/extension/catalog.ts +543 -0
  4. package/extension/crew.ts +377 -0
  5. package/extension/index.ts +35 -18
  6. package/extension/subagent-session.ts +257 -0
  7. package/extension/tools.ts +323 -0
  8. package/extension/ui.ts +291 -0
  9. package/package.json +6 -6
  10. package/prompts/pi-crew-review.md +25 -16
  11. package/skills/pi-crew/SKILL.md +3 -1
  12. package/extension/agent-catalog.ts +0 -369
  13. package/extension/agent-config-fields.ts +0 -359
  14. package/extension/agent-discovery.ts +0 -123
  15. package/extension/bootstrap-session.ts +0 -131
  16. package/extension/integration/crew-tool-actions.ts +0 -306
  17. package/extension/integration/crew-tool-executor.ts +0 -109
  18. package/extension/integration/register-renderers.ts +0 -77
  19. package/extension/integration/register-tools.ts +0 -47
  20. package/extension/integration/tool-presentation.ts +0 -30
  21. package/extension/integration/tools/crew-abort.ts +0 -56
  22. package/extension/integration/tools/crew-done.ts +0 -27
  23. package/extension/integration/tools/crew-list.ts +0 -36
  24. package/extension/integration/tools/crew-respond.ts +0 -38
  25. package/extension/integration/tools/crew-spawn.ts +0 -46
  26. package/extension/message-delivery-policy.ts +0 -22
  27. package/extension/runtime/crew-runtime.ts +0 -263
  28. package/extension/runtime/overflow-recovery.ts +0 -211
  29. package/extension/runtime/owner-session-coordinator.ts +0 -138
  30. package/extension/runtime/subagent-lifecycle.ts +0 -203
  31. package/extension/runtime/subagent-registry.ts +0 -122
  32. package/extension/runtime/subagent-transitions.ts +0 -100
  33. package/extension/status-widget.ts +0 -107
  34. package/extension/subagent-messages.ts +0 -116
  35. package/extension/tool-registry.ts +0 -19
@@ -0,0 +1,377 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import type { AgentSession, ModelRegistry } from "@earendil-works/pi-coding-agent";
4
+ import type { AgentConfig } from "./catalog.js";
5
+ import type { BootstrapContext, SubagentRunner, SubagentRunnerCallbacks } from "./subagent-session.js";
6
+ import { SubagentSessionRunner } from "./subagent-session.js";
7
+ import {
8
+ type SendMessageFn,
9
+ type SteeringPayload,
10
+ type SubagentStatus,
11
+ sendSteeringMessage,
12
+ } from "./ui.js";
13
+
14
+ export interface ActiveRuntimeBinding {
15
+ sessionId: string;
16
+ isIdle: () => boolean;
17
+ sendMessage: SendMessageFn;
18
+ }
19
+
20
+ interface PendingMessage {
21
+ ownerSessionId: string;
22
+ payload: SteeringPayload;
23
+ queuedAt: number;
24
+ }
25
+
26
+ export interface SubagentState {
27
+ id: string;
28
+ agentConfig: AgentConfig;
29
+ task: string;
30
+ status: SubagentStatus;
31
+ ownerSessionId: string;
32
+ session: AgentSession | null;
33
+ turns: number;
34
+ contextTokens: number;
35
+ model: string | undefined;
36
+ error?: string;
37
+ result?: string;
38
+ unsubscribe?: () => void;
39
+ }
40
+
41
+ export interface ActiveAgentSummary {
42
+ id: string;
43
+ agentName: string;
44
+ status: SubagentStatus;
45
+ turns: number;
46
+ contextTokens: number;
47
+ model: string | undefined;
48
+ }
49
+
50
+ export interface AbortOwnedResult {
51
+ abortedIds: string[];
52
+ missingIds: string[];
53
+ foreignIds: string[];
54
+ }
55
+
56
+ interface AbortOptions {
57
+ reason: string;
58
+ }
59
+
60
+ export interface SpawnContext {
61
+ model: Model<Api> | undefined;
62
+ modelRegistry: ModelRegistry;
63
+ agentDir: string;
64
+ parentSessionFile?: string;
65
+ onWarning?: (message: string) => void;
66
+ }
67
+
68
+ type SettledSubagentStatus = Extract<SubagentStatus, "done" | "waiting" | "error" | "aborted">;
69
+
70
+ const PENDING_MESSAGE_TTL_MS = 86_400_000;
71
+
72
+ function toBootstrapContext(ctx: SpawnContext): BootstrapContext {
73
+ return {
74
+ model: ctx.model,
75
+ modelRegistry: ctx.modelRegistry,
76
+ agentDir: ctx.agentDir,
77
+ parentSessionFile: ctx.parentSessionFile,
78
+ };
79
+ }
80
+
81
+ function generateId(name: string, existingIds: Set<string>): string {
82
+ for (let i = 0; i < 10; i++) {
83
+ const id = `${name}-${randomBytes(4).toString("hex")}`;
84
+ if (!existingIds.has(id)) return id;
85
+ }
86
+ return `${name}-${randomBytes(8).toString("hex")}`;
87
+ }
88
+
89
+ function isAbortableStatus(status: SubagentStatus): boolean {
90
+ return status === "running" || status === "waiting";
91
+ }
92
+
93
+ function buildActiveAgentSummary(state: SubagentState): ActiveAgentSummary {
94
+ return {
95
+ id: state.id,
96
+ agentName: state.agentConfig.name,
97
+ status: state.status,
98
+ turns: state.turns,
99
+ contextTokens: state.contextTokens,
100
+ model: state.model,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Process-global coordinator for subagent state, ownership, delivery, and cleanup.
106
+ */
107
+ export class CrewRuntime {
108
+ private readonly agents = new Map<string, SubagentState>();
109
+ private readonly runner: SubagentRunner;
110
+ private readonly refreshCallbacks = new Map<string, () => void>();
111
+ private activeBinding: ActiveRuntimeBinding | undefined;
112
+ private pendingMessages: PendingMessage[] = [];
113
+ private flushScheduled = false;
114
+ private readonly now: () => number;
115
+ private readonly scheduleFlush: (callback: () => void) => void;
116
+
117
+ constructor(opts: {
118
+ now?: () => number;
119
+ scheduleFlush?: (callback: () => void) => void;
120
+ createRunner?: (callbacks: SubagentRunnerCallbacks) => SubagentRunner;
121
+ } = {}) {
122
+ this.now = opts.now ?? Date.now;
123
+ this.scheduleFlush = opts.scheduleFlush ?? ((callback) => setTimeout(callback, 0));
124
+ const callbacks: SubagentRunnerCallbacks = {
125
+ isCurrent: (state) => this.agents.get(state.id) === state,
126
+ onProgress: (ownerSessionId) => this.refreshWidgetFor(ownerSessionId),
127
+ onSettled: (state, status, outcome) => this.settleAgent(state, status, outcome),
128
+ };
129
+ this.runner = opts.createRunner?.(callbacks) ?? new SubagentSessionRunner(callbacks);
130
+ }
131
+
132
+ activateSession(binding: ActiveRuntimeBinding, refreshWidget?: () => void): void {
133
+ if (refreshWidget) this.refreshCallbacks.set(binding.sessionId, refreshWidget);
134
+ this.activeBinding = binding;
135
+ this.schedulePendingFlushFor(binding.sessionId);
136
+ refreshWidget?.();
137
+ }
138
+
139
+ deactivateSession(sessionId: string): void {
140
+ if (this.activeBinding?.sessionId === sessionId) this.activeBinding = undefined;
141
+ this.refreshCallbacks.delete(sessionId);
142
+ }
143
+
144
+ spawn(
145
+ agentConfig: AgentConfig,
146
+ task: string,
147
+ cwd: string,
148
+ ownerSessionId: string,
149
+ ctx: SpawnContext,
150
+ extensionResolvedPath: string,
151
+ ): string {
152
+ const state = this.createAgent(agentConfig, task, ownerSessionId);
153
+ this.refreshWidgetFor(ownerSessionId);
154
+ this.runner.start(state, {
155
+ cwd,
156
+ ctx: toBootstrapContext(ctx),
157
+ extensionResolvedPath,
158
+ onWarning: ctx.onWarning,
159
+ });
160
+ return state.id;
161
+ }
162
+
163
+ respond(id: string, message: string, callerSessionId: string): { error?: string } {
164
+ const transition = this.startSubagentResponse(id, callerSessionId);
165
+ if (!transition.ok) return { error: transition.error };
166
+
167
+ this.refreshWidgetFor(transition.state.ownerSessionId);
168
+ this.runner.respond(transition.state, message);
169
+ return {};
170
+ }
171
+
172
+ done(id: string, callerSessionId: string): { error?: string } {
173
+ const transition = this.validateSubagentDone(id, callerSessionId);
174
+ if (!transition.ok) return { error: transition.error };
175
+
176
+ this.disposeAgent(transition.state);
177
+ return {};
178
+ }
179
+
180
+ abort(id: string, opts: AbortOptions): boolean {
181
+ const state = this.agents.get(id);
182
+ if (!state || !isAbortableStatus(state.status)) return false;
183
+
184
+ this.runner.abort(state);
185
+ this.settleAgent(state, "aborted", { error: opts.reason });
186
+ return true;
187
+ }
188
+
189
+ abortOwned(ids: string[], callerSessionId: string, opts: AbortOptions): AbortOwnedResult {
190
+ const uniqueIds = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean)));
191
+ const result: AbortOwnedResult = { abortedIds: [], missingIds: [], foreignIds: [] };
192
+
193
+ for (const id of uniqueIds) {
194
+ const state = this.agents.get(id);
195
+ if (!state || !isAbortableStatus(state.status)) {
196
+ result.missingIds.push(id);
197
+ continue;
198
+ }
199
+ if (state.ownerSessionId !== callerSessionId) {
200
+ result.foreignIds.push(id);
201
+ continue;
202
+ }
203
+ if (this.abort(id, opts)) result.abortedIds.push(id);
204
+ else result.missingIds.push(id);
205
+ }
206
+
207
+ return result;
208
+ }
209
+
210
+ abortAllOwned(callerSessionId: string, opts: AbortOptions): string[] {
211
+ const ids = Array.from(this.agents.values())
212
+ .filter((state) => state.ownerSessionId === callerSessionId && isAbortableStatus(state.status))
213
+ .map((state) => state.id);
214
+ for (const id of ids) this.abort(id, opts);
215
+ return ids;
216
+ }
217
+
218
+ abortAll(): void {
219
+ const allAgents = Array.from(this.agents.values()).filter((state) => isAbortableStatus(state.status));
220
+ for (const state of allAgents) this.abort(state.id, { reason: "Aborted during shutdown" });
221
+ }
222
+
223
+ getActiveSummariesForOwner(ownerSessionId: string): ActiveAgentSummary[] {
224
+ return Array.from(this.agents.values())
225
+ .filter((state) => isAbortableStatus(state.status) && state.ownerSessionId === ownerSessionId)
226
+ .map(buildActiveAgentSummary);
227
+ }
228
+
229
+ private createAgent(agentConfig: AgentConfig, task: string, ownerSessionId: string): SubagentState {
230
+ const id = generateId(agentConfig.name, new Set(this.agents.keys()));
231
+ const state: SubagentState = {
232
+ id,
233
+ agentConfig,
234
+ task,
235
+ status: "running",
236
+ ownerSessionId,
237
+ session: null,
238
+ turns: 0,
239
+ contextTokens: 0,
240
+ model: undefined,
241
+ };
242
+ this.agents.set(id, state);
243
+ return state;
244
+ }
245
+
246
+ private refreshWidgetFor(sessionId: string): void {
247
+ this.refreshCallbacks.get(sessionId)?.();
248
+ }
249
+
250
+ private settleAgent(state: SubagentState, nextStatus: SettledSubagentStatus, opts: { result?: string; error?: string }): void {
251
+ if (this.agents.get(state.id) !== state) return;
252
+
253
+ state.status = nextStatus;
254
+ state.result = opts.result;
255
+ state.error = opts.error;
256
+
257
+ this.deliver(state.ownerSessionId, {
258
+ id: state.id,
259
+ agentName: state.agentConfig.name,
260
+ sessionFile: state.session?.sessionFile,
261
+ status: state.status,
262
+ result: state.result,
263
+ error: state.error,
264
+ });
265
+
266
+ if (state.status !== "waiting") this.disposeAgent(state);
267
+ else this.refreshWidgetFor(state.ownerSessionId);
268
+ }
269
+
270
+ private disposeAgent(state: SubagentState): void {
271
+ state.unsubscribe?.();
272
+ state.session?.dispose();
273
+ this.agents.delete(state.id);
274
+ this.refreshWidgetFor(state.ownerSessionId);
275
+ }
276
+
277
+ private validateOwnedSubagent(
278
+ id: string,
279
+ callerSessionId: string,
280
+ missingMessage: string,
281
+ ): { ok: true; state: SubagentState } | { ok: false; error: string } {
282
+ const state = this.agents.get(id);
283
+ if (!state) return { ok: false, error: missingMessage };
284
+ if (state.ownerSessionId !== callerSessionId) {
285
+ return { ok: false, error: `Subagent "${id}" belongs to a different session` };
286
+ }
287
+ return { ok: true, state };
288
+ }
289
+
290
+ private startSubagentResponse(id: string, callerSessionId: string): { ok: true; state: SubagentState } | { ok: false; error: string } {
291
+ const owned = this.validateOwnedSubagent(id, callerSessionId, `No subagent with id "${id}"`);
292
+ if (!owned.ok) return owned;
293
+ if (owned.state.status !== "waiting") {
294
+ return { ok: false, error: `Subagent "${id}" is not waiting for a response (status: ${owned.state.status})` };
295
+ }
296
+ if (!owned.state.session) return { ok: false, error: `Subagent "${id}" has no active session` };
297
+
298
+ owned.state.status = "running";
299
+ return owned;
300
+ }
301
+
302
+ private validateSubagentDone(id: string, callerSessionId: string): { ok: true; state: SubagentState } | { ok: false; error: string } {
303
+ const owned = this.validateOwnedSubagent(id, callerSessionId, `No active subagent with id "${id}"`);
304
+ if (!owned.ok) return owned;
305
+ if (owned.state.status !== "waiting") return { ok: false, error: `Subagent "${id}" is not in waiting state` };
306
+ return owned;
307
+ }
308
+
309
+ private countRunningForOwner(ownerSessionId: string, excludeId: string): number {
310
+ let count = 0;
311
+ for (const state of this.agents.values()) {
312
+ if (state.id !== excludeId && state.ownerSessionId === ownerSessionId && state.status === "running") count++;
313
+ }
314
+ return count;
315
+ }
316
+
317
+ private schedulePendingFlushFor(sessionId: string): void {
318
+ if (!this.pendingMessages.some((entry) => entry.ownerSessionId === sessionId)) return;
319
+
320
+ this.flushScheduled = true;
321
+ this.scheduleFlush(() => {
322
+ this.flushScheduled = false;
323
+ this.flushPending();
324
+ });
325
+ }
326
+
327
+ private deliver(ownerSessionId: string, payload: SteeringPayload): void {
328
+ if (!this.activeBinding || ownerSessionId !== this.activeBinding.sessionId || this.flushScheduled) {
329
+ this.queue(ownerSessionId, payload);
330
+ return;
331
+ }
332
+ this.send(ownerSessionId, payload);
333
+ }
334
+
335
+ private queue(ownerSessionId: string, payload: SteeringPayload): void {
336
+ this.pendingMessages.push({ ownerSessionId, payload, queuedAt: this.now() });
337
+ }
338
+
339
+ private cleanStaleMessages(): void {
340
+ const cutoff = this.now() - PENDING_MESSAGE_TTL_MS;
341
+ this.pendingMessages = this.pendingMessages.filter((entry) => entry.queuedAt >= cutoff);
342
+ }
343
+
344
+ private flushPending(): void {
345
+ if (!this.activeBinding) return;
346
+ const targetSessionId = this.activeBinding.sessionId;
347
+ this.cleanStaleMessages();
348
+
349
+ const toDeliver: PendingMessage[] = [];
350
+ const remaining: PendingMessage[] = [];
351
+ for (const entry of this.pendingMessages) {
352
+ if (entry.ownerSessionId === targetSessionId) toDeliver.push(entry);
353
+ else remaining.push(entry);
354
+ }
355
+ this.pendingMessages = remaining;
356
+
357
+ for (const entry of toDeliver) this.send(entry.ownerSessionId, entry.payload);
358
+ }
359
+
360
+ private send(ownerSessionId: string, payload: SteeringPayload): void {
361
+ if (!this.activeBinding || this.activeBinding.sessionId !== ownerSessionId) {
362
+ this.queue(ownerSessionId, payload);
363
+ return;
364
+ }
365
+
366
+ const remaining = this.countRunningForOwner(ownerSessionId, payload.id);
367
+ const isIdle = this.activeBinding.isIdle();
368
+ const triggerResultTurn = payload.status === "waiting" || !(isIdle && remaining > 0);
369
+
370
+ sendSteeringMessage(payload, this.activeBinding.sendMessage, { isIdle, triggerTurn: triggerResultTurn });
371
+ }
372
+ }
373
+
374
+ const crewRuntimeKey = Symbol.for("pi-crew.runtime");
375
+ const globalWithCrewRuntime = globalThis as typeof globalThis & Record<symbol, CrewRuntime | undefined>;
376
+
377
+ export const crewRuntime = globalWithCrewRuntime[crewRuntimeKey] ??= new CrewRuntime();
@@ -1,13 +1,25 @@
1
1
  import { dirname } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import { crewRuntime } from "./runtime/crew-runtime.js";
5
- import { registerCrewMessageRenderers } from "./integration/register-renderers.js";
6
- import { registerCrewTools } from "./integration/register-tools.js";
7
- import { updateWidget } from "./status-widget.js";
4
+ import { crewRuntime, type CrewRuntime } from "./crew.js";
5
+ import { registerCrewTools } from "./tools.js";
6
+ import { registerCrewMessageRenderers, updateWidget } from "./ui.js";
8
7
 
9
8
  const extensionDir = dirname(fileURLToPath(import.meta.url));
10
9
 
10
+ interface ProcessHooks {
11
+ once(event: "SIGINT", listener: () => void): unknown;
12
+ on(event: "beforeExit", listener: () => void): unknown;
13
+ exit(code?: number): never;
14
+ }
15
+
16
+ interface RegisterPiCrewExtensionOptions {
17
+ crew?: CrewRuntime;
18
+ extensionDir?: string;
19
+ processHooks?: ProcessHooks;
20
+ processHooksSetupKey?: symbol;
21
+ }
22
+
11
23
  // Process-level cleanup for subagents on exit
12
24
  const processHooksSetupKey = Symbol.for("pi-crew.processHooksSetup");
13
25
  const globalWithProcessHooks = globalThis as typeof globalThis & Record<
@@ -15,29 +27,30 @@ const globalWithProcessHooks = globalThis as typeof globalThis & Record<
15
27
  boolean | undefined
16
28
  >;
17
29
 
18
- function setupProcessHooks() {
19
- if (globalWithProcessHooks[processHooksSetupKey]) return;
20
- globalWithProcessHooks[processHooksSetupKey] = true;
30
+ function setupProcessHooks(crew: CrewRuntime, processHooks: ProcessHooks, setupKey: symbol) {
31
+ if (globalWithProcessHooks[setupKey]) return;
32
+ globalWithProcessHooks[setupKey] = true;
21
33
 
22
- process.once('SIGINT', () => {
23
- crewRuntime.abortAll();
24
- process.exit(130);
34
+ processHooks.once("SIGINT", () => {
35
+ crew.abortAll();
36
+ processHooks.exit(130);
25
37
  });
26
- process.on('beforeExit', () => crewRuntime.abortAll());
38
+ processHooks.on("beforeExit", () => crew.abortAll());
27
39
  }
28
40
 
29
- export default function (pi: ExtensionAPI) {
41
+ export function registerPiCrewExtension(pi: ExtensionAPI, options: RegisterPiCrewExtensionOptions = {}) {
42
+ const crew = options.crew ?? crewRuntime;
30
43
  let currentCtx: ExtensionContext | undefined;
31
44
 
32
- setupProcessHooks();
45
+ setupProcessHooks(crew, options.processHooks ?? process, options.processHooksSetupKey ?? processHooksSetupKey);
33
46
 
34
47
  const refreshWidget = () => {
35
- if (currentCtx) updateWidget(currentCtx, crewRuntime);
48
+ if (currentCtx) updateWidget(currentCtx, crew);
36
49
  };
37
50
 
38
51
  const activateSession = (ctx: ExtensionContext) => {
39
52
  currentCtx = ctx;
40
- crewRuntime.activateSession(
53
+ crew.activateSession(
41
54
  {
42
55
  sessionId: ctx.sessionManager.getSessionId(),
43
56
  isIdle: () => ctx.isIdle(),
@@ -53,13 +66,17 @@ export default function (pi: ExtensionAPI) {
53
66
 
54
67
  pi.on("session_shutdown", (event, ctx) => {
55
68
  const sessionId = ctx.sessionManager.getSessionId();
56
- crewRuntime.deactivateSession(sessionId);
69
+ crew.deactivateSession(sessionId);
57
70
 
58
71
  if (event.reason === "quit") {
59
- crewRuntime.abortAll();
72
+ crew.abortAll();
60
73
  }
61
74
  });
62
75
 
63
- registerCrewTools(pi, crewRuntime, extensionDir);
76
+ registerCrewTools(pi, crew, options.extensionDir ?? extensionDir);
64
77
  registerCrewMessageRenderers(pi);
65
78
  }
79
+
80
+ export default function (pi: ExtensionAPI) {
81
+ registerPiCrewExtension(pi);
82
+ }