pi-crew 0.5.2 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +183 -0
- package/README.md +17 -1
- package/docs/architecture.md +2 -0
- package/docs/bugs/cross-session-notification-leakage.md +82 -0
- package/docs/coding-agent-optimization.md +268 -0
- package/docs/deep-review-report.md +384 -0
- package/docs/distillation/cybersecurity-patterns.md +294 -0
- package/docs/migration-v0.4-v0.5.md +208 -0
- package/docs/optimization-plan.md +642 -0
- package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
- package/docs/pi-mono-opportunities.md +969 -0
- package/docs/pi-mono-review.md +291 -0
- package/docs/skills/REFERENCE.md +144 -0
- package/package.json +12 -9
- package/skills/artifact-analysis-loop/SKILL.md +302 -0
- package/skills/async-worker-recovery/SKILL.md +19 -1
- package/skills/child-pi-spawning/SKILL.md +19 -6
- package/skills/context-artifact-hygiene/SKILL.md +19 -2
- package/skills/delegation-patterns/SKILL.md +68 -3
- package/skills/detection-pipeline-design/SKILL.md +285 -0
- package/skills/event-log-tracing/SKILL.md +20 -6
- package/skills/git-master/SKILL.md +20 -6
- package/skills/hunting-investigation-loop/SKILL.md +401 -0
- package/skills/incident-playbook-construction/SKILL.md +383 -0
- package/skills/live-agent-lifecycle/SKILL.md +20 -6
- package/skills/mailbox-interactive/SKILL.md +19 -6
- package/skills/model-routing-context/SKILL.md +19 -1
- package/skills/multi-perspective-review/SKILL.md +19 -4
- package/skills/observability-reliability/SKILL.md +19 -2
- package/skills/orchestration/SKILL.md +20 -2
- package/skills/ownership-session-security/SKILL.md +20 -2
- package/skills/pi-extension-lifecycle/SKILL.md +20 -2
- package/skills/post-mortem/SKILL.md +7 -2
- package/skills/read-only-explorer/SKILL.md +20 -6
- package/skills/requirements-to-task-packet/SKILL.md +23 -3
- package/skills/resource-discovery-config/SKILL.md +20 -2
- package/skills/runtime-state-reader/SKILL.md +20 -2
- package/skills/safe-bash/SKILL.md +21 -6
- package/skills/scrutinize/SKILL.md +20 -2
- package/skills/secure-agent-orchestration-review/SKILL.md +29 -2
- package/skills/security-review/SKILL.md +560 -0
- package/skills/state-mutation-locking/SKILL.md +22 -2
- package/skills/systematic-debugging/SKILL.md +8 -6
- package/skills/threat-hypothesis-framework/SKILL.md +175 -0
- package/skills/ui-render-performance/SKILL.md +20 -2
- package/skills/verification-before-done/SKILL.md +17 -2
- package/skills/widget-rendering/SKILL.md +21 -6
- package/skills/workspace-isolation/SKILL.md +20 -6
- package/skills/worktree-isolation/SKILL.md +20 -6
- package/src/agents/agent-config.ts +40 -1
- package/src/benchmark/benchmark-runner.ts +45 -0
- package/src/benchmark/feedback-loop.ts +5 -0
- package/src/config/config.ts +32 -5
- package/src/config/role-tools.ts +82 -0
- package/src/config/suggestions.ts +8 -0
- package/src/config/types.ts +4 -0
- package/src/extension/async-notifier.ts +10 -1
- package/src/extension/crew-cleanup.ts +114 -0
- package/src/extension/cross-extension-rpc.ts +1 -1
- package/src/extension/notification-router.ts +18 -0
- package/src/extension/register.ts +27 -19
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/team-tool/anchor.ts +201 -0
- package/src/extension/team-tool/api.ts +2 -1
- package/src/extension/team-tool/auto-summarize.ts +154 -0
- package/src/extension/team-tool/run.ts +42 -7
- package/src/extension/team-tool.ts +44 -2
- package/src/hooks/registry.ts +1 -3
- package/src/observability/event-bus.ts +69 -0
- package/src/observability/event-to-metric.ts +0 -2
- package/src/runtime/anchor-manager.ts +473 -0
- package/src/runtime/async-runner.ts +8 -4
- package/src/runtime/auto-summarize.ts +350 -0
- package/src/runtime/background-runner.ts +10 -3
- package/src/runtime/budget-tracker.ts +354 -0
- package/src/runtime/chain-runner.ts +507 -0
- package/src/runtime/child-pi.ts +123 -35
- package/src/runtime/crash-recovery.ts +5 -4
- package/src/runtime/crew-agent-runtime.ts +1 -0
- package/src/runtime/custom-tools/irc-tool.ts +13 -0
- package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
- package/src/runtime/delivery-coordinator.ts +10 -3
- package/src/runtime/dynamic-script-runner.ts +482 -0
- package/src/runtime/foreground-control.ts +87 -17
- package/src/runtime/handoff-manager.ts +589 -0
- package/src/runtime/hidden-handoff.ts +424 -0
- package/src/runtime/live-agent-manager.ts +20 -4
- package/src/runtime/live-session-runtime.ts +39 -4
- package/src/runtime/manifest-cache.ts +2 -1
- package/src/runtime/model-resolver.ts +16 -4
- package/src/runtime/phase-tracker.ts +373 -0
- package/src/runtime/pi-args.ts +11 -1
- package/src/runtime/pi-json-output.ts +31 -0
- package/src/runtime/pipeline-runner.ts +514 -0
- package/src/runtime/progress-tracker.ts +124 -0
- package/src/runtime/retry-runner.ts +354 -0
- package/src/runtime/sandbox.ts +252 -0
- package/src/runtime/scheduler.ts +7 -2
- package/src/runtime/skill-effectiveness.ts +473 -0
- package/src/runtime/skill-instructions.ts +37 -3
- package/src/runtime/subagent-manager.ts +1 -1
- package/src/runtime/task-graph.ts +11 -1
- package/src/runtime/task-runner.ts +92 -18
- package/src/runtime/team-runner.ts +13 -12
- package/src/runtime/tool-progress.ts +10 -3
- package/src/runtime/verification-gates.ts +367 -0
- package/src/schema/team-tool-schema.ts +37 -0
- package/src/skills/discover-skills.ts +5 -0
- package/src/state/active-run-registry.ts +9 -2
- package/src/state/contracts.ts +9 -0
- package/src/state/crew-init.ts +3 -3
- package/src/state/decision-ledger.ts +98 -55
- package/src/state/event-log-rotation.ts +2 -2
- package/src/state/event-log.ts +144 -10
- package/src/state/hook-instinct-bridge.ts +5 -5
- package/src/state/mailbox.ts +10 -0
- package/src/state/run-cache.ts +18 -8
- package/src/state/state-store.ts +3 -1
- package/src/state/types.ts +4 -0
- package/src/tools/safe-bash-extension.ts +1 -0
- package/src/tools/safe-bash.ts +152 -20
- package/src/types/new-api-types.ts +34 -0
- package/src/ui/agent-management-overlay.ts +5 -1
- package/src/ui/crew-widget.ts +29 -15
- package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
- package/src/ui/powerbar-publisher.ts +101 -7
- package/src/ui/tool-render.ts +15 -15
- package/src/ui/transcript-cache.ts +13 -0
- package/src/utils/bm25-search.ts +16 -8
- package/src/utils/env-filter.ts +8 -5
- package/src/utils/redaction.ts +169 -15
- package/src/utils/session-utils.ts +52 -0
- package/src/utils/sse-parser.ts +10 -1
- package/src/worktree/cleanup.ts +6 -1
- package/src/worktree/worktree-manager.ts +32 -13
- package/workflows/chain.workflow.md +252 -0
- package/workflows/pipeline.workflow.md +27 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import type { TeamTaskState } from "../state/types.ts";
|
|
2
|
+
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
3
|
+
import type { TeamConfig } from "../teams/team-config.ts";
|
|
4
|
+
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
5
|
+
import { writeArtifact } from "../state/artifact-store.ts";
|
|
6
|
+
import { appendEvent, appendEventAsync } from "../state/event-log.ts";
|
|
7
|
+
import { mapConcurrent } from "./parallel-utils.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pipeline stage configuration.
|
|
11
|
+
*/
|
|
12
|
+
export interface PipelineStage {
|
|
13
|
+
name: string;
|
|
14
|
+
team: string;
|
|
15
|
+
inputs: unknown;
|
|
16
|
+
/** Enable fan-out when inputs is an array (default: true) */
|
|
17
|
+
fanOut?: boolean;
|
|
18
|
+
/** Maximum concurrent executions for fan-out (default: 5) */
|
|
19
|
+
maxConcurrency?: number;
|
|
20
|
+
/** Stop pipeline if this stage fails (default: true) */
|
|
21
|
+
stopOnError?: boolean;
|
|
22
|
+
/** Pass previous stage results as inputs (default: true) */
|
|
23
|
+
usePreviousResults?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pipeline workflow configuration.
|
|
28
|
+
*/
|
|
29
|
+
export interface PipelineWorkflow {
|
|
30
|
+
name: string;
|
|
31
|
+
description: string;
|
|
32
|
+
goal: string;
|
|
33
|
+
stages: PipelineStage[];
|
|
34
|
+
/** Stop pipeline if any stage fails (default: true) */
|
|
35
|
+
stopOnError?: boolean;
|
|
36
|
+
/** Default max concurrency for fan-out stages */
|
|
37
|
+
defaultMaxConcurrency?: number;
|
|
38
|
+
/** Context passed to all stages */
|
|
39
|
+
context?: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Context passed to each stage execution.
|
|
44
|
+
*/
|
|
45
|
+
export interface PipelineContext {
|
|
46
|
+
stageIndex: number;
|
|
47
|
+
stageName: string;
|
|
48
|
+
previousResults: unknown[];
|
|
49
|
+
totalStages: number;
|
|
50
|
+
runId: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Result of a single stage execution.
|
|
55
|
+
*/
|
|
56
|
+
export interface StageResult {
|
|
57
|
+
name: string;
|
|
58
|
+
status: "completed" | "failed" | "skipped";
|
|
59
|
+
results: unknown[];
|
|
60
|
+
error?: string;
|
|
61
|
+
duration: number;
|
|
62
|
+
fanOutItems?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Complete pipeline execution result.
|
|
67
|
+
*/
|
|
68
|
+
export interface PipelineResult {
|
|
69
|
+
stages: StageResult[];
|
|
70
|
+
totalDuration: number;
|
|
71
|
+
finalResults: unknown[];
|
|
72
|
+
status: "completed" | "failed" | "partial";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* PipelineRunner executes multi-stage workflows with automatic fan-out
|
|
77
|
+
* for array inputs.
|
|
78
|
+
*/
|
|
79
|
+
export class PipelineRunner {
|
|
80
|
+
private stopOnError: boolean;
|
|
81
|
+
private defaultMaxConcurrency: number;
|
|
82
|
+
|
|
83
|
+
constructor(options?: { stopOnError?: boolean; defaultMaxConcurrency?: number }) {
|
|
84
|
+
this.stopOnError = options?.stopOnError ?? true;
|
|
85
|
+
this.defaultMaxConcurrency = options?.defaultMaxConcurrency ?? 5;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Execute a pipeline workflow.
|
|
90
|
+
* @param workflow - The pipeline workflow definition
|
|
91
|
+
* @param context - Additional context for execution
|
|
92
|
+
* @param executeStage - Function to execute a single stage
|
|
93
|
+
* @param runId - Run identifier for event logging
|
|
94
|
+
* @param eventsPath - Path to event log file
|
|
95
|
+
*/
|
|
96
|
+
async run(
|
|
97
|
+
workflow: PipelineWorkflow,
|
|
98
|
+
context: Record<string, unknown>,
|
|
99
|
+
executeStage: (stage: PipelineStage, inputs: unknown, stageContext: PipelineContext) => Promise<unknown>,
|
|
100
|
+
runId: string,
|
|
101
|
+
eventsPath: string,
|
|
102
|
+
): Promise<PipelineResult> {
|
|
103
|
+
const stages: StageResult[] = [];
|
|
104
|
+
let previousResults: unknown[] = [];
|
|
105
|
+
const startTime = Date.now();
|
|
106
|
+
|
|
107
|
+
await appendEventAsync(eventsPath, {
|
|
108
|
+
type: "pipeline:started",
|
|
109
|
+
runId,
|
|
110
|
+
message: `Pipeline '${workflow.name}' started`,
|
|
111
|
+
data: { stages: workflow.stages.map((s) => s.name) },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < workflow.stages.length; i++) {
|
|
115
|
+
const stage = workflow.stages[i];
|
|
116
|
+
const stageStartTime = Date.now();
|
|
117
|
+
|
|
118
|
+
// Determine stop behavior for this stage
|
|
119
|
+
const effectiveStopOnError = stage.stopOnError ?? workflow.stopOnError ?? this.stopOnError;
|
|
120
|
+
|
|
121
|
+
await appendEventAsync(eventsPath, {
|
|
122
|
+
type: "pipeline:stage_started",
|
|
123
|
+
runId,
|
|
124
|
+
message: `Stage '${stage.name}' started`,
|
|
125
|
+
data: { stageIndex: i, stageName: stage.name },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// Build stage context
|
|
130
|
+
const stageContext: PipelineContext = {
|
|
131
|
+
stageIndex: i,
|
|
132
|
+
stageName: stage.name,
|
|
133
|
+
previousResults,
|
|
134
|
+
totalStages: workflow.stages.length,
|
|
135
|
+
runId,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Resolve inputs
|
|
139
|
+
const inputs = this.resolveInputs(stage.inputs, previousResults, context);
|
|
140
|
+
|
|
141
|
+
// Execute stage (handle fan-out if enabled)
|
|
142
|
+
const results = await this.executeStageInternal(
|
|
143
|
+
stage,
|
|
144
|
+
inputs,
|
|
145
|
+
stageContext,
|
|
146
|
+
executeStage,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const duration = Date.now() - stageStartTime;
|
|
150
|
+
stages.push({
|
|
151
|
+
name: stage.name,
|
|
152
|
+
status: "completed",
|
|
153
|
+
results,
|
|
154
|
+
duration,
|
|
155
|
+
fanOutItems: Array.isArray(inputs) ? inputs.length : undefined,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
previousResults = results;
|
|
159
|
+
|
|
160
|
+
await appendEventAsync(eventsPath, {
|
|
161
|
+
type: "pipeline:stage_completed",
|
|
162
|
+
runId,
|
|
163
|
+
message: `Stage '${stage.name}' completed`,
|
|
164
|
+
data: { stageIndex: i, stageName: stage.name, duration, resultCount: results.length },
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const duration = Date.now() - stageStartTime;
|
|
168
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
169
|
+
|
|
170
|
+
if (effectiveStopOnError) {
|
|
171
|
+
stages.push({
|
|
172
|
+
name: stage.name,
|
|
173
|
+
status: "failed",
|
|
174
|
+
results: [],
|
|
175
|
+
error: errorMessage,
|
|
176
|
+
duration,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await appendEventAsync(eventsPath, {
|
|
180
|
+
type: "pipeline:stage_failed",
|
|
181
|
+
runId,
|
|
182
|
+
message: `Stage '${stage.name}' failed: ${errorMessage}`,
|
|
183
|
+
data: { stageIndex: i, stageName: stage.name, duration, error: errorMessage },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await appendEventAsync(eventsPath, {
|
|
187
|
+
type: "pipeline:failed",
|
|
188
|
+
runId,
|
|
189
|
+
message: `Pipeline '${workflow.name}' failed at stage '${stage.name}'`,
|
|
190
|
+
data: { failedStage: stage.name, error: errorMessage },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
stages,
|
|
195
|
+
totalDuration: Date.now() - startTime,
|
|
196
|
+
finalResults: previousResults,
|
|
197
|
+
status: "failed",
|
|
198
|
+
};
|
|
199
|
+
} else {
|
|
200
|
+
stages.push({
|
|
201
|
+
name: stage.name,
|
|
202
|
+
status: "failed",
|
|
203
|
+
results: [],
|
|
204
|
+
error: errorMessage,
|
|
205
|
+
duration,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await appendEventAsync(eventsPath, {
|
|
209
|
+
type: "pipeline:stage_skipped",
|
|
210
|
+
runId,
|
|
211
|
+
message: `Stage '${stage.name}' skipped due to error`,
|
|
212
|
+
data: { stageIndex: i, stageName: stage.name, duration, error: errorMessage },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await appendEventAsync(eventsPath, {
|
|
219
|
+
type: "pipeline:completed",
|
|
220
|
+
runId,
|
|
221
|
+
message: `Pipeline '${workflow.name}' completed`,
|
|
222
|
+
data: { stages: stages.map((s) => ({ name: s.name, status: s.status })) },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
stages,
|
|
227
|
+
totalDuration: Date.now() - startTime,
|
|
228
|
+
finalResults: previousResults,
|
|
229
|
+
status: stages.some((s) => s.status === "failed") ? "partial" : "completed",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Execute a single stage, handling fan-out for array inputs.
|
|
235
|
+
* Uses depth parameter to prevent stack overflow from deep recursion.
|
|
236
|
+
*/
|
|
237
|
+
private async executeStageInternal(
|
|
238
|
+
stage: PipelineStage,
|
|
239
|
+
inputs: unknown,
|
|
240
|
+
stageContext: PipelineContext,
|
|
241
|
+
callback: (stage: PipelineStage, inputs: unknown, stageContext: PipelineContext) => Promise<unknown>,
|
|
242
|
+
depth: number = 0,
|
|
243
|
+
): Promise<unknown[]> {
|
|
244
|
+
// CRITICAL-6: Prevent stack overflow from deep recursion
|
|
245
|
+
if (depth > 50) {
|
|
246
|
+
throw new Error(`Pipeline recursion depth limit exceeded (${depth}). Possible circular stage dependency.`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const fanOut = stage.fanOut ?? true;
|
|
250
|
+
const maxConcurrency = stage.maxConcurrency ?? this.defaultMaxConcurrency;
|
|
251
|
+
|
|
252
|
+
// Fan-out if inputs is an array with multiple items to process.
|
|
253
|
+
// We don't fan-out for single-element arrays as they typically represent
|
|
254
|
+
// the result of a previous stage that returned a single value.
|
|
255
|
+
const shouldFanOut = fanOut && Array.isArray(inputs) && inputs.length > 1;
|
|
256
|
+
|
|
257
|
+
// Increment depth for non-fan-out path
|
|
258
|
+
const nextDepth = depth + 1;
|
|
259
|
+
|
|
260
|
+
if (shouldFanOut) {
|
|
261
|
+
const tasks = (inputs as unknown[]).map((item, index) => ({
|
|
262
|
+
item,
|
|
263
|
+
index,
|
|
264
|
+
name: `${stage.name}[${index}]`,
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
// Execute with concurrency limit - pass each item to callback
|
|
268
|
+
const results = await mapConcurrent(
|
|
269
|
+
tasks,
|
|
270
|
+
maxConcurrency,
|
|
271
|
+
async (task) => {
|
|
272
|
+
// Call the user-provided callback with each item
|
|
273
|
+
const result = await this.executeStageInternal(stage, task.item, {
|
|
274
|
+
...stageContext,
|
|
275
|
+
stageName: task.name,
|
|
276
|
+
}, callback, nextDepth);
|
|
277
|
+
return result;
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Single execution - pass inputs directly to callback
|
|
285
|
+
const result = await callback(stage, inputs, stageContext);
|
|
286
|
+
return [result];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Resolve inputs from template strings and previous results.
|
|
291
|
+
* Supports JMESPath-like resolution:
|
|
292
|
+
* - ${previous} -> previousResults
|
|
293
|
+
* - ${previous[0]} -> previousResults[0]
|
|
294
|
+
* - ${context.key} -> context.key
|
|
295
|
+
* - ${args.x} -> context.args.x
|
|
296
|
+
*
|
|
297
|
+
* C5: Validates template inputs to prevent injection.
|
|
298
|
+
*/
|
|
299
|
+
private resolveInputs(
|
|
300
|
+
inputs: unknown,
|
|
301
|
+
previousResults: unknown[],
|
|
302
|
+
context: Record<string, unknown>,
|
|
303
|
+
): unknown {
|
|
304
|
+
// If inputs is an array, resolve each element
|
|
305
|
+
if (Array.isArray(inputs)) {
|
|
306
|
+
// H4: Type safety - limit array size to prevent memory issues
|
|
307
|
+
const maxItems = 10000;
|
|
308
|
+
const limitedInputs = inputs.length > maxItems ? inputs.slice(0, maxItems) : inputs;
|
|
309
|
+
return limitedInputs.map((input) => this.resolveInputs(input, previousResults, context));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// If inputs is a string, check for template patterns
|
|
313
|
+
if (typeof inputs === "string") {
|
|
314
|
+
return this.resolveTemplate(inputs, previousResults, context);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// If inputs is an object, resolve each value
|
|
318
|
+
if (typeof inputs === "object" && inputs !== null) {
|
|
319
|
+
const resolved: Record<string, unknown> = {};
|
|
320
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
321
|
+
// C5: Validate key to prevent prototype pollution
|
|
322
|
+
if (this.isValidObjectKey(key)) {
|
|
323
|
+
resolved[key] = this.resolveInputs(value as string | string[] | Record<string, unknown>, previousResults, context);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return resolved;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Primitive value - return as-is
|
|
330
|
+
return inputs;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* C5: Validate object key to prevent prototype pollution and injection.
|
|
335
|
+
*/
|
|
336
|
+
private isValidObjectKey(key: string): boolean {
|
|
337
|
+
// Reject dangerous keys
|
|
338
|
+
const dangerousKeys = [
|
|
339
|
+
'__proto__',
|
|
340
|
+
'constructor',
|
|
341
|
+
'prototype',
|
|
342
|
+
'__defineGetter__',
|
|
343
|
+
'__defineSetter__',
|
|
344
|
+
'__lookupGetter__',
|
|
345
|
+
'__lookupSetter__',
|
|
346
|
+
];
|
|
347
|
+
if (dangerousKeys.includes(key)) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
// Reject keys with null bytes or control characters
|
|
351
|
+
if (/[\x00-\x1F\x7F]/.test(key)) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
// Reject overly long keys
|
|
355
|
+
if (key.length > 256) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* C5: Validate nested path (e.g., "nested.deep.value") to prevent injection.
|
|
363
|
+
* Each part is validated individually.
|
|
364
|
+
*/
|
|
365
|
+
private isValidNestedPath(path: string): boolean {
|
|
366
|
+
// Reject empty paths
|
|
367
|
+
if (!path || path.length === 0) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
// Reject overly long paths
|
|
371
|
+
if (path.length > 512) {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
// Reject paths with empty segments
|
|
375
|
+
if (path.includes('..')) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
// Validate each path segment
|
|
379
|
+
const parts = path.split('.');
|
|
380
|
+
for (const part of parts) {
|
|
381
|
+
if (!this.isValidObjectKey(part)) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Resolve a single template string.
|
|
390
|
+
* C5: Validates template inputs to prevent injection.
|
|
391
|
+
*/
|
|
392
|
+
private resolveTemplate(
|
|
393
|
+
template: string,
|
|
394
|
+
previousResults: unknown[],
|
|
395
|
+
context: Record<string, unknown>,
|
|
396
|
+
): unknown {
|
|
397
|
+
// C5: Validate template length to prevent DoS
|
|
398
|
+
if (template.length > 10000) {
|
|
399
|
+
return template;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Check for ${previous} pattern
|
|
403
|
+
const previousMatch = template.match(/^\$\{previous\}$/);
|
|
404
|
+
if (previousMatch) {
|
|
405
|
+
return previousResults;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Check for ${previous[N]} pattern with bounds checking
|
|
409
|
+
const previousIndexMatch = template.match(/^\$\{previous\[(\d+)\]\}$/);
|
|
410
|
+
if (previousIndexMatch) {
|
|
411
|
+
const index = parseInt(previousIndexMatch[1], 10);
|
|
412
|
+
// H4: Type safety - validate index bounds
|
|
413
|
+
if (index >= 0 && index < previousResults.length) {
|
|
414
|
+
return previousResults[index];
|
|
415
|
+
}
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Check for ${context.key} pattern with sanitized key extraction
|
|
420
|
+
const contextMatch = template.match(/^\$\{context\.([a-zA-Z_][a-zA-Z0-9_.]*)\}$/);
|
|
421
|
+
if (contextMatch) {
|
|
422
|
+
const key = contextMatch[1];
|
|
423
|
+
// C5: Validate the full path (each part validated in getNestedValue)
|
|
424
|
+
if (this.isValidNestedPath(key)) {
|
|
425
|
+
return this.getNestedValue(context, key);
|
|
426
|
+
}
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Check for ${args.key} pattern with sanitized key extraction
|
|
431
|
+
const argsMatch = template.match(/^\$\{args\.([a-zA-Z_][a-zA-Z0-9_.]*)\}$/);
|
|
432
|
+
if (argsMatch) {
|
|
433
|
+
const key = argsMatch[1];
|
|
434
|
+
// C5: Validate the full path (each part validated in getNestedValue)
|
|
435
|
+
if (this.isValidNestedPath(key)) {
|
|
436
|
+
const args = (context.args as Record<string, unknown>) ?? {};
|
|
437
|
+
return this.getNestedValue(args, key);
|
|
438
|
+
}
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// No pattern matched - return template as-is
|
|
443
|
+
return template;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Get nested value from object using dot notation.
|
|
448
|
+
* H4: Type safety - validates path and prevents prototype pollution.
|
|
449
|
+
*/
|
|
450
|
+
private getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
451
|
+
const parts = path.split(".");
|
|
452
|
+
let current: unknown = obj;
|
|
453
|
+
|
|
454
|
+
for (const part of parts) {
|
|
455
|
+
// H4: Validate each path part
|
|
456
|
+
if (!this.isValidObjectKey(part)) {
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
if (current === null || current === undefined) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
if (typeof current !== "object") {
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
current = (current as Record<string, unknown>)[part];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return current;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Parse a pipeline workflow from a workflow configuration.
|
|
473
|
+
* Converts standard WorkflowConfig to PipelineWorkflow.
|
|
474
|
+
*/
|
|
475
|
+
static fromWorkflowConfig(
|
|
476
|
+
workflow: WorkflowConfig,
|
|
477
|
+
goal: string,
|
|
478
|
+
): PipelineWorkflow {
|
|
479
|
+
const stages: PipelineStage[] = workflow.steps.map((step) => ({
|
|
480
|
+
name: step.id,
|
|
481
|
+
team: step.role, // Using role as team identifier
|
|
482
|
+
inputs: step.task,
|
|
483
|
+
usePreviousResults: step.dependsOn && step.dependsOn.length > 0,
|
|
484
|
+
}));
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
name: workflow.name,
|
|
488
|
+
description: workflow.description,
|
|
489
|
+
goal,
|
|
490
|
+
stages,
|
|
491
|
+
stopOnError: true,
|
|
492
|
+
defaultMaxConcurrency: workflow.maxConcurrency ?? 5,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Create a pipeline workflow from a goal and stage definitions.
|
|
499
|
+
*/
|
|
500
|
+
export function createPipelineWorkflow(
|
|
501
|
+
name: string,
|
|
502
|
+
description: string,
|
|
503
|
+
goal: string,
|
|
504
|
+
stages: PipelineStage[],
|
|
505
|
+
): PipelineWorkflow {
|
|
506
|
+
return {
|
|
507
|
+
name,
|
|
508
|
+
description,
|
|
509
|
+
goal,
|
|
510
|
+
stages,
|
|
511
|
+
stopOnError: true,
|
|
512
|
+
defaultMaxConcurrency: 5,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { crewEventBus } from "../observability/event-bus.ts";
|
|
3
|
+
|
|
4
|
+
export interface AgentProgress {
|
|
5
|
+
toolCalls: number;
|
|
6
|
+
currentTool: string | null;
|
|
7
|
+
toolStartTime: number | null;
|
|
8
|
+
errors: string[];
|
|
9
|
+
turns: number;
|
|
10
|
+
tokens: { input: number; output: number };
|
|
11
|
+
status: "idle" | "running" | "completed" | "error";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ProgressTracker {
|
|
15
|
+
private sessions = new Map<string, {
|
|
16
|
+
unsubscribe: () => void;
|
|
17
|
+
progress: AgentProgress;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
track(
|
|
21
|
+
session: { subscribe: (listener: (event: AgentSessionEvent) => void) => () => void },
|
|
22
|
+
agentId: string,
|
|
23
|
+
runId: string
|
|
24
|
+
): AgentProgress {
|
|
25
|
+
if (this.sessions.has(agentId)) {
|
|
26
|
+
return this.sessions.get(agentId)!.progress;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const progress: AgentProgress = {
|
|
30
|
+
toolCalls: 0,
|
|
31
|
+
currentTool: null,
|
|
32
|
+
toolStartTime: null,
|
|
33
|
+
errors: [],
|
|
34
|
+
turns: 0,
|
|
35
|
+
tokens: { input: 0, output: 0 },
|
|
36
|
+
status: "running",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
40
|
+
this.handleEvent(event, progress, agentId, runId);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.sessions.set(agentId, { unsubscribe, progress });
|
|
44
|
+
return progress;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private handleEvent(
|
|
48
|
+
event: AgentSessionEvent,
|
|
49
|
+
progress: AgentProgress,
|
|
50
|
+
agentId: string,
|
|
51
|
+
runId: string
|
|
52
|
+
): void {
|
|
53
|
+
switch (event.type) {
|
|
54
|
+
case "tool_execution_start":
|
|
55
|
+
progress.toolCalls++;
|
|
56
|
+
progress.currentTool = event.toolName;
|
|
57
|
+
progress.toolStartTime = Date.now();
|
|
58
|
+
crewEventBus.emit({
|
|
59
|
+
type: "agent:progress",
|
|
60
|
+
runId,
|
|
61
|
+
agentId,
|
|
62
|
+
payload: { ...progress },
|
|
63
|
+
timestamp: Date.now(),
|
|
64
|
+
});
|
|
65
|
+
break;
|
|
66
|
+
|
|
67
|
+
case "tool_execution_end":
|
|
68
|
+
progress.currentTool = null;
|
|
69
|
+
progress.toolStartTime = null;
|
|
70
|
+
if (event.isError) {
|
|
71
|
+
progress.errors.push(String(event.result ?? "Unknown error"));
|
|
72
|
+
crewEventBus.emit({
|
|
73
|
+
type: "agent:error",
|
|
74
|
+
runId,
|
|
75
|
+
agentId,
|
|
76
|
+
payload: String(event.result ?? "Unknown error"),
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
crewEventBus.emit({
|
|
81
|
+
type: "agent:progress",
|
|
82
|
+
runId,
|
|
83
|
+
agentId,
|
|
84
|
+
payload: { ...progress },
|
|
85
|
+
timestamp: Date.now(),
|
|
86
|
+
});
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
case "turn_start":
|
|
90
|
+
progress.turns++;
|
|
91
|
+
break;
|
|
92
|
+
|
|
93
|
+
case "agent_end":
|
|
94
|
+
progress.status = "completed";
|
|
95
|
+
crewEventBus.emit({
|
|
96
|
+
type: "agent:complete",
|
|
97
|
+
runId,
|
|
98
|
+
agentId,
|
|
99
|
+
payload: { ...progress },
|
|
100
|
+
timestamp: Date.now(),
|
|
101
|
+
});
|
|
102
|
+
break;
|
|
103
|
+
|
|
104
|
+
case "agent_start":
|
|
105
|
+
progress.status = "running";
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
untrack(agentId: string): void {
|
|
111
|
+
const tracked = this.sessions.get(agentId);
|
|
112
|
+
if (tracked) {
|
|
113
|
+
tracked.unsubscribe();
|
|
114
|
+
this.sessions.delete(agentId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getProgress(agentId: string): AgentProgress | undefined {
|
|
119
|
+
return this.sessions.get(agentId)?.progress;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Export singleton instance
|
|
124
|
+
export const globalProgressTracker = new ProgressTracker();
|