@melihmucuk/pi-crew 1.0.7 → 1.0.9

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 (45) hide show
  1. package/agents/code-reviewer.md +18 -6
  2. package/agents/planner.md +9 -1
  3. package/agents/quality-reviewer.md +20 -13
  4. package/agents/scout.md +33 -26
  5. package/dist/agent-discovery.d.ts +0 -5
  6. package/dist/agent-discovery.js +1 -1
  7. package/dist/bootstrap-session.d.ts +13 -4
  8. package/dist/bootstrap-session.js +25 -16
  9. package/dist/index.js +37 -7
  10. package/dist/integration/register-command.d.ts +2 -2
  11. package/dist/integration/register-command.js +5 -5
  12. package/dist/integration/register-renderers.js +3 -0
  13. package/dist/integration/register-tools.d.ts +2 -2
  14. package/dist/integration/register-tools.js +2 -2
  15. package/dist/integration/tool-presentation.d.ts +2 -3
  16. package/dist/integration/tool-presentation.js +7 -8
  17. package/dist/integration/tools/crew-abort.d.ts +1 -1
  18. package/dist/integration/tools/crew-abort.js +3 -3
  19. package/dist/integration/tools/crew-done.d.ts +1 -1
  20. package/dist/integration/tools/crew-done.js +2 -2
  21. package/dist/integration/tools/crew-list.d.ts +1 -1
  22. package/dist/integration/tools/crew-list.js +3 -3
  23. package/dist/integration/tools/crew-respond.d.ts +1 -1
  24. package/dist/integration/tools/crew-respond.js +6 -7
  25. package/dist/integration/tools/crew-spawn.d.ts +1 -1
  26. package/dist/integration/tools/crew-spawn.js +17 -14
  27. package/dist/integration/tools/tool-deps.d.ts +3 -2
  28. package/dist/integration.d.ts +2 -2
  29. package/dist/integration.js +3 -3
  30. package/dist/runtime/crew-runtime.d.ts +61 -0
  31. package/dist/{crew-manager.js → runtime/crew-runtime.js} +84 -58
  32. package/dist/runtime/delivery-coordinator.d.ts +16 -7
  33. package/dist/runtime/delivery-coordinator.js +46 -20
  34. package/dist/runtime/subagent-registry.d.ts +1 -0
  35. package/dist/runtime/subagent-registry.js +3 -0
  36. package/dist/runtime/subagent-state.d.ts +2 -0
  37. package/dist/status-widget.d.ts +2 -2
  38. package/dist/status-widget.js +3 -3
  39. package/dist/subagent-messages.d.ts +5 -2
  40. package/dist/subagent-messages.js +5 -4
  41. package/docs/architecture.md +106 -843
  42. package/package.json +1 -1
  43. package/prompts/pi-crew-plan.md +82 -123
  44. package/prompts/pi-crew-review.md +64 -115
  45. package/dist/crew-manager.d.ts +0 -44
@@ -2,18 +2,18 @@ import { Text } from "@mariozechner/pi-tui";
2
2
  import { Type } from "@sinclair/typebox";
3
3
  import { discoverAgents } from "../../agent-discovery.js";
4
4
  import { STATUS_ICON } from "../../subagent-messages.js";
