pi-crew 0.1.17 → 0.1.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.
@@ -9,6 +9,9 @@ const POST_EXIT_STDIO_GUARD_MS = 3000;
9
9
  const FINAL_DRAIN_MS = 5000;
10
10
  const HARD_KILL_MS = 3000;
11
11
  const MAX_CAPTURE_BYTES = 256 * 1024;
12
+ const MAX_ASSISTANT_TEXT_CHARS = 8192;
13
+ const MAX_TOOL_RESULT_CHARS = 1024;
14
+ const MAX_TOOL_INPUT_CHARS = 2048;
12
15
  const MAX_COMPACT_CONTENT_CHARS = 4096;
13
16
  const activeChildProcesses = new Map<number, ChildProcess>();
14
17
 
@@ -87,9 +90,9 @@ function compactValue(value: unknown): unknown {
87
90
  function compactContentPart(part: unknown): unknown | undefined {
88
91
  const record = asRecord(part);
89
92
  if (!record) return undefined;
90
- if (record.type === "text") return { type: "text", text: typeof record.text === "string" ? compactString(record.text) : "" };
91
- if (record.type === "toolCall") return { type: "toolCall", name: record.name, input: compactValue(record.input) };
92
- if (record.type === "toolResult") return { type: "toolResult", name: record.name, content: compactValue(record.content) };
93
+ if (record.type === "text") return { type: "text", text: typeof record.text === "string" ? compactString(record.text, MAX_ASSISTANT_TEXT_CHARS) : "" };
94
+ if (record.type === "toolCall") return { type: "toolCall", name: record.name, input: compactValue(typeof record.input === "string" ? compactString(record.input, MAX_TOOL_INPUT_CHARS) : record.input) };
95
+ if (record.type === "toolResult") return { type: "toolResult", name: record.name, content: compactValue(typeof record.content === "string" ? compactString(record.content, MAX_TOOL_RESULT_CHARS) : record.content) };
93
96
  return undefined;
94
97
  }
95
98
 
@@ -102,6 +105,7 @@ function compactChildPiEvent(event: unknown): unknown | undefined {
102
105
  }
103
106
  if (record.type === "tool_result_end" || record.type === "message_end" || record.type === "message") {
104
107
  const message = asRecord(record.message);
108
+ if (message?.role === "user" || message?.role === "system") return undefined;
105
109
  const content = Array.isArray(message?.content) ? message.content.map(compactContentPart).filter((part) => part !== undefined) : undefined;
106
110
  return {
107
111
  type: record.type,
@@ -0,0 +1,42 @@
1
+ export interface ResolveBatchConcurrencyInput {
2
+ workflowName: string;
3
+ teamMaxConcurrency?: number;
4
+ limitMaxConcurrentWorkers?: number;
5
+ readyCount: number;
6
+ workspaceMode?: "single" | "worktree";
7
+ readyRoles?: string[];
8
+ }
9
+
10
+ export interface BatchConcurrencyDecision {
11
+ maxConcurrent: number;
12
+ selectedCount: number;
13
+ defaultConcurrency: number;
14
+ reason: string;
15
+ }
16
+
17
+ export function defaultWorkflowConcurrency(workflowName: string): number {
18
+ if (workflowName === "parallel-research") return 4;
19
+ if (workflowName === "research") return 2;
20
+ if (workflowName === "implementation" || workflowName === "review" || workflowName === "default") return 2;
21
+ return 1;
22
+ }
23
+
24
+ function positiveInteger(value: number | undefined): number | undefined {
25
+ if (value === undefined || !Number.isFinite(value)) return undefined;
26
+ return Math.max(1, Math.trunc(value));
27
+ }
28
+
29
+ export function resolveBatchConcurrency(input: ResolveBatchConcurrencyInput): BatchConcurrencyDecision {
30
+ const defaultConcurrency = defaultWorkflowConcurrency(input.workflowName);
31
+ const limitMax = positiveInteger(input.limitMaxConcurrentWorkers);
32
+ const teamMax = positiveInteger(input.teamMaxConcurrency);
33
+ const requested = limitMax ?? teamMax ?? defaultConcurrency;
34
+ const source = limitMax !== undefined ? "limit" : teamMax !== undefined ? "team" : "workflow";
35
+ const readyCount = Math.max(0, Math.trunc(input.readyCount));
36
+ return {
37
+ maxConcurrent: requested,
38
+ selectedCount: readyCount === 0 ? 0 : Math.min(readyCount, requested),
39
+ defaultConcurrency,
40
+ reason: `${source}:${requested};ready:${readyCount}`,
41
+ };
42
+ }
@@ -0,0 +1,43 @@
1
+ export interface ProgressEventSummary {
2
+ eventType: string;
3
+ currentTool?: string;
4
+ toolCount?: number;
5
+ tokens?: number;
6
+ turns?: number;
7
+ activityState?: string;
8
+ lastActivityAt?: string;
9
+ }
10
+
11
+ export interface ProgressEventCoalesceDecision {
12
+ shouldAppend: boolean;
13
+ reason: string;
14
+ }
15
+
16
+ export interface ProgressEventCoalesceInput {
17
+ previous?: ProgressEventSummary;
18
+ next: ProgressEventSummary;
19
+ nowMs: number;
20
+ lastAppendMs?: number;
21
+ minIntervalMs: number;
22
+ force?: boolean;
23
+ tokenThreshold?: number;
24
+ }
25
+
26
+ const DEFAULT_TOKEN_THRESHOLD = 256;
27
+
28
+ function numericIncrease(previous: number | undefined, next: number | undefined): number {
29
+ return next !== undefined && previous !== undefined ? next - previous : next !== undefined ? next : 0;
30
+ }
31
+
32
+ export function shouldAppendProgressEventUpdate(input: ProgressEventCoalesceInput): ProgressEventCoalesceDecision {
33
+ if (input.force) return { shouldAppend: true, reason: "force" };
34
+ if (!input.previous) return { shouldAppend: true, reason: "first" };
35
+ if (input.previous.activityState !== input.next.activityState) return { shouldAppend: true, reason: "activity_changed" };
36
+ if (input.previous.currentTool !== input.next.currentTool) return { shouldAppend: true, reason: "tool_changed" };
37
+ if (numericIncrease(input.previous.toolCount, input.next.toolCount) > 0) return { shouldAppend: true, reason: "tool_count_increased" };
38
+ if (numericIncrease(input.previous.turns, input.next.turns) > 0) return { shouldAppend: true, reason: "turns_increased" };
39
+ const tokenIncrease = numericIncrease(input.previous.tokens, input.next.tokens);
40
+ if (tokenIncrease >= (input.tokenThreshold ?? DEFAULT_TOKEN_THRESHOLD)) return { shouldAppend: true, reason: "tokens_increased" };
41
+ if (input.lastAppendMs === undefined || input.nowMs - input.lastAppendMs >= input.minIntervalMs) return { shouldAppend: true, reason: "interval" };
42
+ return { shouldAppend: false, reason: "coalesced" };
43
+ }
@@ -0,0 +1,202 @@
1
+ import { loadRunManifestById } from "../state/state-store.ts";
2
+ import type { PiTeamsToolResult } from "../extension/tool-result.ts";
3
+
4
+ export type SubagentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "error" | "stopped";
5
+
6
+ export interface SubagentSpawnOptions {
7
+ cwd: string;
8
+ type: string;
9
+ description: string;
10
+ prompt: string;
11
+ background: boolean;
12
+ model?: string;
13
+ maxTurns?: number;
14
+ }
15
+
16
+ export interface SubagentRecord {
17
+ id: string;
18
+ runId?: string;
19
+ type: string;
20
+ description: string;
21
+ prompt: string;
22
+ status: SubagentStatus;
23
+ startedAt: number;
24
+ completedAt?: number;
25
+ result?: string;
26
+ error?: string;
27
+ resultConsumed?: boolean;
28
+ model?: string;
29
+ background: boolean;
30
+ promise?: Promise<void>;
31
+ }
32
+
33
+ type SpawnRunner = (options: SubagentSpawnOptions, signal?: AbortSignal) => Promise<PiTeamsToolResult>;
34
+ type Notify = (record: SubagentRecord) => void;
35
+
36
+ interface QueuedSpawn {
37
+ record: SubagentRecord;
38
+ options: SubagentSpawnOptions;
39
+ runner: SpawnRunner;
40
+ signal?: AbortSignal;
41
+ }
42
+
43
+ const TERMINAL_RUN_STATUS = new Set(["completed", "failed", "cancelled", "blocked"]);
44
+
45
+ function resultText(result: PiTeamsToolResult): string {
46
+ return result.content?.map((item) => item.type === "text" ? item.text : "").filter(Boolean).join("\n") ?? "";
47
+ }
48
+
49
+ function detailsRunId(result: PiTeamsToolResult): string | undefined {
50
+ const details = result.details as { runId?: unknown } | undefined;
51
+ return typeof details?.runId === "string" ? details.runId : undefined;
52
+ }
53
+
54
+ export class SubagentManager {
55
+ private readonly records = new Map<string, SubagentRecord>();
56
+ private queue: QueuedSpawn[] = [];
57
+ private runningBackground = 0;
58
+ private counter = 0;
59
+ private maxConcurrent: number;
60
+ private readonly onComplete?: Notify;
61
+ private readonly pollIntervalMs: number;
62
+
63
+ constructor(maxConcurrent = 4, onComplete?: Notify, pollIntervalMs = 1000) {
64
+ this.maxConcurrent = maxConcurrent;
65
+ this.onComplete = onComplete;
66
+ this.pollIntervalMs = pollIntervalMs;
67
+ }
68
+
69
+ spawn(options: SubagentSpawnOptions, runner: SpawnRunner, signal?: AbortSignal): SubagentRecord {
70
+ const record: SubagentRecord = {
71
+ id: `agent_${Date.now().toString(36)}_${(++this.counter).toString(36)}`,
72
+ type: options.type,
73
+ description: options.description,
74
+ prompt: options.prompt,
75
+ status: options.background && this.runningBackground >= this.maxConcurrent ? "queued" : "running",
76
+ startedAt: Date.now(),
77
+ model: options.model,
78
+ background: options.background,
79
+ };
80
+ this.records.set(record.id, record);
81
+ if (record.status === "queued") {
82
+ this.queue.push({ record, options, runner, signal });
83
+ return record;
84
+ }
85
+ this.start(record, options, runner, signal);
86
+ return record;
87
+ }
88
+
89
+ getRecord(id: string): SubagentRecord | undefined {
90
+ return this.records.get(id);
91
+ }
92
+
93
+ listAgents(): SubagentRecord[] {
94
+ return [...this.records.values()].sort((a, b) => b.startedAt - a.startedAt);
95
+ }
96
+
97
+ abort(id: string): boolean {
98
+ const record = this.records.get(id);
99
+ if (!record) return false;
100
+ if (record.status === "queued") {
101
+ this.queue = this.queue.filter((entry) => entry.record.id !== id);
102
+ record.status = "stopped";
103
+ record.completedAt = Date.now();
104
+ return true;
105
+ }
106
+ if (record.status !== "running") return false;
107
+ record.status = "stopped";
108
+ record.completedAt = Date.now();
109
+ return true;
110
+ }
111
+
112
+ abortAll(): number {
113
+ let count = 0;
114
+ for (const entry of this.queue) {
115
+ entry.record.status = "stopped";
116
+ entry.record.completedAt = Date.now();
117
+ count++;
118
+ }
119
+ this.queue = [];
120
+ for (const record of this.records.values()) {
121
+ if (record.status === "running") {
122
+ record.status = "stopped";
123
+ record.completedAt = Date.now();
124
+ count++;
125
+ }
126
+ }
127
+ return count;
128
+ }
129
+
130
+ async waitForAll(): Promise<void> {
131
+ while (true) {
132
+ this.drainQueue();
133
+ const pending = this.listAgents().filter((record) => record.status === "running" || record.status === "queued").map((record) => record.promise).filter((promise): promise is Promise<void> => Boolean(promise));
134
+ if (!pending.length) break;
135
+ await Promise.allSettled(pending);
136
+ }
137
+ }
138
+
139
+ async waitForRecord(id: string): Promise<SubagentRecord | undefined> {
140
+ while (true) {
141
+ const record = this.records.get(id);
142
+ if (!record) return undefined;
143
+ if (record.status !== "running" && record.status !== "queued") return record;
144
+ if (record.promise) await record.promise;
145
+ else await new Promise((resolve) => setTimeout(resolve, 100));
146
+ }
147
+ }
148
+
149
+ setMaxConcurrent(value: number): void {
150
+ this.maxConcurrent = Math.max(1, Math.floor(value));
151
+ this.drainQueue();
152
+ }
153
+
154
+ private start(record: SubagentRecord, options: SubagentSpawnOptions, runner: SpawnRunner, signal?: AbortSignal): void {
155
+ if (options.background) this.runningBackground++;
156
+ record.status = "running";
157
+ record.startedAt = Date.now();
158
+ record.promise = (async () => {
159
+ try {
160
+ const result = await runner(options, signal);
161
+ record.runId = detailsRunId(result);
162
+ record.result = resultText(result);
163
+ if (result.isError) {
164
+ record.status = "error";
165
+ record.error = record.result;
166
+ return;
167
+ }
168
+ if (record.runId) await this.pollRunToTerminal(options.cwd, record);
169
+ else record.status = "completed";
170
+ } catch (error) {
171
+ record.status = "error";
172
+ record.error = error instanceof Error ? error.message : String(error);
173
+ } finally {
174
+ if (options.background) this.runningBackground = Math.max(0, this.runningBackground - 1);
175
+ record.completedAt = record.completedAt ?? Date.now();
176
+ this.onComplete?.(record);
177
+ this.drainQueue();
178
+ }
179
+ })();
180
+ }
181
+
182
+ private drainQueue(): void {
183
+ while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
184
+ const next = this.queue.shift();
185
+ if (!next || next.record.status !== "queued") continue;
186
+ this.start(next.record, next.options, next.runner, next.signal);
187
+ }
188
+ }
189
+
190
+ private async pollRunToTerminal(cwd: string, record: SubagentRecord): Promise<void> {
191
+ while (record.runId && record.status === "running") {
192
+ const loaded = loadRunManifestById(cwd, record.runId);
193
+ if (loaded && TERMINAL_RUN_STATUS.has(loaded.manifest.status)) {
194
+ record.status = loaded.manifest.status === "completed" ? "completed" : loaded.manifest.status === "cancelled" ? "cancelled" : "failed";
195
+ record.error = record.status === "completed" ? undefined : loaded.manifest.summary;
196
+ record.completedAt = Date.now();
197
+ return;
198
+ }
199
+ await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
200
+ }
201
+ }
202
+ }
@@ -0,0 +1,38 @@
1
+ import type { TeamTaskState } from "../state/types.ts";
2
+ import type { CrewAgentRecord, CrewRuntimeKind } from "./crew-agent-runtime.ts";
3
+ import { recordFromTask } from "./crew-agent-records.ts";
4
+ import type { TeamRunManifest } from "../state/types.ts";
5
+
6
+ export function shouldMaterializeAgent(task: TeamTaskState): boolean {
7
+ return task.status !== "queued" && task.status !== "skipped";
8
+ }
9
+
10
+ export function recordsForMaterializedTasks(manifest: TeamRunManifest, tasks: TeamTaskState[], runtime: CrewRuntimeKind): CrewAgentRecord[] {
11
+ return tasks.filter(shouldMaterializeAgent).map((task) => recordFromTask(manifest, task, runtime));
12
+ }
13
+
14
+ export function taskById(tasks: TeamTaskState[]): Map<string, TeamTaskState> {
15
+ const map = new Map<string, TeamTaskState>();
16
+ for (const task of tasks) {
17
+ map.set(task.id, task);
18
+ if (task.stepId) map.set(task.stepId, task);
19
+ }
20
+ return map;
21
+ }
22
+
23
+ export function waitingReason(task: TeamTaskState, tasks: TeamTaskState[]): string | undefined {
24
+ if (task.status !== "queued") return undefined;
25
+ const byId = taskById(tasks);
26
+ const waiting = task.dependsOn.map((id) => byId.get(id)?.id ?? id).filter((id) => byId.get(id)?.status !== "completed");
27
+ if (waiting.length === 0) return "ready";
28
+ return `waiting for ${waiting.join(", ")}`;
29
+ }
30
+
31
+ export function formatTaskGraphLines(tasks: TeamTaskState[]): string[] {
32
+ if (tasks.length === 0) return ["- (none)"];
33
+ return tasks.map((task) => {
34
+ const icon = task.status === "completed" ? "✓" : task.status === "running" ? "⠋" : task.status === "failed" ? "✗" : task.status === "cancelled" || task.status === "skipped" ? "■" : "◦";
35
+ const wait = waitingReason(task, tasks);
36
+ return `- ${icon} ${task.id} [${task.status}] ${task.role}->${task.agent}${wait && wait !== "ready" ? ` (${wait})` : ""}`;
37
+ });
38
+ }
@@ -22,6 +22,7 @@ import { parseSessionUsage } from "./session-usage.ts";
22
22
  import type { CrewAgentProgress, CrewRuntimeKind } from "./crew-agent-runtime.ts";
23
23
  import { buildMemoryBlock } from "./agent-memory.ts";
24
24
  import { runLiveSessionTask } from "./live-session-runtime.ts";
25
+ import { shouldAppendProgressEventUpdate, type ProgressEventSummary } from "./progress-event-coalescer.ts";
25
26
 
26
27
  export interface TaskRunnerInput {
27
28
  manifest: TeamRunManifest;
@@ -201,7 +202,7 @@ function cleanResultText(text: string | undefined): string | undefined {
201
202
  return trimmed;
202
203
  }
203
204
 
204
- function progressEventSummary(task: TeamTaskState, event: unknown): Record<string, unknown> {
205
+ function progressEventSummary(task: TeamTaskState, event: unknown): ProgressEventSummary {
205
206
  const type = asRecord(event)?.type;
206
207
  return {
207
208
  eventType: typeof type === "string" ? type : "event",
@@ -306,14 +307,18 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
306
307
  const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
307
308
  let lastAgentRecordPersistedAt = 0;
308
309
  let lastRunProgressPersistedAt = 0;
310
+ let lastRunProgressSummary: ProgressEventSummary | undefined;
309
311
  const persistChildProgress = (event: unknown, force = false): void => {
310
312
  const now = Date.now();
311
313
  if (force || shouldFlushProgressEvent(event) || now - lastAgentRecordPersistedAt >= 500) {
312
314
  upsertCrewAgent(manifest, recordFromTask(manifest, task, "child-process"));
313
315
  lastAgentRecordPersistedAt = now;
314
316
  }
315
- if (force || shouldFlushProgressEvent(event) || now - lastRunProgressPersistedAt >= 1000) {
316
- appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: progressEventSummary(task, event) });
317
+ const summary = progressEventSummary(task, event);
318
+ const decision = shouldAppendProgressEventUpdate({ previous: lastRunProgressSummary, next: summary, nowMs: now, lastAppendMs: lastRunProgressPersistedAt || undefined, minIntervalMs: 1000, force });
319
+ if (decision.shouldAppend) {
320
+ appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: { ...summary, coalesceReason: decision.reason } });
321
+ lastRunProgressSummary = summary;
317
322
  lastRunProgressPersistedAt = now;
318
323
  }
319
324
  };
@@ -389,14 +394,18 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
389
394
  const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
390
395
  let lastAgentRecordPersistedAt = 0;
391
396
  let lastRunProgressPersistedAt = 0;
397
+ let lastRunProgressSummary: ProgressEventSummary | undefined;
392
398
  const persistLiveProgress = (event: unknown, force = false): void => {
393
399
  const now = Date.now();
394
400
  if (force || shouldFlushProgressEvent(event) || now - lastAgentRecordPersistedAt >= 500) {
395
401
  upsertCrewAgent(manifest, recordFromTask(manifest, task, "live-session"));
396
402
  lastAgentRecordPersistedAt = now;
397
403
  }
398
- if (force || shouldFlushProgressEvent(event) || now - lastRunProgressPersistedAt >= 1000) {
399
- appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: progressEventSummary(task, event) });
404
+ const summary = progressEventSummary(task, event);
405
+ const decision = shouldAppendProgressEventUpdate({ previous: lastRunProgressSummary, next: summary, nowMs: now, lastAppendMs: lastRunProgressPersistedAt || undefined, minIntervalMs: 1000, force });
406
+ if (decision.shouldAppend) {
407
+ appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, taskId: task.id, data: { ...summary, coalesceReason: decision.reason } });
408
+ lastRunProgressSummary = summary;
400
409
  lastRunProgressPersistedAt = now;
401
410
  }
402
411
  };