stageflow 0.9.0 → 0.13.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 (167) hide show
  1. package/README.md +6 -1
  2. package/dist/agent/activity.d.ts +9 -0
  3. package/dist/agent/agentBackend.d.ts +32 -0
  4. package/dist/agent/agentBackend.js +38 -0
  5. package/dist/agent/claudeActivity.d.ts +15 -0
  6. package/dist/agent/claudeActivity.js +58 -0
  7. package/dist/agent/claudeAdapter.d.ts +5 -0
  8. package/dist/agent/claudeAdapter.js +331 -0
  9. package/dist/agent/claudeSession.d.ts +18 -0
  10. package/dist/agent/claudeSession.js +54 -0
  11. package/dist/agent/claudeTools.d.ts +89 -0
  12. package/dist/agent/claudeTools.js +135 -0
  13. package/dist/agent/fakeAgent.js +24 -4
  14. package/dist/agent/piAdapter.d.ts +12 -7
  15. package/dist/agent/piAdapter.js +194 -44
  16. package/dist/agent/piIsolatedMcp.d.ts +23 -0
  17. package/dist/agent/piIsolatedMcp.js +223 -0
  18. package/dist/agent/piIsolatedMcpProbe.d.ts +15 -0
  19. package/dist/agent/piIsolatedMcpProbe.js +292 -0
  20. package/dist/agent/port.d.ts +52 -1
  21. package/dist/agent/port.js +1 -1
  22. package/dist/agent/resolveAgentPort.d.ts +26 -0
  23. package/dist/agent/resolveAgentPort.js +39 -0
  24. package/dist/cli/runCommand.js +2 -2
  25. package/dist/cli/runsCommand.d.ts +1 -1
  26. package/dist/cli/runsCommand.js +184 -2
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +11 -5
  29. package/dist/config/browseCatalog.js +1 -1
  30. package/dist/config/createPipeline.d.ts +2 -1
  31. package/dist/config/createPipeline.js +34 -9
  32. package/dist/config/createStage.js +14 -9
  33. package/dist/config/loadPipeline.js +11 -1
  34. package/dist/config/loadStage.d.ts +1 -0
  35. package/dist/config/loadStage.js +207 -2
  36. package/dist/config/loadStageflowManifest.js +14 -1
  37. package/dist/config/mergePipelineIncludes.d.ts +1 -0
  38. package/dist/config/mergePipelineIncludes.js +14 -1
  39. package/dist/config/normalizePipelineStageEntry.d.ts +7 -2
  40. package/dist/config/normalizePipelineStageEntry.js +56 -7
  41. package/dist/config/parseCompletionContract.d.ts +8 -0
  42. package/dist/config/parseCompletionContract.js +218 -0
  43. package/dist/config/parsePreEmitChecks.d.ts +3 -0
  44. package/dist/config/parsePreEmitChecks.js +65 -0
  45. package/dist/config/pipelineNeeds.d.ts +19 -0
  46. package/dist/config/pipelineNeeds.js +151 -0
  47. package/dist/config/pipelineStageKeys.js +17 -1
  48. package/dist/config/resolveForkEmitContext.d.ts +6 -2
  49. package/dist/config/resolveForkEmitContext.js +9 -2
  50. package/dist/config/resolvePipelineDag.d.ts +2 -1
  51. package/dist/config/resolvePipelineDag.js +182 -39
  52. package/dist/config/resolveStageMcpServers.d.ts +35 -0
  53. package/dist/config/resolveStageMcpServers.js +207 -0
  54. package/dist/config/validateCatalog.d.ts +1 -1
  55. package/dist/config/validateCatalog.js +51 -0
  56. package/dist/config/validateCompletionContract.d.ts +4 -0
  57. package/dist/config/validateCompletionContract.js +52 -0
  58. package/dist/envelope/check.d.ts +1 -1
  59. package/dist/envelope/check.js +86 -13
  60. package/dist/envelope/cloneForks.d.ts +1 -1
  61. package/dist/envelope/cloneForks.js +24 -9
  62. package/dist/envelope/feedbackLoop.d.ts +4 -0
  63. package/dist/envelope/feedbackLoop.js +31 -0
  64. package/dist/envelope/payloadSchema.d.ts +7 -1
  65. package/dist/envelope/payloadSchema.js +170 -13
  66. package/dist/envelope/preEmitChecks.d.ts +29 -0
  67. package/dist/envelope/preEmitChecks.js +57 -0
  68. package/dist/hitl/qaTrail.d.ts +25 -0
  69. package/dist/hitl/qaTrail.js +51 -0
  70. package/dist/mcp/catalogTools.js +4 -2
  71. package/dist/mcp/controlTools.js +72 -0
  72. package/dist/mcp/readArtifact.d.ts +2 -0
  73. package/dist/mcp/readArtifact.js +25 -1
  74. package/dist/mcp/waitingGates.d.ts +2 -1
  75. package/dist/mcp/waitingGates.js +17 -1
  76. package/dist/package-meta.d.ts +1 -1
  77. package/dist/package-meta.js +1 -1
  78. package/dist/projection/projectRun.d.ts +4 -1
  79. package/dist/projection/projectRun.js +7 -0
  80. package/dist/prompt/feedbackLoopContext.d.ts +2 -0
  81. package/dist/prompt/feedbackLoopContext.js +17 -0
  82. package/dist/prompt/priorEnvelope.d.ts +2 -2
  83. package/dist/prompt/priorEnvelope.js +4 -1
  84. package/dist/runstore/pipelineDagSnapshot.d.ts +1 -0
  85. package/dist/runstore/pipelineDagSnapshot.js +51 -11
  86. package/dist/runstore/port.d.ts +185 -3
  87. package/dist/runstore/port.js +42 -3
  88. package/dist/runstore/runProjection.d.ts +7 -3
  89. package/dist/runstore/runProjection.js +30 -15
  90. package/dist/runstore/sqlite/SqliteRunStore.d.ts +25 -1
  91. package/dist/runstore/sqlite/SqliteRunStore.js +626 -8
  92. package/dist/runstore/sqlite/schema.d.ts +1 -1
  93. package/dist/runstore/sqlite/schema.js +99 -0
  94. package/dist/runstore/stageInstanceId.d.ts +1 -1
  95. package/dist/runstore/stageInstanceId.js +3 -2
  96. package/dist/runstore/trackProjection.js +59 -27
  97. package/dist/runstore/verificationHistory.d.ts +19 -0
  98. package/dist/runstore/verificationHistory.js +30 -0
  99. package/dist/runtime/cloneSchedule.d.ts +40 -4
  100. package/dist/runtime/cloneSchedule.js +212 -28
  101. package/dist/runtime/completionCheckRunner.d.ts +137 -0
  102. package/dist/runtime/completionCheckRunner.js +365 -0
  103. package/dist/runtime/completionCheckoutBaseline.d.ts +3 -0
  104. package/dist/runtime/completionCheckoutBaseline.js +44 -0
  105. package/dist/runtime/envelopeRouting.d.ts +10 -3
  106. package/dist/runtime/envelopeRouting.js +146 -5
  107. package/dist/runtime/feedbackLoopCoordinator.d.ts +74 -0
  108. package/dist/runtime/feedbackLoopCoordinator.js +446 -0
  109. package/dist/runtime/feedbackLoopDecision.d.ts +44 -0
  110. package/dist/runtime/feedbackLoopDecision.js +207 -0
  111. package/dist/runtime/feedbackLoopRoute.d.ts +5 -0
  112. package/dist/runtime/feedbackLoopRoute.js +13 -0
  113. package/dist/runtime/feedbackLoopSchedule.d.ts +38 -0
  114. package/dist/runtime/feedbackLoopSchedule.js +72 -0
  115. package/dist/runtime/forkGeneration.d.ts +72 -0
  116. package/dist/runtime/forkGeneration.js +295 -0
  117. package/dist/runtime/gitCheckoutCapability.d.ts +7 -0
  118. package/dist/runtime/gitCheckoutCapability.js +97 -0
  119. package/dist/runtime/manualRecoveryState.d.ts +40 -0
  120. package/dist/runtime/manualRecoveryState.js +73 -0
  121. package/dist/runtime/pipelineScheduler.d.ts +13 -1
  122. package/dist/runtime/pipelineScheduler.js +328 -45
  123. package/dist/runtime/replayLifecycle.d.ts +107 -0
  124. package/dist/runtime/replayLifecycle.js +168 -0
  125. package/dist/runtime/runManager.d.ts +54 -1
  126. package/dist/runtime/runManager.js +471 -6
  127. package/dist/runtime/runRetryCoordinator.d.ts +6 -1
  128. package/dist/runtime/runRetryCoordinator.js +43 -9
  129. package/dist/runtime/stageAttemptBootstrap.d.ts +3 -1
  130. package/dist/runtime/stageAttemptBootstrap.js +117 -3
  131. package/dist/runtime/stageProcessLauncher.d.ts +1 -1
  132. package/dist/runtime/stageRecovery.js +1 -1
  133. package/dist/runtime/stageRunner.d.ts +4 -1
  134. package/dist/runtime/stageRunner.js +29 -3
  135. package/dist/runtime/stageWorker.js +69 -2
  136. package/dist/runtime/stageWorkerProtocol.d.ts +1 -1
  137. package/dist/runtime/verifiedStageExecution.d.ts +31 -0
  138. package/dist/runtime/verifiedStageExecution.js +127 -0
  139. package/dist/server/bootstrap.js +1 -0
  140. package/dist/server/http.js +137 -4
  141. package/dist/server/operatorResults.js +2 -0
  142. package/dist/server/projectMcpRoutes.d.ts +6 -0
  143. package/dist/server/projectMcpRoutes.js +38 -0
  144. package/dist/tools/askOperator.d.ts +161 -5
  145. package/dist/tools/askOperator.js +58 -25
  146. package/dist/tools/emitStageEnvelope.d.ts +12 -12
  147. package/dist/tools/emitStageEnvelope.js +112 -24
  148. package/dist/types/completion.d.ts +43 -0
  149. package/dist/types/completion.js +1 -0
  150. package/dist/types/envelope.d.ts +18 -0
  151. package/dist/types/forkChoice.d.ts +9 -4
  152. package/dist/types/forkChoice.js +1 -1
  153. package/dist/types/pipeline.d.ts +41 -2
  154. package/dist/types/preEmitCheck.d.ts +18 -0
  155. package/dist/types/preEmitCheck.js +1 -0
  156. package/dist/types/stage.d.ts +14 -0
  157. package/dist/types/stageflowManifest.d.ts +2 -0
  158. package/dist/ui/assets/index-Yzh42vhf.js +118 -0
  159. package/dist/ui/assets/{index-C3N7MXAC.css → index-uVHiae8s.css} +1 -1
  160. package/dist/ui/index.html +2 -2
  161. package/package.json +5 -1
  162. package/skills/stageflow-author/SKILL.md +20 -2
  163. package/skills/stageflow-author/references/catalog-mapping.md +88 -3
  164. package/skills/stageflow-author/references/catalog-write-conventions.md +1 -1
  165. package/skills/stageflow-author/references/stage-prompt-template.md +63 -4
  166. package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -1
  167. package/dist/ui/assets/index-CSoGDA3A.js +0 -118