5
- export function registerCrewListTool({ pi, crewManager, notifyDiscoveryWarnings, }) {
5
+ export function registerCrewListTool({ pi, crew, notifyDiscoveryWarnings, }) {
6
6
  pi.registerTool({
7
7
  name: "crew_list",
8
8
  label: "List Crew",
9
- description: "List available subagent definitions (from <cwd>/.pi/agents/, ~/.pi/agent/agents/, and bundled agents, with optional global/project pi-crew.json overrides) and currently running subagents with their status.",
9
+ description: "List available subagent definitions and currently running subagents with their status.",
10
10
  parameters: Type.Object({}),
11
11
  promptSnippet: "List subagent definitions and active subagents",
12
12
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
13
13
  const { agents, warnings } = discoverAgents(ctx.cwd);
14
14
  notifyDiscoveryWarnings(ctx, warnings);
15
15
  const callerSessionId = ctx.sessionManager.getSessionId();
16
- const running = crewManager.getActiveSummariesForOwner(callerSessionId);
16
+ const running = crew.getActiveSummariesForOwner(callerSessionId);
17
17
  const lines = [];
18
18
  lines.push("## Available subagents");
19
19
  if (agents.length === 0) {
@@ -1,2 +1,2 @@
1
1
  import type { CrewToolDeps } from "./tool-deps.js";
2
- export declare function registerCrewRespondTool({ pi, crewManager }: CrewToolDeps): void;
2
+ export declare function registerCrewRespondTool({ pi, crew }: CrewToolDeps): void;
@@ -1,10 +1,10 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import { renderCrewCall, renderCrewResult, toolError, toolSuccess, truncatePreview, } from "../tool-presentation.js";
3
- export function registerCrewRespondTool({ pi, crewManager }) {
2
+ import { renderCrewCall, renderCrewResult, toolError, toolSuccess, } from "../tool-presentation.js";
3
+ export function registerCrewRespondTool({ pi, crew }) {
4
4
  pi.registerTool({
5
5
  name: "crew_respond",
6
6
  label: "Respond to Crew",
7
- description: "Send a follow-up message to an interactive subagent that is waiting for a response. Use crew_list to see waiting subagents.",
7
+ description: "Send a follow-up message to an interactive subagent that is waiting for a response.",
8
8
  parameters: Type.Object({
9
9
  subagent_id: Type.String({
10
10
  description: "ID of the waiting subagent (from crew_list or crew_spawn result)",
@@ -14,14 +14,13 @@ export function registerCrewRespondTool({ pi, crewManager }) {
14
14
  promptSnippet: "Send a follow-up message to a waiting interactive subagent.",
15
15
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
16
16
  const callerSessionId = ctx.sessionManager.getSessionId();
17
- const { error } = crewManager.respond(params.subagent_id, params.message, pi, callerSessionId);
17
+ const { error } = crew.respond(params.subagent_id, params.message, callerSessionId);
18
18
  if (error)
19
19
  return toolError(error);
20
- return toolSuccess(`Message sent to subagent ${params.subagent_id}. Response will be delivered as a steering message.`, { id: params.subagent_id });
20
+ return toolSuccess(`Message sent to subagent ${params.subagent_id}. Response will be delivered as a steering message.`, { id: params.subagent_id, message: params.message });
21
21
  },
22
22
  renderCall(args, theme, _context) {
23
- const preview = args.message ? truncatePreview(args.message, 60) : "...";
24
- return renderCrewCall(theme, "crew_respond", args.subagent_id || "...", preview);
23
+ return renderCrewCall(theme, "crew_respond", args.subagent_id || "...", args.message);
25
24
  },
26
25
  renderResult(result, _options, theme, _context) {
27
26
  return renderCrewResult(result, theme);
@@ -1,2 +1,2 @@
1
1
  import type { CrewToolDeps } from "./tool-deps.js";
2
- export declare function registerCrewSpawnTool({ pi, crewManager, notifyDiscoveryWarnings, }: CrewToolDeps): void;
2
+ export declare function registerCrewSpawnTool({ pi, crew, extensionDir, notifyDiscoveryWarnings, }: CrewToolDeps): void;
@@ -1,24 +1,23 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { discoverAgents } from "../../agent-discovery.js";
3
- import { renderCrewCall, renderCrewResult, toolError, toolSuccess, truncatePreview, } from "../tool-presentation.js";
4
- export function registerCrewSpawnTool({ pi, crewManager, notifyDiscoveryWarnings, }) {
3
+ import { renderCrewCall, renderCrewResult, toolError, toolSuccess, } from "../tool-presentation.js";
4
+ export function registerCrewSpawnTool({ pi, crew, extensionDir, notifyDiscoveryWarnings, }) {
5
5
  pi.registerTool({
6
6
  name: "crew_spawn",
7
7
  label: "Spawn Crew",
8
- description: "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 when done. NEVER PREDICT or FABRICATE results for subagents that have not yet reported back to you. Use crew_list first to see available subagents.",
8
+ description: "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.",
9
9
  parameters: Type.Object({
10
10
  subagent: Type.String({ description: "Subagent name from crew_list" }),
11
11
  task: Type.String({ description: "Task to delegate to the subagent" }),
12
12
  }),
13
13
  promptSnippet: "Spawn a non-blocking subagent. Use crew_list first to see available subagents.",
14
14
  promptGuidelines: [
15
- "Use crew_* tools to delegate parallelizable, independent tasks to specialized subagents. For interactive multi-turn workflows, use crew_respond/crew_done. Avoid spawning for trivial, single-turn tasks.",
16
- "crew_spawn: Always call crew_list first to see which subagents are available before spawning.",
17
- "crew_spawn: The spawned subagent runs in a separate context window with no access to your session. Include all relevant context (file paths, requirements, prior findings) directly in the task parameter.",
18
- "crew_spawn: Results are delivered asynchronously as steering messages. Do not block or poll for completion. If there are other independent tasks to handle, continue with those; otherwise wait for the user's next instruction or the subagent result.",
19
- "crew_spawn: NEVER perform the same work you delegated to a subagent. Once a task is spawned, trust the subagent to do it. Do not run the same searches, reads, or analysis yourself while waiting. You may only gather context BEFORE spawning to prepare the task description. After spawning, move on to other independent work or simply wait for the result.",
20
- "crew_spawn: When multiple subagents are spawned, each result arrives as a separate steering message. NEVER PREDICT or FABRICATE results for subagents that have not yet reported back to you. Wait for ALL crew-result messages.",
21
- "crew_spawn: Interactive subagents (marked with 'interactive' in crew_list) stay alive after responding. Use crew_respond to continue the conversation and crew_done to close when finished.",
15
+ "Use crew_list first to see available subagents before spawning.",
16
+ "crew_spawn: The subagent runs in isolation with no access to your session. Include file paths, requirements, and known locations directly in the task parameter.",
17
+ "crew_spawn: DELEGATE means STOP. After spawning, either work on an UNRELATED task or end your turn. Never continue the delegated task yourself.",
18
+ "crew_spawn: To avoid duplication, gather only enough context to write a useful task (key files, entry points). Do not pre-investigate the full problem.",
19
+ "crew_spawn: Results arrive asynchronously as steering messages. Do not predict or fabricate results. Wait for all crew-result messages before acting on them.",
20
+ "crew_spawn: Interactive subagents stay alive after responding. Use crew_respond to continue and crew_done to close when finished.",
22
21
  ],
23
22
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
24
23
  const { agents, warnings } = discoverAgents(ctx.cwd);
@@ -29,12 +28,16 @@ export function registerCrewSpawnTool({ pi, crewManager, notifyDiscoveryWarnings
29
28
  return toolError(`Unknown subagent: "${params.subagent}". Available: ${available}`);
30
29
  }
31
30
  const ownerSessionId = ctx.sessionManager.getSessionId();
32
- const id = crewManager.spawn(subagent, params.task, ctx.cwd, ownerSessionId, ctx, pi);
33
- return toolSuccess(`Subagent '${subagent.name}' spawned as ${id}. Result will be delivered as a steering message when done.`, { id });
31
+ const id = crew.spawn(subagent, params.task, ctx.cwd, ownerSessionId, {
32
+ model: ctx.model,
33
+ modelRegistry: ctx.modelRegistry,
34
+ parentSessionFile: ctx.sessionManager.getSessionFile(),
35
+ onWarning: (msg) => ctx.ui.notify(msg, "warning"),
36
+ }, extensionDir);
37
+ return toolSuccess(`Subagent '${subagent.name}' spawned as ${id}. Result will be delivered as a steering message when done.`, { id, agentName: subagent.name, task: params.task });
34
38
  },
35
39
  renderCall(args, theme, _context) {
36
- const preview = args.task ? truncatePreview(args.task, 60) : "...";
37
- return renderCrewCall(theme, "crew_spawn", args.subagent || "...", preview);
40
+ return renderCrewCall(theme, "crew_spawn", args.subagent || "...", args.task);
38
41
  },
39
42
  renderResult(result, _options, theme, _context) {
40
43
  return renderCrewResult(result, theme);
@@ -1,8 +1,9 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import type { AgentDiscoveryWarning } from "../../agent-discovery.js";
3
- import type { CrewManager } from "../../crew-manager.js";
3
+ import type { CrewRuntime } from "../../runtime/crew-runtime.js";
4
4
  export interface CrewToolDeps {
5
5
  pi: ExtensionAPI;
6
- crewManager: CrewManager;
6
+ crew: CrewRuntime;
7
+ extensionDir: string;
7
8
  notifyDiscoveryWarnings: (ctx: ExtensionContext, warnings: AgentDiscoveryWarning[]) => void;
8
9
  }
@@ -1,3 +1,3 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import type { CrewManager } from "./crew-manager.js";
3
- export declare function registerCrewIntegration(pi: ExtensionAPI, crewManager: CrewManager): void;
2
+ import type { CrewRuntime } from "./runtime/crew-runtime.js";
3
+ export declare function registerCrewIntegration(pi: ExtensionAPI, crew: CrewRuntime, extensionDir: string): void;
@@ -1,8 +1,8 @@
1
1
  import { registerCrewCommand } from "./integration/register-command.js";
2
2
  import { registerCrewMessageRenderers } from "./integration/register-renderers.js";
3
3
  import { registerCrewTools } from "./integration/register-tools.js";
4
- export function registerCrewIntegration(pi, crewManager) {
5
- registerCrewTools(pi, crewManager);
6
- registerCrewCommand(pi, crewManager);
4
+ export function registerCrewIntegration(pi, crew, extensionDir) {
5
+ registerCrewTools(pi, crew, extensionDir);
6
+ registerCrewCommand(pi, crew);
7
7
  registerCrewMessageRenderers(pi);
8
8
  }
@@ -0,0 +1,61 @@
1
+ import type { Api, Model } from "@mariozechner/pi-ai";
2
+ import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
3
+ import type { AgentConfig } from "../agent-discovery.js";
4
+ import { type ActiveRuntimeBinding } from "./delivery-coordinator.js";
5
+ import { type AbortableAgentSummary, type ActiveAgentSummary } from "./subagent-state.js";
6
+ export type { AbortableAgentSummary, ActiveAgentSummary, } from "./subagent-state.js";
7
+ export interface AbortOwnedResult {
8
+ abortedIds: string[];
9
+ missingIds: string[];
10
+ foreignIds: string[];
11
+ }
12
+ interface AbortOptions {
13
+ reason: string;
14
+ }
15
+ export interface SpawnContext {
16
+ model: Model<Api> | undefined;
17
+ modelRegistry: ModelRegistry;
18
+ parentSessionFile?: string;
19
+ onWarning?: (message: string) => void;
20
+ }
21
+ /**
22
+ * Process-level singleton that owns all durable subagent state.
23
+ *
24
+ * This survives extension instance replacement caused by runtime
25
+ * teardown/recreation on /resume, /new, /fork (pi 0.65.0+).
26
+ * Each new extension instance rebinds delivery and widget hooks
27
+ * via activateSession/deactivateSession.
28
+ */
29
+ declare class CrewRuntime {
30
+ private readonly registry;
31
+ private readonly delivery;
32
+ private readonly refreshCallbacks;
33
+ private refreshWidgetFor;
34
+ activateSession(binding: ActiveRuntimeBinding, refreshWidget?: () => void): void;
35
+ deactivateSession(sessionId: string): void;
36
+ spawn(agentConfig: AgentConfig, task: string, cwd: string, ownerSessionId: string, ctx: SpawnContext, extensionResolvedPath: string): string;
37
+ private attachSessionListeners;
38
+ private attachSpawnedSession;
39
+ private settleAgent;
40
+ private disposeAgent;
41
+ private runPromptCycle;
42
+ private spawnSession;
43
+ respond(id: string, message: string, callerSessionId: string): {
44
+ error?: string;
45
+ };
46
+ done(id: string, callerSessionId: string): {
47
+ error?: string;
48
+ };
49
+ abort(id: string, opts: AbortOptions): boolean;
50
+ abortOwned(ids: string[], callerSessionId: string, opts: AbortOptions): AbortOwnedResult;
51
+ abortAllOwned(callerSessionId: string, opts: AbortOptions): string[];
52
+ /**
53
+ * Abort all running subagents (process-level cleanup).
54
+ * Called from process exit hooks.
55
+ */
56
+ abortAll(): void;
57
+ getAbortableAgents(): AbortableAgentSummary[];
58
+ getActiveSummariesForOwner(ownerSessionId: string): ActiveAgentSummary[];
59
+ }
60
+ export declare const crewRuntime: CrewRuntime;
61
+ export type { CrewRuntime };
@@ -1,8 +1,15 @@
1
- import { bootstrapSession } from "./bootstrap-session.js";
2
- import { DeliveryCoordinator } from "./runtime/delivery-coordinator.js";
3
- import { runPromptWithOverflowRecovery } from "./runtime/overflow-recovery.js";
4
- import { SubagentRegistry } from "./runtime/subagent-registry.js";
5
- import { isAbortableStatus, isAborted, } from "./runtime/subagent-state.js";
1
+ import { bootstrapSession } from "../bootstrap-session.js";
2
+ import { DeliveryCoordinator } from "./delivery-coordinator.js";
3
+ import { runPromptWithOverflowRecovery } from "./overflow-recovery.js";
4
+ import { SubagentRegistry } from "./subagent-registry.js";
5
+ import { isAbortableStatus, isAborted, } from "./subagent-state.js";
6
+ function toBootstrapContext(ctx) {
7
+ return {
8
+ model: ctx.model,
9
+ modelRegistry: ctx.modelRegistry,
10
+ parentSessionFile: ctx.parentSessionFile,
11
+ };
12
+ }
6
13
  function getLastAssistantMessage(messages) {
7
14
  for (let i = messages.length - 1; i >= 0; i--) {
8
15
  const msg = messages[i];
@@ -43,26 +50,41 @@ function getPromptOutcome(state) {
43
50
  result: text ?? "(no output)",
44
51
  };
45
52
  }
46
- export class CrewManager {
47
- extensionResolvedPath;
53
+ /**
54
+ * Process-level singleton that owns all durable subagent state.
55
+ *
56
+ * This survives extension instance replacement caused by runtime
57
+ * teardown/recreation on /resume, /new, /fork (pi 0.65.0+).
58
+ * Each new extension instance rebinds delivery and widget hooks
59
+ * via activateSession/deactivateSession.
60
+ */
61
+ class CrewRuntime {
48
62
  registry = new SubagentRegistry();
49
63
  delivery = new DeliveryCoordinator();
50
- overflowAbortControllers = new Map();
51
- onWidgetUpdate;
52
- constructor(extensionResolvedPath) {
53
- this.extensionResolvedPath = extensionResolvedPath;
64
+ // Per-session refresh callbacks, keyed by ownerSessionId
65
+ refreshCallbacks = new Map();
66
+ refreshWidgetFor(sessionId) {
67
+ this.refreshCallbacks.get(sessionId)?.();
54
68
  }
55
- activateSession(sessionId, isIdle, pi) {
56
- this.delivery.activateSession(sessionId, isIdle, pi, (ownerSessionId, excludeId) => this.registry.countRunningForOwner(ownerSessionId, excludeId));
69
+ activateSession(binding, refreshWidget) {
70
+ if (refreshWidget) {
71
+ this.refreshCallbacks.set(binding.sessionId, refreshWidget);
72
+ }
73
+ this.delivery.activateSession(binding, (ownerSessionId, excludeId) => this.registry.countRunningForOwner(ownerSessionId, excludeId));
74
+ refreshWidget?.();
75
+ }
76
+ deactivateSession(sessionId) {
77
+ this.delivery.deactivateSession(sessionId);
78
+ this.refreshCallbacks.delete(sessionId);
57
79
  }
58
- spawn(agentConfig, task, cwd, ownerSessionId, ctx, pi) {
80
+ spawn(agentConfig, task, cwd, ownerSessionId, ctx, extensionResolvedPath) {
59
81
  const state = this.registry.create(agentConfig, task, ownerSessionId);
60
- this.onWidgetUpdate?.();
61
- void this.spawnSession(state, cwd, ctx.sessionManager.getSessionFile(), ctx, pi);
82
+ this.refreshWidgetFor(ownerSessionId);
83
+ void this.spawnSession(state, cwd, ctx, extensionResolvedPath);
62
84
  return state.id;
63
85
  }
64
86
  attachSessionListeners(state, session) {
65
- session.subscribe((event) => {
87
+ state.unsubscribe = session.subscribe((event) => {
66
88
  if (event.type !== "turn_end")
67
89
  return;
68
90
  state.turns++;
@@ -72,7 +94,7 @@ export class CrewManager {
72
94
  state.contextTokens = assistantMsg.usage.totalTokens;
73
95
  state.model = assistantMsg.model;
74
96
  }
75
- this.onWidgetUpdate?.();
97
+ this.refreshWidgetFor(state.ownerSessionId);
76
98
  });
77
99
  }
78
100
  attachSpawnedSession(state, session) {
@@ -83,90 +105,89 @@ export class CrewManager {
83
105
  state.session = session;
84
106
  return true;
85
107
  }
86
- /**
87
- * Single owner for post-prompt and terminal state transitions.
88
- * Publishes the outcome, updates state, and disposes finished subagents.
89
- */
90
- settleAgent(state, nextStatus, opts, pi) {
108
+ settleAgent(state, nextStatus, opts) {
91
109
  state.status = nextStatus;
92
110
  state.result = opts.result;
93
111
  state.error = opts.error;
94
112
  this.delivery.deliver(state.ownerSessionId, {
95
113
  id: state.id,
96
114
  agentName: state.agentConfig.name,
115
+ sessionFile: state.session?.sessionFile,
97
116
  status: state.status,
98
117
  result: state.result,
99
118
  error: state.error,
100
- }, pi, (ownerSessionId, excludeId) => this.registry.countRunningForOwner(ownerSessionId, excludeId));
119
+ }, (ownerSessionId, excludeId) => this.registry.countRunningForOwner(ownerSessionId, excludeId));
101
120
  if (state.status !== "waiting") {
102
121
  this.disposeAgent(state);
103
122
  }
104
123
  else {
105
- this.onWidgetUpdate?.();
124
+ this.refreshWidgetFor(state.ownerSessionId);
106
125
  }
107
126
  }
108
127
  disposeAgent(state) {
128
+ state.unsubscribe?.();
129
+ state.promptAbortController = undefined;
109
130
  state.session?.dispose();
110
131
  this.registry.delete(state.id);
111
- this.onWidgetUpdate?.();
132
+ this.refreshWidgetFor(state.ownerSessionId);
112
133
  }
113
- async runPromptCycle(state, prompt, pi) {
134
+ async runPromptCycle(state, prompt) {
114
135
  if (isAborted(state))
115
136
  return;
116
137
  const abortController = new AbortController();
117
- this.overflowAbortControllers.set(state.id, abortController);
138
+ state.promptAbortController = abortController;
118
139
  try {
119
140
  const recovery = await runPromptWithOverflowRecovery(state.session, prompt, abortController.signal);
120
141
  if (isAborted(state))
121
142
  return;
122
143
  const outcome = getPromptOutcome(state);
123
- // If overflow recovery ran but failed, keep the error from outcome.
124
- // If it recovered, outcome now reflects the retry turn's result.
125
144
  if (recovery === "failed" && outcome.status !== "error") {
126
145
  this.settleAgent(state, "error", {
127
146
  error: "Context overflow recovery failed",
128
- }, pi);
147
+ });
129
148
  return;
130
149
  }
131
- this.settleAgent(state, outcome.status, outcome, pi);
150
+ this.settleAgent(state, outcome.status, outcome);
132
151
  }
133
152
  catch (err) {
134
153
  if (isAborted(state))
135
154
  return;
136
155
  const error = err instanceof Error ? err.message : String(err);
137
- this.settleAgent(state, "error", { error }, pi);
156
+ this.settleAgent(state, "error", { error });
138
157
  }
139
158
  finally {
140
- this.overflowAbortControllers.delete(state.id);
159
+ state.promptAbortController = undefined;
141
160
  }
142
161
  }
143
- async spawnSession(state, cwd, parentSessionFile, ctx, pi) {
162
+ async spawnSession(state, cwd, ctx, extensionResolvedPath) {
144
163
  try {
145
164
  if (isAborted(state))
146
165
  return;
147
- const { session } = await bootstrapSession({
166
+ const { session, warnings } = await bootstrapSession({
148
167
  agentConfig: state.agentConfig,
149
168
  cwd,
150
- ctx,
151
- extensionResolvedPath: this.extensionResolvedPath,
152
- parentSessionFile,
169
+ ctx: toBootstrapContext(ctx),
170
+ extensionResolvedPath,
153
171
  });
172
+ // Emit bootstrap warnings to UI
173
+ for (const warning of warnings) {
174
+ ctx.onWarning?.(warning);
175
+ }
154
176
  if (!this.attachSpawnedSession(state, session))
155
177
  return;
156
178
  this.attachSessionListeners(state, session);
157
- await this.runPromptCycle(state, state.task, pi);
179
+ await this.runPromptCycle(state, state.task);
158
180
  }
159
181
  catch (err) {
160
182
  if (isAborted(state))
161
183
  return;
162
- // Only bootstrap errors reach here; runPromptCycle handles its own errors
163
184
  if (state.status === "running") {
164
185
  const error = err instanceof Error ? err.message : String(err);
165
- this.settleAgent(state, "error", { error }, pi);
186
+ this.settleAgent(state, "error", { error });
166
187
  }
167
188
  }
168
189
  }
169
- respond(id, message, pi, callerSessionId) {
190
+ respond(id, message, callerSessionId) {
170
191
  const state = this.registry.get(id);
171
192
  if (!state)
172
193
  return { error: `No subagent with id "${id}"` };
@@ -181,8 +202,8 @@ export class CrewManager {
181
202
  if (!state.session)
182
203
  return { error: `Subagent "${id}" has no active session` };
183
204
  state.status = "running";
184
- this.onWidgetUpdate?.();
185
- void this.runPromptCycle(state, message, pi);
205
+ this.refreshWidgetFor(state.ownerSessionId);
206
+ void this.runPromptCycle(state, message);
186
207
  return {};
187
208
  }
188
209
  done(id, callerSessionId) {
@@ -198,19 +219,19 @@ export class CrewManager {
198
219
  this.disposeAgent(state);
199
220
  return {};
200
221
  }
201
- abort(id, pi, opts) {
222
+ abort(id, opts) {
202
223
  const state = this.registry.get(id);
203
224
  if (!state || !isAbortableStatus(state.status))
204
225
  return false;
205
- this.overflowAbortControllers.get(id)?.abort();
206
- this.overflowAbortControllers.delete(id);
226
+ state.promptAbortController?.abort();
227
+ state.promptAbortController = undefined;
207
228
  state.session?.abortCompaction();
208
229
  state.session?.abortRetry();
209
230
  state.session?.abort().catch(() => { });
210
- this.settleAgent(state, "aborted", { error: opts.reason }, pi);
231
+ this.settleAgent(state, "aborted", { error: opts.reason });
211
232
  return true;
212
233
  }
213
- abortOwned(ids, callerSessionId, pi, opts) {
234
+ abortOwned(ids, callerSessionId, opts) {
214
235
  const uniqueIds = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean)));
215
236
  const result = {
216
237
  abortedIds: [],
@@ -227,7 +248,7 @@ export class CrewManager {
227
248
  result.foreignIds.push(id);
228
249
  continue;
229
250
  }
230
- if (this.abort(id, pi, opts)) {
251
+ if (this.abort(id, opts)) {
231
252
  result.abortedIds.push(id);
232
253
  }
233
254
  else {
@@ -236,18 +257,22 @@ export class CrewManager {
236
257
  }
237
258
  return result;
238
259
  }
239
- abortAllOwned(callerSessionId, pi, opts) {
260
+ abortAllOwned(callerSessionId, opts) {
240
261
  const ids = this.registry.getOwnedAbortableIds(callerSessionId);
241
262
  for (const id of ids) {
242
- this.abort(id, pi, opts);
263
+ this.abort(id, opts);
243
264
  }
244
265
  return ids;
245
266
  }
246
- abortForOwner(ownerSessionId, pi) {
247
- this.abortAllOwned(ownerSessionId, pi, {
248
- reason: "Aborted on session shutdown",
249
- });
250
- this.delivery.clearPendingForOwner(ownerSessionId);
267
+ /**
268
+ * Abort all running subagents (process-level cleanup).
269
+ * Called from process exit hooks.
270
+ */
271
+ abortAll() {
272
+ const allAgents = this.registry.getAllRunning();
273
+ for (const state of allAgents) {
274
+ this.abort(state.id, { reason: "Aborted on process exit" });
275
+ }
251
276
  }
252
277
  getAbortableAgents() {
253
278
  return this.registry.getAbortableAgents();
@@ -256,3 +281,4 @@ export class CrewManager {
256
281
  return this.registry.getActiveSummariesForOwner(ownerSessionId);
257
282
  }
258
283
  }
284
+ export const crewRuntime = new CrewRuntime();
@@ -1,12 +1,21 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { type SteeringPayload } from "../subagent-messages.js";
1
+ import { type SteeringPayload, type SendMessageFn } from "../subagent-messages.js";
2
+ export interface ActiveRuntimeBinding {
3
+ sessionId: string;
4
+ isIdle: () => boolean;
5
+ sendMessage: SendMessageFn;
6
+ }
3
7
  export declare class DeliveryCoordinator {
4
- private currentSessionId;
5
- private currentIsIdle;
8
+ private binding;
6
9
  private pendingMessages;
7
- activateSession(sessionId: string, isIdle: () => boolean, pi: ExtensionAPI, countRunningForOwner: (ownerSessionId: string, excludeId: string) => number): void;
8
- deliver(ownerSessionId: string, payload: SteeringPayload, pi: ExtensionAPI, countRunningForOwner: (ownerSessionId: string, excludeId: string) => number): void;
9
- clearPendingForOwner(ownerSessionId: string): void;
10
+ private flushScheduled;
11
+ activateSession(binding: ActiveRuntimeBinding, countRunningForOwner: (ownerSessionId: string, excludeId: string) => number): void;
12
+ deactivateSession(sessionId: string): void;
13
+ deliver(ownerSessionId: string, payload: SteeringPayload, countRunningForOwner: (ownerSessionId: string, excludeId: string) => number): void;
14
+ /**
15
+ * Remove pending messages older than the TTL.
16
+ * Called during activateSession to prevent unbounded memory growth.
17
+ */
18
+ private cleanStaleMessages;
10
19
  private flushPending;
11
20
  /**
12
21
  * Result messages always go first. If more subagents are still running and the
@@ -1,42 +1,64 @@
1
1
  import { sendRemainingNote, sendSteeringMessage, } from "../subagent-messages.js";
2
2
  export class DeliveryCoordinator {
3
- currentSessionId;
4
- currentIsIdle = () => true;
3
+ binding;
5
4
  pendingMessages = [];
6
- activateSession(sessionId, isIdle, pi, countRunningForOwner) {
7
- this.currentSessionId = sessionId;
8
- this.currentIsIdle = isIdle;
5
+ flushScheduled = false;
6
+ activateSession(binding, countRunningForOwner) {
7
+ this.binding = binding;
9
8
  // Delay flush to next macrotask. session_start fires before pi-core
10
9
  // calls _reconnectToAgent(), so synchronous delivery would emit agent
11
10
  // events while the session listener is disconnected, losing JSONL persistence.
12
- if (this.pendingMessages.some((entry) => entry.ownerSessionId === sessionId)) {
13
- setTimeout(() => this.flushPending(pi, countRunningForOwner), 0);
11
+ if (this.pendingMessages.some((entry) => entry.ownerSessionId === binding.sessionId)) {
12
+ this.flushScheduled = true;
13
+ setTimeout(() => {
14
+ this.flushScheduled = false;
15
+ this.flushPending(countRunningForOwner);
16
+ }, 0);
14
17
  }
15
18
  }
16
- deliver(ownerSessionId, payload, pi, countRunningForOwner) {
17
- if (ownerSessionId !== this.currentSessionId) {
18
- this.pendingMessages.push({ ownerSessionId, payload });
19
+ deactivateSession(sessionId) {
20
+ if (this.binding?.sessionId === sessionId) {
21
+ this.binding = undefined;
22
+ }
23
+ }
24
+ deliver(ownerSessionId, payload, countRunningForOwner) {
25
+ if (!this.binding || ownerSessionId !== this.binding.sessionId || this.flushScheduled) {
26
+ this.pendingMessages.push({ ownerSessionId, payload, queuedAt: Date.now() });
19
27
  return;
20
28
  }
21
- this.send(ownerSessionId, payload, pi, countRunningForOwner);
29
+ this.send(ownerSessionId, payload, countRunningForOwner);
22
30
  }
23
- clearPendingForOwner(ownerSessionId) {
24
- this.pendingMessages = this.pendingMessages.filter((entry) => entry.ownerSessionId !== ownerSessionId);
31
+ /**
32
+ * Remove pending messages older than the TTL.
33
+ * Called during activateSession to prevent unbounded memory growth.
34
+ */
35
+ cleanStaleMessages() {
36
+ const maxAgeMs = 86_400_000; // 24 hours
37
+ const cutoff = Date.now() - maxAgeMs;
38
+ this.pendingMessages = this.pendingMessages.filter((entry) => entry.queuedAt >= cutoff);
25
39
  }
26
- flushPending(pi, countRunningForOwner) {
40
+ flushPending(countRunningForOwner) {
41
+ if (!this.binding)
42
+ return;
43
+ const targetSessionId = this.binding.sessionId;
44
+ // Clean up stale messages first (older than TTL)
45
+ this.cleanStaleMessages();
27
46
  const toDeliver = [];
28
47
  const remaining = [];
29
48
  for (const entry of this.pendingMessages) {
30
- if (entry.ownerSessionId === this.currentSessionId) {
49
+ if (entry.ownerSessionId === targetSessionId) {
31
50
  toDeliver.push(entry);
32
51
  }
33
52
  else {
53
+ // Keep all other messages - they may be for sessions that will be reactivated later
34
54
  remaining.push(entry);
35
55
  }
36
56
  }
57
+ // Keep messages for other sessions
37
58
  this.pendingMessages = remaining;
59
+ // Deliver messages for the active session
38
60
  for (const entry of toDeliver) {
39
- this.send(entry.ownerSessionId, entry.payload, pi, countRunningForOwner);
61
+ this.send(entry.ownerSessionId, entry.payload, countRunningForOwner);
40
62
  }
41
63
  }
42
64
  /**
@@ -44,15 +66,19 @@ export class DeliveryCoordinator {
44
66
  * owner is idle, queue the result without triggering, then queue the separate
45
67
  * remaining note with triggerTurn so the next turn sees both in order.
46
68
  */
47
- send(ownerSessionId, payload, pi, countRunningForOwner) {
69
+ send(ownerSessionId, payload, countRunningForOwner) {
70
+ if (!this.binding || this.binding.sessionId !== ownerSessionId) {
71
+ this.pendingMessages.push({ ownerSessionId, payload, queuedAt: Date.now() });
72
+ return;
73
+ }
48
74
  const remaining = countRunningForOwner(ownerSessionId, payload.id);
49
- const isIdle = this.currentIsIdle();
75
+ const isIdle = this.binding.isIdle();
50
76
  const triggerResultTurn = !(isIdle && remaining > 0);
51
- sendSteeringMessage(payload, pi, {
77
+ sendSteeringMessage(payload, this.binding.sendMessage, {
52
78
  isIdle,
53
79
  triggerTurn: triggerResultTurn,
54
80
  });
55
- sendRemainingNote(remaining, pi, {
81
+ sendRemainingNote(remaining, this.binding.sendMessage, {
56
82
  isIdle,
57
83
  triggerTurn: isIdle && remaining > 0,
58
84
  });
@@ -10,4 +10,5 @@ export declare class SubagentRegistry {
10
10
  getAbortableAgents(): AbortableAgentSummary[];
11
11
  getActiveSummariesForOwner(ownerSessionId: string): ActiveAgentSummary[];
12
12
  getOwnedAbortableIds(ownerSessionId: string): string[];
13
+ getAllRunning(): SubagentState[];
13
14
  }