pi-workflow-engine 0.13.0 → 1.1.0
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/.pi/extensions/pi-workflow-engine/index.ts +152 -416
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +17 -14
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +12 -13
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +131 -117
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +3 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +58 -117
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +41 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +29 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +17 -4
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +30 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +41 -34
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +162 -22
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/README.md +1 -1
- package/USAGE.md +79 -20
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- package/.pi/settings.json +0 -3
|
@@ -7,16 +7,16 @@ import { resolveWorkflowModelProfiles, type ResolvedWorkflowModelProfiles } from
|
|
|
7
7
|
import { abortReason, isWorkflowPauseError, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
|
|
8
8
|
import { createBudget } from "./budget.ts";
|
|
9
9
|
import { runAgent, type AgentExecutionOptions, type RunContext } from "./agent-runner.ts";
|
|
10
|
+
import { runClassifier } from "./classify.ts";
|
|
10
11
|
import { ProgressTracker } from "./progress.ts";
|
|
11
|
-
import { createPerfRecorder,
|
|
12
|
+
import { createPerfRecorder, formatPerfSummary, type PerfSink } from "./perf.ts";
|
|
12
13
|
import { createWorkflowUsageRecorder, type WorkflowUsageSink } from "./usage.ts";
|
|
13
14
|
import {
|
|
14
15
|
resolveWorkflowRunOptions,
|
|
15
16
|
type ResolvedWorkflowRunOptions,
|
|
16
17
|
} from "./options.ts";
|
|
17
18
|
import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
|
|
18
|
-
import {
|
|
19
|
-
import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./ui/workflow-viewer-layout.ts";
|
|
19
|
+
import { showWorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
20
20
|
import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
|
|
21
21
|
import { WorktreeRegistry } from "./worktree.ts";
|
|
22
22
|
import { runFinalizers } from "./finalizers.ts";
|
|
@@ -97,8 +97,10 @@ export async function runResolvedWorkflow(
|
|
|
97
97
|
): Promise<unknown> {
|
|
98
98
|
const runId = resolvedOptions.runId ?? createWorkflowRunId();
|
|
99
99
|
let durableProgress: DurableWorkflowRun | undefined;
|
|
100
|
-
const progress = new ProgressTracker(ctx, mod.meta.name, runId, (snapshot) => durableProgress?.updateProgress(snapshot)
|
|
101
|
-
|
|
100
|
+
const progress = new ProgressTracker(ctx, mod.meta.name, runId, (snapshot) => durableProgress?.updateProgress(snapshot), {
|
|
101
|
+
plannedPhases: mod.meta.phases?.map((phase) => phase.title),
|
|
102
|
+
display: resolvedOptions.background ? "background" : "live",
|
|
103
|
+
});
|
|
102
104
|
const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
|
|
103
105
|
const usage = createWorkflowUsageRecorder((snapshot) => progress.updateUsage(snapshot));
|
|
104
106
|
const budget = createBudget(resolvedOptions.budget, usage);
|
|
@@ -122,6 +124,7 @@ export async function runResolvedWorkflow(
|
|
|
122
124
|
},
|
|
123
125
|
);
|
|
124
126
|
durableProgress = durableRun;
|
|
127
|
+
const progressSource = { snapshot: () => progress.snapshot(), state: () => durableRun.state };
|
|
125
128
|
const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
|
|
126
129
|
const runAbortController = new AbortController();
|
|
127
130
|
const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
|
|
@@ -129,11 +132,7 @@ export async function runResolvedWorkflow(
|
|
|
129
132
|
const workflowOutcome = await captureOutcome(async () => {
|
|
130
133
|
await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
|
|
131
134
|
if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
|
|
132
|
-
void ctx.ui
|
|
133
|
-
.custom<void>(
|
|
134
|
-
(tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
|
|
135
|
-
WORKFLOW_VIEWER_OVERLAY_OPTIONS,
|
|
136
|
-
)
|
|
135
|
+
void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }))
|
|
137
136
|
.catch((error: unknown) => {
|
|
138
137
|
try {
|
|
139
138
|
progress.log(`inspector failed: ${unknownErrorMessage(error)}`);
|
|
@@ -179,6 +178,8 @@ export async function runResolvedWorkflow(
|
|
|
179
178
|
budget,
|
|
180
179
|
journal,
|
|
181
180
|
worktrees,
|
|
181
|
+
hostTools: resolvedOptions.hostTools,
|
|
182
|
+
classifierRoutes: new Map(),
|
|
182
183
|
};
|
|
183
184
|
|
|
184
185
|
// perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
|
|
@@ -206,69 +207,44 @@ export async function runResolvedWorkflow(
|
|
|
206
207
|
}),
|
|
207
208
|
);
|
|
208
209
|
|
|
209
|
-
|
|
210
|
+
let outcome: Outcome<unknown> = workflowOutcome;
|
|
211
|
+
if (!finalizationOutcome.ok) {
|
|
212
|
+
outcome = workflowOutcome.ok
|
|
213
|
+
? finalizationOutcome
|
|
214
|
+
: { ok: false, error: combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error) };
|
|
215
|
+
}
|
|
216
|
+
const pauseError = outcome.ok ? undefined : backgroundPauseError(outcome.error, ctx.signal, resolvedOptions.signal);
|
|
217
|
+
if (outcome.ok) {
|
|
210
218
|
durableRun.transition({
|
|
211
219
|
state: "completed",
|
|
212
220
|
progress: progress.snapshot(),
|
|
213
221
|
usage: usage.snapshot(),
|
|
214
|
-
result:
|
|
222
|
+
result: outcome.value,
|
|
215
223
|
});
|
|
216
|
-
} else if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (pauseError instanceof WorkflowProviderUsageLimitError) {
|
|
230
|
-
if (resolvedOptions.background === undefined) {
|
|
231
|
-
durableRun.transition({
|
|
232
|
-
state: "failed",
|
|
233
|
-
progress: progress.snapshot(),
|
|
234
|
-
usage: usage.snapshot(),
|
|
235
|
-
error: pauseError,
|
|
236
|
-
});
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
const providerPause = createProviderUsageLimitPauseRecord(
|
|
240
|
-
pauseError,
|
|
241
|
-
resolvedOptions,
|
|
242
|
-
mod.source.kind === "file" && args.length === 0,
|
|
243
|
-
);
|
|
244
|
-
durableRun.transition({
|
|
245
|
-
state: "paused",
|
|
246
|
-
progress: progress.snapshot(),
|
|
247
|
-
...providerPause,
|
|
248
|
-
});
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
durableRun.transition({
|
|
252
|
-
state: "paused",
|
|
253
|
-
progress: progress.snapshot(),
|
|
254
|
-
message: unknownErrorMessage(pauseError),
|
|
255
|
-
});
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
224
|
+
} else if (pauseError instanceof WorkflowProviderUsageLimitError) {
|
|
225
|
+
durableRun.transition({
|
|
226
|
+
state: "paused",
|
|
227
|
+
progress: progress.snapshot(),
|
|
228
|
+
...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions, mod.source.kind === "file" && args.length === 0),
|
|
229
|
+
});
|
|
230
|
+
} else if (pauseError) {
|
|
231
|
+
durableRun.transition({
|
|
232
|
+
state: "paused",
|
|
233
|
+
progress: progress.snapshot(),
|
|
234
|
+
message: unknownErrorMessage(pauseError),
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
258
237
|
durableRun.transition({
|
|
259
238
|
state: ctx.signal?.aborted || resolvedOptions.signal?.aborted ? "stopped" : "failed",
|
|
260
239
|
progress: progress.snapshot(),
|
|
261
240
|
usage: usage.snapshot(),
|
|
262
|
-
error,
|
|
241
|
+
error: outcome.error,
|
|
263
242
|
});
|
|
264
243
|
}
|
|
244
|
+
await durableRun.flush().catch(() => undefined);
|
|
265
245
|
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
throw finalizationOutcome.error;
|
|
269
|
-
}
|
|
270
|
-
if (finalizationOutcome.ok) throw workflowOutcome.error;
|
|
271
|
-
throw combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
|
|
246
|
+
if (outcome.ok) return outcome.value;
|
|
247
|
+
throw outcome.error;
|
|
272
248
|
}
|
|
273
249
|
|
|
274
250
|
function backgroundPauseError(error: unknown, ...signals: Array<AbortSignal | undefined>): unknown {
|
|
@@ -315,7 +291,7 @@ async function finalizeWorkflowRun(input: WorkflowFinalizationInput): Promise<vo
|
|
|
315
291
|
if (!input.options.perf) return;
|
|
316
292
|
const snapshot = input.perf.snapshot();
|
|
317
293
|
await input.options.onPerfSnapshot?.(snapshot);
|
|
318
|
-
input.progress.log(formatPerfSummary(snapshot));
|
|
294
|
+
input.progress.log(formatPerfSummary(snapshot.aggregates));
|
|
319
295
|
},
|
|
320
296
|
},
|
|
321
297
|
...input.unlinkSignals.map((run, index) => ({
|
|
@@ -343,13 +319,7 @@ async function finalizeWorkflowRun(input: WorkflowFinalizationInput): Promise<vo
|
|
|
343
319
|
},
|
|
344
320
|
],
|
|
345
321
|
{
|
|
346
|
-
onBestEffortFailure: (failure) => {
|
|
347
|
-
try {
|
|
348
|
-
input.progress.log(`${failure.name} failed: ${unknownErrorMessage(failure.error)}`);
|
|
349
|
-
} catch {
|
|
350
|
-
// Observer failures must never replace the workflow outcome.
|
|
351
|
-
}
|
|
352
|
-
},
|
|
322
|
+
onBestEffortFailure: (failure) => input.progress.log(`${failure.name} failed: ${unknownErrorMessage(failure.error)}`),
|
|
353
323
|
},
|
|
354
324
|
);
|
|
355
325
|
}
|
|
@@ -434,6 +404,7 @@ export async function runWorkflowWithContext(
|
|
|
434
404
|
|
|
435
405
|
const api: WorkflowApi = {
|
|
436
406
|
agent,
|
|
407
|
+
classify: (context, classifyOpts) => runClassifier(rc, context, { ...classifyOpts, phase: scope.agentOptions({ phase: classifyOpts?.phase }).phase }),
|
|
437
408
|
workflow,
|
|
438
409
|
parallel: bindParallel({
|
|
439
410
|
signal: rc.signal,
|
|
@@ -491,24 +462,14 @@ function createWorkflowScope(progress: WorkflowProgress, prefix: string, namespa
|
|
|
491
462
|
function namespaceProgressEvent(namespace: string, event: WorkflowProgressEvent): WorkflowProgressEvent {
|
|
492
463
|
switch (event.type) {
|
|
493
464
|
case "counter":
|
|
494
|
-
return { ...event, key: `${namespace}.${event.key}` };
|
|
495
465
|
case "counter_delta":
|
|
466
|
+
case "summary":
|
|
496
467
|
return { ...event, key: `${namespace}.${event.key}` };
|
|
497
468
|
case "lane_item":
|
|
498
469
|
return { ...event, lane: `${namespace} ▸ ${event.lane}` };
|
|
499
|
-
case "summary":
|
|
500
|
-
return { ...event, key: `${namespace}.${event.key}` };
|
|
501
470
|
}
|
|
502
471
|
}
|
|
503
472
|
|
|
504
|
-
function formatPerfSummary(snapshot: PerfSnapshot): string {
|
|
505
|
-
const parts = snapshot.aggregates
|
|
506
|
-
.filter((aggregate) => aggregate.count > 0)
|
|
507
|
-
.slice(0, 5)
|
|
508
|
-
.map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
|
|
509
|
-
return parts.length > 0 ? `perf: ${parts.join(", ")}` : "perf: no samples";
|
|
510
|
-
}
|
|
511
|
-
|
|
512
473
|
async function notifyLifecycleObserver(
|
|
513
474
|
progress: ProgressTracker,
|
|
514
475
|
name: string,
|
|
@@ -10,7 +10,6 @@ export interface Finalizer {
|
|
|
10
10
|
|
|
11
11
|
export interface FinalizerFailure {
|
|
12
12
|
readonly name: string;
|
|
13
|
-
readonly criticality: FinalizerCriticality;
|
|
14
13
|
readonly error: unknown;
|
|
15
14
|
}
|
|
16
15
|
|
|
@@ -35,24 +34,18 @@ export class RequiredFinalizerError extends AggregateError {
|
|
|
35
34
|
export async function runFinalizers(
|
|
36
35
|
finalizers: readonly Finalizer[],
|
|
37
36
|
options: RunFinalizersOptions = {},
|
|
38
|
-
): Promise<
|
|
37
|
+
): Promise<void> {
|
|
39
38
|
const requiredFailures: FinalizerFailure[] = [];
|
|
40
|
-
const bestEffortFailures: FinalizerFailure[] = [];
|
|
41
39
|
|
|
42
40
|
for (const finalizer of finalizers) {
|
|
43
41
|
try {
|
|
44
42
|
await finalizer.run();
|
|
45
43
|
} catch (error) {
|
|
46
|
-
const failure: FinalizerFailure = {
|
|
47
|
-
name: finalizer.name,
|
|
48
|
-
criticality: finalizer.criticality,
|
|
49
|
-
error,
|
|
50
|
-
};
|
|
44
|
+
const failure: FinalizerFailure = { name: finalizer.name, error };
|
|
51
45
|
if (finalizer.criticality === "required") {
|
|
52
46
|
requiredFailures.push(failure);
|
|
53
47
|
continue;
|
|
54
48
|
}
|
|
55
|
-
bestEffortFailures.push(failure);
|
|
56
49
|
try {
|
|
57
50
|
options.onBestEffortFailure?.(failure);
|
|
58
51
|
} catch {
|
|
@@ -62,5 +55,4 @@ export async function runFinalizers(
|
|
|
62
55
|
}
|
|
63
56
|
|
|
64
57
|
if (requiredFailures.length > 0) throw new RequiredFinalizerError(requiredFailures);
|
|
65
|
-
return bestEffortFailures;
|
|
66
58
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { runBoundedProcess, type BoundedProcessResult } from "./process-runner.ts";
|
|
3
|
+
import { isPathWithin } from "./tree-fingerprint.ts";
|
|
4
|
+
|
|
5
|
+
export interface GitCommandOptions {
|
|
6
|
+
/** The GitHub CLI shares the runner so pull-request commands get the same bounds. Defaults to `git`. */
|
|
7
|
+
readonly file?: "git" | "gh";
|
|
8
|
+
readonly cwd: string;
|
|
9
|
+
readonly args: readonly string[];
|
|
10
|
+
/** Names the command in its abort, timeout, output-limit, and exit messages. */
|
|
11
|
+
readonly label: string;
|
|
12
|
+
readonly signal?: AbortSignal;
|
|
13
|
+
readonly timeoutMs: number;
|
|
14
|
+
readonly maxBufferBytes?: number;
|
|
15
|
+
readonly stdin?: string;
|
|
16
|
+
/** Overrides merged over `process.env`. */
|
|
17
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runGit(options: GitCommandOptions): Promise<BoundedProcessResult> {
|
|
21
|
+
const { label, timeoutMs, maxBufferBytes } = options;
|
|
22
|
+
const env = { ...process.env, ...options.env };
|
|
23
|
+
// Drop env-level diff overrides so git emits plain patches. Setting them to an empty string
|
|
24
|
+
// instead makes a plain `git diff` try to run an empty external diff command.
|
|
25
|
+
delete env.GIT_EXTERNAL_DIFF;
|
|
26
|
+
delete env.GIT_DIFF_OPTS;
|
|
27
|
+
return await runBoundedProcess({
|
|
28
|
+
file: options.file ?? "git",
|
|
29
|
+
args: options.args,
|
|
30
|
+
cwd: options.cwd,
|
|
31
|
+
env,
|
|
32
|
+
stdin: options.stdin,
|
|
33
|
+
signal: options.signal,
|
|
34
|
+
timeoutMs,
|
|
35
|
+
maxBufferBytes,
|
|
36
|
+
abortError: `${label} aborted`,
|
|
37
|
+
timeoutError: `${label} timed out after ${timeoutMs}ms`,
|
|
38
|
+
maxBufferError: maxBufferBytes === undefined ? undefined : `${label} exceeded ${maxBufferBytes} bytes`,
|
|
39
|
+
exitError: (stderr, code, signal) => stderr.trim() || `${label} exited with code ${code ?? `signal ${signal ?? "unknown"}`}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A full SHA-1 or SHA-256 Git object ID. */
|
|
44
|
+
export function isGitObjectId(value: string): boolean {
|
|
45
|
+
return /^[0-9a-f]{40,64}$/i.test(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolve `git rev-parse --show-toplevel` output to a root that contains `cwd`. */
|
|
49
|
+
export function parseGitTopLevel(output: string, cwd: string): string | undefined {
|
|
50
|
+
const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
51
|
+
const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
|
|
52
|
+
if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
|
|
53
|
+
const root = resolve(cwd, value);
|
|
54
|
+
return isPathWithin(root, cwd) ? root : undefined;
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** A non-null, non-array object whose fields can be inspected by name. */
|
|
2
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
3
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isFiniteNumber(value: unknown): value is number {
|
|
7
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
8
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AgentToolCallOutcome } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { defineTool, type ExtensionToolContext, type ToolDefinition, type ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
/** pi names MCP tools `mcp__<server>__<tool>`; its MCP extension also registers these resource tools. */
|
|
5
|
+
const MCP_TOOL_PREFIX = "mcp__";
|
|
6
|
+
const MCP_RESOURCE_TOOLS = new Set(["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
|
|
7
|
+
|
|
8
|
+
/** Mirrors pi's own `isMcpToolName`, which the package does not export. */
|
|
9
|
+
export function isMcpToolName(name: string): boolean {
|
|
10
|
+
return name.startsWith(MCP_TOOL_PREFIX) || MCP_RESOURCE_TOOLS.has(name);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** An agent named MCP tools it cannot reach. Raised before prompting so it never runs without them. */
|
|
14
|
+
export class WorkflowHostToolUnavailableError extends Error {
|
|
15
|
+
readonly code = "WORKFLOW_HOST_TOOL_UNAVAILABLE";
|
|
16
|
+
readonly tools: readonly string[];
|
|
17
|
+
|
|
18
|
+
constructor(tools: readonly string[], bridged: boolean) {
|
|
19
|
+
super(
|
|
20
|
+
bridged
|
|
21
|
+
? `Host MCP tools not available: ${tools.join(", ")}`
|
|
22
|
+
: `MCP tools reach subagents only in runs started by the workflow tool; unavailable: ${tools.join(", ")}`,
|
|
23
|
+
);
|
|
24
|
+
this.name = "WorkflowHostToolUnavailableError";
|
|
25
|
+
this.tools = [...tools];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Host-only tools a subagent can reach through the host session. Subagents are SDK sessions, which
|
|
31
|
+
* load none of pi's built-in extensions, so they connect no MCP servers of their own; proxies call the
|
|
32
|
+
* host's tools over its existing connections and through its `tool_call` permission handlers. They act
|
|
33
|
+
* in the host's context, so a local MCP tool sees the main checkout even for an isolated worktree agent.
|
|
34
|
+
*/
|
|
35
|
+
export interface HostToolBridge {
|
|
36
|
+
/** Callable host MCP tools, read as each agent starts so servers that connected mid-run are included. */
|
|
37
|
+
tools(): readonly ToolInfo[];
|
|
38
|
+
execute(name: string, args: unknown, signal: AbortSignal | undefined): Promise<AgentToolCallOutcome>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `executeTool` exists only while the host's tool call runs, so only a synchronous `workflow` tool run
|
|
43
|
+
* gets a bridge; `/workflow` commands and background runs that outlive the call do not.
|
|
44
|
+
*/
|
|
45
|
+
export function createHostToolBridge(
|
|
46
|
+
registeredTools: () => readonly ToolInfo[],
|
|
47
|
+
ctx: Pick<ExtensionToolContext, "tools" | "executeTool">,
|
|
48
|
+
): HostToolBridge {
|
|
49
|
+
return {
|
|
50
|
+
tools() {
|
|
51
|
+
const callable = new Set(ctx.tools.map((tool) => tool.name));
|
|
52
|
+
return registeredTools().filter((tool) => isMcpToolName(tool.name) && callable.has(tool.name));
|
|
53
|
+
},
|
|
54
|
+
execute: (name, args, signal) => ctx.executeTool(name, args, { signal }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A subagent tool that runs the host tool. It reports no usage: pi already adds nested calls' usage to
|
|
60
|
+
* the host's `workflow` tool result.
|
|
61
|
+
*/
|
|
62
|
+
export function hostToolProxy(tool: ToolInfo, bridge: HostToolBridge): ToolDefinition {
|
|
63
|
+
return defineTool({
|
|
64
|
+
name: tool.name,
|
|
65
|
+
label: tool.name,
|
|
66
|
+
description: tool.description,
|
|
67
|
+
parameters: tool.parameters,
|
|
68
|
+
promptGuidelines: tool.promptGuidelines,
|
|
69
|
+
namespace: tool.namespace,
|
|
70
|
+
annotations: tool.annotations,
|
|
71
|
+
async execute(_toolCallId, params, signal) {
|
|
72
|
+
const outcome = await bridge.execute(tool.name, params, signal);
|
|
73
|
+
return { content: outcome.result.content, details: undefined, ...(outcome.isError ? { isError: true } : {}) };
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -2,15 +2,9 @@ export type IdentityCanonicalization =
|
|
|
2
2
|
| { readonly kind: "verified"; readonly value: string }
|
|
3
3
|
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
readonly maxNodes?: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const DEFAULT_MAX_BYTES = 1 << 20;
|
|
12
|
-
const DEFAULT_MAX_DEPTH = 64;
|
|
13
|
-
const DEFAULT_MAX_NODES = 16_384;
|
|
5
|
+
const MAX_BYTES = 1 << 20;
|
|
6
|
+
const MAX_DEPTH = 64;
|
|
7
|
+
const MAX_NODES = 16_384;
|
|
14
8
|
|
|
15
9
|
/**
|
|
16
10
|
* Produce a deterministic, bounded identity for JSON-like data.
|
|
@@ -19,13 +13,7 @@ const DEFAULT_MAX_NODES = 16_384;
|
|
|
19
13
|
* accessors, proxies, or values JSON cannot represent. Those inputs disable
|
|
20
14
|
* replay instead of throwing or being silently collapsed to the same key.
|
|
21
15
|
*/
|
|
22
|
-
export function canonicalizeIdentity(
|
|
23
|
-
value: unknown,
|
|
24
|
-
options: IdentityCanonicalizationOptions = {},
|
|
25
|
-
): IdentityCanonicalization {
|
|
26
|
-
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
27
|
-
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
28
|
-
const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
|
|
16
|
+
export function canonicalizeIdentity(value: unknown): IdentityCanonicalization {
|
|
29
17
|
const active = new WeakSet<object>();
|
|
30
18
|
const chunks: string[] = [];
|
|
31
19
|
let bytes = 0;
|
|
@@ -33,14 +21,14 @@ export function canonicalizeIdentity(
|
|
|
33
21
|
|
|
34
22
|
const append = (chunk: string): void => {
|
|
35
23
|
bytes += Buffer.byteLength(chunk);
|
|
36
|
-
if (bytes >
|
|
24
|
+
if (bytes > MAX_BYTES) throw new IdentityCanonicalizationError(`identity exceeded ${MAX_BYTES} bytes`);
|
|
37
25
|
chunks.push(chunk);
|
|
38
26
|
};
|
|
39
27
|
|
|
40
28
|
const visit = (current: unknown, depth: number): void => {
|
|
41
29
|
nodes += 1;
|
|
42
|
-
if (nodes >
|
|
43
|
-
if (depth >
|
|
30
|
+
if (nodes > MAX_NODES) throw new IdentityCanonicalizationError(`identity exceeded ${MAX_NODES} values`);
|
|
31
|
+
if (depth > MAX_DEPTH) throw new IdentityCanonicalizationError(`identity exceeded ${MAX_DEPTH} levels`);
|
|
44
32
|
|
|
45
33
|
if (current === null) {
|
|
46
34
|
append("n;");
|