@@ -26,14 +26,17 @@
26
26
  * fails closed with `StageSessionReconstructError` (KTD7). Epic proving
27
27
  * vehicle: pipeline `plan-review-proving` (Pi live wait + console answers).
28
28
  *
29
- * `ask_operator` is always registered on sealed stage sessions (allowlist +
30
- * customTools).
29
+ * `ask_operator` is registered on sealed stage sessions unless the stage
30
+ * declares `gate_kinds: []`.
31
31
  */
32
32
  import { existsSync } from "node:fs";
33
33
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
34
34
  import path from "node:path";
35
35
  import { createAgentSession, DefaultResourceLoader, defineTool, ModelRuntime, resolveCliModel, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
36
+ import { StageMcpError } from "../config/resolveStageMcpServers.js";
37
+ import { attachIsolatedMcp, STAGEFLOW_PI_MCP_EXTENSION_NAME, } from "./piIsolatedMcp.js";
36
38
  import { isAdvancingEnvelope } from "../envelope/check.js";
39
+ import { formatFeedbackLoopContext } from "../prompt/feedbackLoopContext.js";
37
40
  import { formatPriorEnvelope } from "../prompt/priorEnvelope.js";
38
41
  import { attemptContext, noAttemptContext, } from "../runtime/stageAttemptContext.js";
39
42
  import { createEmitStageEnvelopeTool, } from "../tools/emitStageEnvelope.js";
@@ -46,18 +49,21 @@ import { createStageActivityObserver, } from "./activityObserver.js";
46
49
  import { DEFAULT_STAGE_TIMEOUT_MS, runtimeStageId, runStageViaOpen } from "./port.js";
47
50
  /**
48
51
  * Stage tool allowlist for sealed Pi sessions.
49
- * Always includes `ask_operator` (R7); `write_stage_artifact` when registered
50
- * (always, for bound and unbound — bound/unbound only gates cwd / env bind).
52
+ * Includes `ask_operator` unless `gateKinds` is an empty list (R6).
53
+ * `write_stage_artifact` when registered (always, for bound and unbound —
54
+ * bound/unbound only gates cwd / env bind).
51
55
  */
52
- export function resolveStageToolNames(emitToolName, artifactToolName, askOperatorToolName = "ask_operator") {
56
+ export function resolveStageToolNames(emitToolName, artifactToolName, askOperatorToolName = "ask_operator", gateKinds) {
53
57
  const tools = [
54
58
  "read",
55
59
  "bash",
56
60
  "write",
57
61
  "edit",
58
62
  emitToolName,
59
- askOperatorToolName,
60
63
  ];
64
+ if (gateKinds === undefined || gateKinds.length > 0) {
65
+ tools.push(askOperatorToolName);
66
+ }
61
67
  if (artifactToolName) {
62
68
  tools.push(artifactToolName);
63
69
  }
@@ -364,6 +370,37 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
364
370
  if (input.stage.payload_schema !== undefined) {
365
371
  emitHint += `\nOn status=success, payload is required and must match this JSON Schema:\n${JSON.stringify(input.stage.payload_schema, null, 2)}`;
366
372
  }
373
+ const checklistChecks = input.completionContract?.checks.filter((check) => check.type === "checklist") ?? [];
374
+ if (checklistChecks.length > 0) {
375
+ emitHint += `\nBefore emitting success, self-review every required checklist item. Include checklist_attestations in the envelope with each check_id and its complete item list exactly as declared:\n${JSON.stringify(checklistChecks, null, 2)}`;
376
+ }
377
+ if (input.repairContext !== undefined) {
378
+ emitHint += `\nThis is recovery attempt ${input.roots.attempt ?? input.repairContext.prior_attempt + 1}. The previous candidate did not pass Stageflow verification. Address the failed checks before emitting success.`;
379
+ if (input.repairContext.operator_guidance) {
380
+ emitHint += `\nThe operator approved this retry with these instructions:\n${input.repairContext.operator_guidance}`;
381
+ }
382
+ if (input.repairContext.failed_checks?.length) {
383
+ emitHint += `\nFailed-check evidence from attempt ${input.repairContext.prior_attempt}:\n${JSON.stringify(input.repairContext.failed_checks, null, 2)}`;
384
+ }
385
+ }
386
+ if (input.cloneEmitContext !== undefined) {
387
+ const allowedActions = input.cloneEmitContext.allowedActions ?? ["skip", "once", "fanout"];
388
+ const successorLines = input.cloneEmitContext.clonableSuccessors.map((successor) => {
389
+ const header = `- ${successor.successorId} (clone_cap: ${successor.cloneCap})`;
390
+ if (successor.cloneInputSchema === undefined) {
391
+ return header;
392
+ }
393
+ return `${header}\n Assignment schema (clone_input_schema):\n${JSON.stringify(successor.cloneInputSchema, null, 2)}`;
394
+ });
395
+ emitHint += `\nClonable successors — emit clone_forks covering each of these ids exactly once:\n${successorLines.join("\n")}`;
396
+ emitHint += `\nEach once or fanout envelope is a full StageEnvelope — it requires status ("success" or "failure"), summary (non-empty string), and artifacts (array of strings). clone_input_schema fields belong in envelope.payload, not at the top level of the clone_forks item.`;
397
+ emitHint += `\nAllowed clone actions: ${allowedActions.join(", ")}`;
398
+ }
399
+ if (input.feedbackLoopEmitContext !== undefined) {
400
+ emitHint += `\nFeedback-loop decision: this stage is a feedback-loop source. On status=success, feedback_loop is required. Emit either {"action":"continue"} to proceed downstream or {"action":"send_back","target":"…"} to request another pass.`;
401
+ emitHint += `\nAllowed send_back target: ${input.feedbackLoopEmitContext.target}.`;
402
+ emitHint += `\nA send_back decision cannot be combined with fork_choice or clone_forks.`;
403
+ }
367
404
  const attempt = input.roots.attempt ?? 1;
368
405
  const attemptArtifactsPath = `stages/${runtimeStageId(input)}/attempts/${attempt}/artifacts/`;
369
406
  const skillBaseDir = input.stage.skill !== undefined && input.skillFilePath !== undefined
@@ -394,7 +431,10 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
394
431
  `Goal: ${input.task.goal}`,
395
432
  input.task.context ? `Context: ${input.task.context}` : "",
396
433
  input.task.constraints ? `Constraints: ${input.task.constraints}` : "",
397
- formatPriorEnvelope(input.priorEnvelope, input.priorEnvelopes),
434
+ formatPriorEnvelope(input.priorEnvelope, input.priorEnvelopes, input.priorEnvelopesByStage),
435
+ input.feedbackLoopContext !== undefined
436
+ ? formatFeedbackLoopContext(input.feedbackLoopContext, input.sessionMode)
437
+ : "",
398
438
  "",
399
439
  artifactGuidance,
400
440
  emitHint,
@@ -409,6 +449,16 @@ export function composeStageUserPrompt(input, emitToolName, emitHintOverride, ar
409
449
  }
410
450
  return body;
411
451
  }
452
+ export function composeFeedbackResumePrompt(input) {
453
+ if (input.feedbackLoopContext === undefined) {
454
+ throw new Error("feedback_resume requires feedbackLoopContext");
455
+ }
456
+ return [
457
+ "Continue this stage after feedback-loop send-back.",
458
+ "Incorporate the operator/source feedback below, then complete the stage (emit an envelope when done).",
459
+ formatFeedbackLoopContext(input.feedbackLoopContext, input.sessionMode ?? "feedback_resume"),
460
+ ].join("\n\n");
461
+ }
412
462
  /**
413
463
  * DefaultResourceLoader with host/global discovery turned off.
414
464
  *
@@ -416,9 +466,9 @@ export function composeStageUserPrompt(input, emitToolName, emitHintOverride, ar
416
466
  * up the consumer project's AGENTS.md, `.agents/skills/`, `.pi/extensions`,
417
467
  * and APPEND_SYSTEM.md. Stages must not inherit that context.
418
468
  *
419
- * `additionalExtensionPaths` is the only way extensions enter a sealed stage
420
- * (used by StageProviderSupport implementations). With `noExtensions: true`,
421
- * discovered global/project packages stay out; only allowlisted paths load.
469
+ * `additionalExtensionPaths` is the Cursor/provider seam. `extensionFactories`
470
+ * is the isolated MCP seam. With `noExtensions: true`, discovered
471
+ * global/project packages stay out; only those allowlists load.
422
472
  * `additionalSkillPaths` is the matching allowlist for one named skill.
423
473
  */
424
474
  export function createSealedResourceLoader(options) {
@@ -430,6 +480,10 @@ export function createSealedResourceLoader(options) {
430
480
  appendSystemPromptOverride: () => [],
431
481
  additionalExtensionPaths: options.additionalExtensionPaths,
432
482
  additionalSkillPaths: options.additionalSkillPaths,
483
+ ...(options.extensionFactories !== undefined
484
+ ? { extensionFactories: options.extensionFactories }
485
+ : {}),
486
+ ...(options.eventBus !== undefined ? { eventBus: options.eventBus } : {}),
433
487
  noContextFiles: true,
434
488
  noExtensions: true,
435
489
  noSkills: true,
@@ -437,6 +491,16 @@ export function createSealedResourceLoader(options) {
437
491
  noThemes: true,
438
492
  });
439
493
  }
494
+ function collectMcpExtensionToolNames(loader) {
495
+ const inlinePath = `<inline:${STAGEFLOW_PI_MCP_EXTENSION_NAME}>`;
496
+ const names = [];
497
+ for (const ext of loader.getExtensions().extensions) {
498
+ if (ext.path !== inlinePath)
499
+ continue;
500
+ names.push(...ext.tools.keys());
501
+ }
502
+ return names;
503
+ }
440
504
  async function shutdownSession(session) {
441
505
  if (!session) {
442
506
  return;
@@ -686,12 +750,20 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
686
750
  const provider = findProviderSupport(input.stage.model);
687
751
  const capture = {};
688
752
  const askWaitChannel = existingAskWaitChannel ?? new AskOperatorWaitChannel();
689
- const emitDef = createEmitStageEnvelopeTool(capture, input.stage.payload_schema, input.forkEmitContext, input.cloneEmitContext);
753
+ const emitDef = createEmitStageEnvelopeTool(capture, input.stage.payload_schema, input.forkEmitContext, input.cloneEmitContext, {
754
+ checks: input.stage.pre_emit_checks,
755
+ readQaTrail: input.readQaTrail,
756
+ }, input.feedbackLoopEmitContext);
690
757
  const emitTool = defineTool(emitDef);
691
- const askDef = createAskOperatorTool({
692
- requestWait: (prompt) => askWaitChannel.requestWait(prompt),
693
- });
694
- const askTool = defineTool(askDef);
758
+ const gateKinds = input.stage.gate_kinds;
759
+ const includeAsk = gateKinds === undefined || gateKinds.length > 0;
760
+ const askDef = includeAsk
761
+ ? createAskOperatorTool({
762
+ requestWait: (prompt) => askWaitChannel.requestWait(prompt),
763
+ ...(gateKinds !== undefined ? { allowedKinds: gateKinds } : {}),
764
+ })
765
+ : undefined;
766
+ const askTool = askDef ? defineTool(askDef) : undefined;
695
767
  const artifactDef = createWriteStageArtifactTool({
696
768
  runWorkspaceDir: roots.runWorkspaceDir,
697
769
  stageId: runtimeStageId(input),
@@ -714,11 +786,28 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
714
786
  additionalExtensionPaths.push(...prepared.extensionPaths);
715
787
  restoreProvider = prepared.restore;
716
788
  }
789
+ let attached;
717
790
  try {
718
- const modelRuntime = await ModelRuntime.create(roots.authPath ? { authPath: roots.authPath } : undefined);
791
+ const modelRuntime = await ModelRuntime.create(roots.authPath
792
+ ? {
793
+ authPath: roots.authPath,
794
+ // ModelRuntime otherwise resolves models.json from getAgentDir(),
795
+ // which bindPiAgentDirEnv seals to an empty per-attempt sandbox
796
+ // dir. Point it at the same durable agent dir as authPath so
797
+ // custom providers (e.g. a self-hosted OpenAI-compatible
798
+ // gateway) defined there stay usable in sealed stage sessions.
799
+ modelsPath: path.join(path.dirname(roots.authPath), "models.json"),
800
+ }
801
+ : undefined);
719
802
  const settingsManager = SettingsManager.inMemory({
720
803
  compaction: { enabled: false },
721
804
  });
805
+ attached = await attachIsolatedMcp(input.resolvedMcpServers);
806
+ const failAfterAttach = (reason) => {
807
+ attached?.cancel?.();
808
+ restoreProvider?.();
809
+ return { ok: false, reason };
810
+ };
722
811
  const loader = createSealedResourceLoader({
723
812
  cwd: roots.cwd,
724
813
  agentDir: roots.agentDir,
@@ -728,57 +817,61 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
728
817
  ...(input.skillFilePath !== undefined
729
818
  ? { additionalSkillPaths: [input.skillFilePath] }
730
819
  : {}),
820
+ ...(attached.extensionFactories !== undefined
821
+ ? { extensionFactories: attached.extensionFactories }
822
+ : {}),
823
+ ...(attached.eventBus !== undefined ? { eventBus: attached.eventBus } : {}),
731
824
  });
732
825
  await loader.reload();
733
826
  const extensionErrors = loader.getExtensions().errors;
734
827
  if (extensionErrors.length > 0) {
735
- restoreProvider?.();
736
- return {
737
- ok: false,
738
- reason: `Failed to load Pi extension(s): ${extensionErrors
739
- .map((e) => `${e.path}: ${e.error}`)
740
- .join("; ")}`,
741
- };
828
+ return failAfterAttach(`Failed to load Pi extension(s): ${extensionErrors
829
+ .map((e) => `${e.path}: ${e.error}`)
830
+ .join("; ")}`);
742
831
  }
743
832
  const skillDiagnostics = loader
744
833
  .getSkills()
745
834
  .diagnostics.filter((d) => d.type === "error" || d.type === "collision");
746
835
  if (skillDiagnostics.length > 0) {
747
- restoreProvider?.();
748
- return {
749
- ok: false,
750
- reason: `Failed to load skill(s): ${skillDiagnostics
751
- .map((d) => (d.path !== undefined ? `${d.path}: ${d.message}` : d.message))
752
- .join("; ")}`,
753
- };
836
+ return failAfterAttach(`Failed to load skill(s): ${skillDiagnostics
837
+ .map((d) => (d.path !== undefined ? `${d.path}: ${d.message}` : d.message))
838
+ .join("; ")}`);
754
839
  }
755
840
  if (input.stage.skill !== undefined &&
756
841
  !loader.getSkills().skills.some((skill) => skill.name === input.stage.skill)) {
757
- restoreProvider?.();
758
- return {
759
- ok: false,
760
- reason: `Skill "${input.stage.skill}" is not installed`,
761
- };
842
+ return failAfterAttach(`Skill "${input.stage.skill}" is not installed`);
843
+ }
844
+ const customTools = askTool
845
+ ? [emitTool, askTool, artifactTool]
846
+ : [emitTool, artifactTool];
847
+ const tools = resolveStageToolNames(emitDef.name, artifactDef.name, askDef?.name ?? "ask_operator", gateKinds);
848
+ if (attached.extensionFactories !== undefined) {
849
+ tools.push(...collectMcpExtensionToolNames(loader));
762
850
  }
763
- const customTools = [emitTool, askTool, artifactTool];
764
851
  return {
765
852
  sessionManager,
766
853
  modelRuntime,
767
854
  settingsManager,
768
855
  loader,
769
- tools: resolveStageToolNames(emitDef.name, artifactDef.name, askDef.name),
856
+ tools,
770
857
  customTools,
771
858
  emitDefName: emitDef.name,
772
- askOperatorDefName: askDef.name,
859
+ ...(askDef ? { askOperatorDefName: askDef.name } : {}),
773
860
  artifactDefName: artifactDef.name,
774
861
  providerEmitHint: provider?.emitToolHint?.(emitDef.name),
775
862
  restoreProvider,
776
863
  capture,
777
864
  askWaitChannel,
865
+ connecting: attached.connecting,
866
+ cancelConnecting: attached.cancel,
778
867
  };
779
868
  }
780
869
  catch (err) {
870
+ attached?.cancel?.();
781
871
  restoreProvider?.();
872
+ if (err instanceof StageMcpError) {
873
+ return { ok: false, reason: err.message };
874
+ }
782
875
  throw err;
783
876
  }
784
877
  }
@@ -818,6 +911,9 @@ export async function reconstructStageSessionForAnswer(input, answer) {
818
911
  });
819
912
  session = created.session;
820
913
  await session.bindExtensions({});
914
+ if (wiring.connecting !== undefined) {
915
+ await wiring.connecting;
916
+ }
821
917
  const resolved = resolveCliModel({
822
918
  cliModel: input.stage.model,
823
919
  modelRuntime: wiring.modelRuntime,
@@ -845,6 +941,7 @@ export async function reconstructStageSessionForAnswer(input, answer) {
845
941
  };
846
942
  }
847
943
  catch (err) {
944
+ wiring.cancelConnecting?.();
848
945
  await shutdownSession(session);
849
946
  wiring.restoreProvider?.();
850
947
  if (err instanceof StageSessionReconstructError)
@@ -866,7 +963,20 @@ async function bindStageSession(input, sessionManager, wiring) {
866
963
  });
867
964
  const session = created.session;
868
965
  ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
869
- await session.bindExtensions({});
966
+ try {
967
+ await session.bindExtensions({});
968
+ if (wiring.connecting !== undefined) {
969
+ await wiring.connecting;
970
+ }
971
+ }
972
+ catch (err) {
973
+ wiring.cancelConnecting?.();
974
+ await shutdownSession(session);
975
+ if (err instanceof StageMcpError) {
976
+ return { ok: false, reason: err.message };
977
+ }
978
+ throw err;
979
+ }
870
980
  const resolved = resolveCliModel({
871
981
  cliModel: input.stage.model,
872
982
  modelRuntime: wiring.modelRuntime,
@@ -901,30 +1011,65 @@ export class PiAgentAdapter {
901
1011
  const timeoutMs = input.timeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
902
1012
  const askWaitChannel = new AskOperatorWaitChannel();
903
1013
  const sessionFile = resolveStageSessionFile(input);
1014
+ const sessionMode = input.sessionMode ?? "fresh";
904
1015
  let sessionManager;
905
1016
  let resumeWaiting = false;
906
- if (existsSync(sessionFile)) {
1017
+ let feedbackResume = false;
1018
+ let forceNewSession = sessionMode === "new_session";
1019
+ if (sessionMode === "feedback_resume") {
1020
+ if (input.feedbackLoopContext === undefined) {
1021
+ throw new StageSessionReconstructError(`feedback_resume requires feedbackLoopContext: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
1022
+ }
1023
+ if (!existsSync(sessionFile)) {
1024
+ throw new StageSessionReconstructError(`stage session file missing for feedback_resume: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
1025
+ }
907
1026
  try {
908
1027
  sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), input.roots.cwd);
909
1028
  }
910
1029
  catch (err) {
911
- throw new StageSessionReconstructError(`stage session file corrupt or unreadable for resume: ${sessionFile}`, {
1030
+ throw new StageSessionReconstructError(`stage session file corrupt or unreadable for feedback_resume: ${sessionFile}`, {
912
1031
  stageId: runtimeStageId(input),
913
1032
  sessionFile,
914
1033
  cause: err,
915
1034
  });
916
1035
  }
917
1036
  ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
918
- if (!findOpenToolCall(sessionManager)) {
919
- throw new StageSessionReconstructError(`stage session has no open tool call for resume: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
1037
+ feedbackResume = true;
1038
+ }
1039
+ else if (sessionMode !== "new_session") {
1040
+ if (sessionMode === "waiting_resume" || existsSync(sessionFile)) {
1041
+ if (existsSync(sessionFile)) {
1042
+ try {
1043
+ sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), input.roots.cwd);
1044
+ }
1045
+ catch (err) {
1046
+ throw new StageSessionReconstructError(`stage session file corrupt or unreadable for resume: ${sessionFile}`, {
1047
+ stageId: runtimeStageId(input),
1048
+ sessionFile,
1049
+ cause: err,
1050
+ });
1051
+ }
1052
+ ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
1053
+ if (!findOpenToolCall(sessionManager)) {
1054
+ throw new StageSessionReconstructError(`stage session has no open tool call for resume: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
1055
+ }
1056
+ resumeWaiting = true;
1057
+ }
1058
+ else {
1059
+ throw new StageSessionReconstructError(`stage session file missing for resume: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
1060
+ }
920
1061
  }
921
- resumeWaiting = true;
922
1062
  }
923
1063
  let session;
924
1064
  let wiring;
925
1065
  let unsubscribeProgress;
926
1066
  let prepareError;
927
1067
  const preparePromise = (async () => {
1068
+ if (forceNewSession) {
1069
+ await mkdir(path.dirname(sessionFile), { recursive: true });
1070
+ await writeFile(sessionFile, "");
1071
+ sessionManager = undefined;
1072
+ }
928
1073
  if (!sessionManager) {
929
1074
  sessionManager = await createStageSessionManager(input.roots, runtimeStageId(input), sessionFile);
930
1075
  }
@@ -987,6 +1132,11 @@ export class PiAgentAdapter {
987
1132
  },
988
1133
  run: async () => {
989
1134
  return runWithTimeout(async () => {
1135
+ if (feedbackResume) {
1136
+ syncAgentMessagesFromSession(session, sessionManager);
1137
+ await session.prompt(composeFeedbackResumePrompt(input));
1138
+ return;
1139
+ }
990
1140
  const userPrompt = composeStageUserPrompt(input, wiring.emitDefName, wiring.providerEmitHint, wiring.artifactDefName);
991
1141
  await session.prompt(userPrompt);
992
1142
  });
@@ -0,0 +1,23 @@
1
+ import { type EventBus, type InlineExtension } from "@earendil-works/pi-coding-agent";
2
+ import { type ResolvedMcpServers } from "../config/resolveStageMcpServers.js";
3
+ export declare const STAGEFLOW_PI_MCP_EXTENSION_NAME = "stageflow-mcp";
4
+ type IsolatedMcpServerEntry = Record<string, unknown> & {
5
+ lifecycle: "lazy" | "eager";
6
+ directTools: true;
7
+ };
8
+ export type IsolatedMcpAttachOptions = {
9
+ timeoutMs?: number;
10
+ lifecycle?: IsolatedMcpServerEntry["lifecycle"];
11
+ };
12
+ export type IsolatedMcpAttach = {
13
+ extensionFactories: InlineExtension[] | undefined;
14
+ eventBus: EventBus | undefined;
15
+ connecting: Promise<void> | undefined;
16
+ cancel?: () => void;
17
+ };
18
+ export declare function emitIsolatedMcpStatus(eventBus: EventBus, servers: ReadonlyArray<{
19
+ name: string;
20
+ status: string;
21
+ }>): void;
22
+ export declare function attachIsolatedMcp(snapshot?: ResolvedMcpServers, options?: IsolatedMcpAttachOptions): Promise<IsolatedMcpAttach>;
23
+ export {};
@@ -0,0 +1,223 @@
1
+ import { createEventBus, } from "@earendil-works/pi-coding-agent";
2
+ import { StageMcpError, } from "../config/resolveStageMcpServers.js";
3
+ export const STAGEFLOW_PI_MCP_EXTENSION_NAME = "stageflow-mcp";
4
+ const MCP_STATUS_EVENT = "pi-mcp-adapter/status/v1";
5
+ const DEFAULT_ISOLATED_MCP_CONNECT_TIMEOUT_MS = 5_000;
6
+ const STATUS_POLL_MS = 50;
7
+ const PI_MCP_ADAPTER_SPEC = "pi-mcp-adapter";
8
+ const ISOLATED_MCP_SETTINGS = {
9
+ directTools: true,
10
+ elicitation: false,
11
+ hostConfigDiscovery: "off",
12
+ };
13
+ const SUCCESS_STATUSES = new Set([
14
+ "connected",
15
+ "cached",
16
+ ]);
17
+ const FAILURE_STATUSES = new Set([
18
+ "failed",
19
+ "needs-auth",
20
+ "disabled",
21
+ ]);
22
+ const EMPTY_ATTACH = {
23
+ extensionFactories: undefined,
24
+ eventBus: undefined,
25
+ connecting: undefined,
26
+ };
27
+ let cachedCreateMcpAdapter;
28
+ function toIsolatedMcpConfig(snapshot, lifecycle = "lazy") {
29
+ const mcpServers = {};
30
+ for (const [name, entry] of Object.entries(snapshot)) {
31
+ const isolated = {
32
+ ...entry,
33
+ lifecycle,
34
+ directTools: true,
35
+ };
36
+ if (typeof entry.cwd === "string") {
37
+ isolated.cwd = entry.cwd;
38
+ }
39
+ mcpServers[name] = isolated;
40
+ }
41
+ return {
42
+ mcpServers,
43
+ settings: { ...ISOLATED_MCP_SETTINGS },
44
+ };
45
+ }
46
+ async function loadCreateMcpAdapter() {
47
+ if (cachedCreateMcpAdapter !== undefined) {
48
+ return cachedCreateMcpAdapter;
49
+ }
50
+ try {
51
+ const mod = (await import(PI_MCP_ADAPTER_SPEC));
52
+ if (typeof mod.createMcpAdapter === "function") {
53
+ cachedCreateMcpAdapter = mod.createMcpAdapter;
54
+ return cachedCreateMcpAdapter;
55
+ }
56
+ }
57
+ catch {
58
+ // Node does not type-strip .ts under node_modules.
59
+ }
60
+ const { createJiti } = await import("jiti/static");
61
+ const jiti = createJiti(import.meta.url);
62
+ const mod = (await jiti.import(PI_MCP_ADAPTER_SPEC));
63
+ cachedCreateMcpAdapter = mod.createMcpAdapter;
64
+ return cachedCreateMcpAdapter;
65
+ }
66
+ function isRuntimeStatus(value) {
67
+ return (value === "connected" ||
68
+ value === "cached" ||
69
+ value === "failed" ||
70
+ value === "needs-auth" ||
71
+ value === "not-connected" ||
72
+ value === "disabled");
73
+ }
74
+ function asIsolatedMcpStatusSnapshot(data) {
75
+ if (data === null || typeof data !== "object")
76
+ return undefined;
77
+ const servers = data.servers;
78
+ if (!Array.isArray(servers))
79
+ return undefined;
80
+ const parsed = [];
81
+ for (const entry of servers) {
82
+ if (entry === null || typeof entry !== "object")
83
+ continue;
84
+ const name = entry.name;
85
+ const status = entry.status;
86
+ if (typeof name !== "string" || typeof status !== "string")
87
+ continue;
88
+ if (!isRuntimeStatus(status))
89
+ continue;
90
+ parsed.push({ name, status });
91
+ }
92
+ return { servers: parsed };
93
+ }
94
+ function mcpStatusSourceFromEvents(events) {
95
+ return {
96
+ subscribe(listener) {
97
+ const handler = (data) => {
98
+ const snapshot = asIsolatedMcpStatusSnapshot(data);
99
+ if (snapshot)
100
+ listener(snapshot);
101
+ };
102
+ const unsubscribe = events.on(MCP_STATUS_EVENT, handler);
103
+ if (typeof unsubscribe === "function")
104
+ return unsubscribe;
105
+ return () => {
106
+ events.off?.(MCP_STATUS_EVENT, handler);
107
+ };
108
+ },
109
+ };
110
+ }
111
+ function connectFailed(name, status) {
112
+ return new StageMcpError(`MCP server "${name}" failed to connect (status: ${status})`, "connect_failed");
113
+ }
114
+ function timeoutError(pendingNames) {
115
+ if (pendingNames.length === 1) {
116
+ return connectFailed(pendingNames[0], "timed out");
117
+ }
118
+ const listed = pendingNames.map((name) => `"${name}"`).join(", ");
119
+ return new StageMcpError(`MCP servers ${listed} failed to connect (status: timed out)`, "connect_failed");
120
+ }
121
+ function evaluateConnectStatus(serverNames, snapshot) {
122
+ if (snapshot === undefined) {
123
+ return { kind: "wait", pending: [...serverNames] };
124
+ }
125
+ const byName = new Map(snapshot.servers.map((entry) => [entry.name, entry]));
126
+ const pending = [];
127
+ for (const name of serverNames) {
128
+ const entry = byName.get(name);
129
+ if (entry === undefined || entry.status === "not-connected") {
130
+ pending.push(name);
131
+ continue;
132
+ }
133
+ if (FAILURE_STATUSES.has(entry.status)) {
134
+ return { kind: "fail", name, status: entry.status };
135
+ }
136
+ if (!SUCCESS_STATUSES.has(entry.status)) {
137
+ pending.push(name);
138
+ }
139
+ }
140
+ if (pending.length > 0) {
141
+ return { kind: "wait", pending };
142
+ }
143
+ return { kind: "ok" };
144
+ }
145
+ function waitForIsolatedMcpConnect(snapshot, source, options) {
146
+ const serverNames = Object.keys(snapshot);
147
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_ISOLATED_MCP_CONNECT_TIMEOUT_MS;
148
+ let lastSnapshot = source.read?.();
149
+ let finish = () => { };
150
+ const connecting = new Promise((resolve, reject) => {
151
+ let settled = false;
152
+ let unsubscribe;
153
+ let poll;
154
+ let timer;
155
+ finish = (error) => {
156
+ if (settled)
157
+ return;
158
+ settled = true;
159
+ unsubscribe?.();
160
+ if (poll !== undefined)
161
+ clearInterval(poll);
162
+ if (timer !== undefined)
163
+ clearTimeout(timer);
164
+ if (error)
165
+ reject(error);
166
+ else
167
+ resolve();
168
+ };
169
+ const consider = (next) => {
170
+ if (next !== undefined)
171
+ lastSnapshot = next;
172
+ const result = evaluateConnectStatus(serverNames, lastSnapshot);
173
+ if (result.kind === "ok") {
174
+ finish();
175
+ return;
176
+ }
177
+ if (result.kind === "fail") {
178
+ finish(connectFailed(result.name, result.status));
179
+ }
180
+ };
181
+ consider(lastSnapshot);
182
+ if (settled)
183
+ return;
184
+ unsubscribe = source.subscribe?.((next) => consider(next));
185
+ consider(source.read?.());
186
+ if (settled)
187
+ return;
188
+ if (source.read) {
189
+ poll = setInterval(() => consider(source.read?.()), STATUS_POLL_MS);
190
+ }
191
+ timer = setTimeout(() => {
192
+ const pending = evaluateConnectStatus(serverNames, lastSnapshot);
193
+ finish(timeoutError(pending.kind === "wait" ? pending.pending : serverNames));
194
+ }, timeoutMs);
195
+ });
196
+ return {
197
+ connecting,
198
+ cancel() {
199
+ void connecting.then(() => undefined, () => undefined);
200
+ finish(new StageMcpError("MCP connect wait cancelled", "connect_failed"));
201
+ },
202
+ };
203
+ }
204
+ export function emitIsolatedMcpStatus(eventBus, servers) {
205
+ eventBus.emit(MCP_STATUS_EVENT, { servers });
206
+ }
207
+ export async function attachIsolatedMcp(snapshot, options) {
208
+ if (snapshot === undefined || Object.keys(snapshot).length === 0) {
209
+ return EMPTY_ATTACH;
210
+ }
211
+ const createMcpAdapter = await loadCreateMcpAdapter();
212
+ const extensionFactories = [
213
+ {
214
+ name: STAGEFLOW_PI_MCP_EXTENSION_NAME,
215
+ factory: createMcpAdapter({
216
+ config: toIsolatedMcpConfig(snapshot, options?.lifecycle ?? "lazy"),
217
+ }),
218
+ },
219
+ ];
220
+ const eventBus = createEventBus();
221
+ const { connecting, cancel } = waitForIsolatedMcpConnect(snapshot, mcpStatusSourceFromEvents(eventBus), options);
222
+ return { extensionFactories, eventBus, connecting, cancel };
223
+ }
@@ -0,0 +1,15 @@
1
+ export declare const PROJECT_MCP_PROBE_TIMEOUT_MS = 30000;
2
+ export type ProjectMcpProbeStatus = "connected" | "needs_auth" | "connect_failed" | "unresolved_var" | "invalid_config" | "missing_catalog" | "cancelled";
3
+ export type ProjectMcpProbeResult = {
4
+ name: string;
5
+ status: ProjectMcpProbeStatus;
6
+ error?: string;
7
+ };
8
+ export type ProbeProjectMcpServerOptions = {
9
+ projectRoot: string;
10
+ name: string;
11
+ env?: NodeJS.ProcessEnv;
12
+ signal?: AbortSignal;
13
+ timeoutMs?: number;
14
+ };
15
+ export declare function probeProjectMcpServer(options: ProbeProjectMcpServerOptions): Promise<ProjectMcpProbeResult>;