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.
Files changed (98) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +152 -416
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +17 -14
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +12 -13
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +131 -117
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +3 -0
  16. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  18. package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  20. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  21. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +58 -117
  23. package/.pi/extensions/pi-workflow-engine/src/engine.ts +41 -80
  24. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  25. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  26. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  27. package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  33. package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
  34. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  35. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  36. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
  37. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  38. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  39. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  40. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  41. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  42. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  43. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +29 -31
  44. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  45. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  46. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  55. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  56. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  57. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  58. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +17 -4
  60. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  61. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  62. package/.pi/extensions/pi-workflow-engine/src/types.ts +30 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  65. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  68. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  69. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  70. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  71. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  72. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  73. package/.pi/extensions/pi-workflow-engine/src/usage.ts +41 -34
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +162 -22
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  79. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  80. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  81. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  82. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  83. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  84. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  85. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  86. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  87. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  88. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  89. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  90. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  91. package/README.md +1 -1
  92. package/USAGE.md +79 -20
  93. package/package.json +1 -2
  94. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  95. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  96. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  97. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  98. 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, type PerfSink, type PerfSnapshot } from "./perf.ts";
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 { WorkflowInspector } from "./ui/workflow-inspector.ts";
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
- const progressSource = { snapshot: () => progress.snapshot() };
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
- if (workflowOutcome.ok && finalizationOutcome.ok) {
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: workflowOutcome.value,
222
+ result: outcome.value,
215
223
  });
216
- } else if (!workflowOutcome.ok) {
217
- const error = finalizationOutcome.ok
218
- ? workflowOutcome.error
219
- : combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
220
- persistTerminalWorkflowError(error);
221
- } else if (!finalizationOutcome.ok) {
222
- persistTerminalWorkflowError(finalizationOutcome.error);
223
- }
224
- await durableRun.flush().catch(() => undefined);
225
-
226
- function persistTerminalWorkflowError(error: unknown): void {
227
- const pauseError = backgroundPauseError(error, ctx.signal, resolvedOptions.signal);
228
- if (pauseError) {
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 (workflowOutcome.ok) {
267
- if (finalizationOutcome.ok) return workflowOutcome.value;
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<readonly FinalizerFailure[]> {
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
- export interface IdentityCanonicalizationOptions {
6
- readonly maxBytes?: number;
7
- readonly maxDepth?: number;
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 > maxBytes) throw new IdentityCanonicalizationError(`identity exceeded ${maxBytes} 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 > maxNodes) throw new IdentityCanonicalizationError(`identity exceeded ${maxNodes} values`);
43
- if (depth > maxDepth) throw new IdentityCanonicalizationError(`identity exceeded ${maxDepth} levels`);
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;");