stageflow 0.12.0 → 0.15.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 (115) hide show
  1. package/README.md +5 -1
  2. package/dist/agent/claudeAdapter.js +31 -1
  3. package/dist/agent/piAdapter.d.ts +6 -4
  4. package/dist/agent/piAdapter.js +71 -26
  5. package/dist/agent/piIsolatedMcp.d.ts +23 -0
  6. package/dist/agent/piIsolatedMcp.js +223 -0
  7. package/dist/agent/piIsolatedMcpProbe.d.ts +15 -0
  8. package/dist/agent/piIsolatedMcpProbe.js +292 -0
  9. package/dist/agent/port.d.ts +4 -2
  10. package/dist/agent/port.js +1 -1
  11. package/dist/cli/initTemplates.d.ts +1 -1
  12. package/dist/cli/initTemplates.js +1 -1
  13. package/dist/cli/migrateYamlCommand.d.ts +18 -0
  14. package/dist/cli/migrateYamlCommand.js +180 -0
  15. package/dist/cli/runOutput.js +26 -18
  16. package/dist/cli/validateCommand.d.ts +1 -0
  17. package/dist/cli/validateCommand.js +2 -0
  18. package/dist/cli.js +15 -1
  19. package/dist/config/browseCatalog.js +1 -1
  20. package/dist/config/createPipeline.d.ts +1 -1
  21. package/dist/config/createPipeline.js +23 -11
  22. package/dist/config/createStage.d.ts +1 -1
  23. package/dist/config/createStage.js +10 -5
  24. package/dist/config/legacyYaml.d.ts +28 -0
  25. package/dist/config/legacyYaml.js +98 -0
  26. package/dist/config/loadOutcome.d.ts +2 -1
  27. package/dist/config/loadOutcome.js +4 -1
  28. package/dist/config/loadPipeline.d.ts +2 -0
  29. package/dist/config/loadPipeline.js +151 -8
  30. package/dist/config/loadStage.d.ts +9 -1
  31. package/dist/config/loadStage.js +207 -29
  32. package/dist/config/loadStageflowManifest.js +10 -0
  33. package/dist/config/loadTask.js +14 -0
  34. package/dist/config/materializeStageModels.d.ts +7 -0
  35. package/dist/config/materializeStageModels.js +35 -0
  36. package/dist/config/mergePipelineIncludes.d.ts +5 -1
  37. package/dist/config/mergePipelineIncludes.js +82 -4
  38. package/dist/config/migrateYaml.d.ts +19 -0
  39. package/dist/config/migrateYaml.js +627 -0
  40. package/dist/config/modelField.d.ts +13 -0
  41. package/dist/config/modelField.js +16 -0
  42. package/dist/config/normalizePipelineStageEntry.js +48 -3
  43. package/dist/config/parseCompletionContract.d.ts +5 -2
  44. package/dist/config/parseCompletionContract.js +16 -3
  45. package/dist/config/parsePreEmitChecks.js +4 -0
  46. package/dist/config/pipelineNeeds.js +3 -3
  47. package/dist/config/pipelineStageKeys.d.ts +11 -0
  48. package/dist/config/pipelineStageKeys.js +15 -0
  49. package/dist/config/printTargetYaml.d.ts +28 -0
  50. package/dist/config/printTargetYaml.js +243 -0
  51. package/dist/config/resolveModel.d.ts +20 -0
  52. package/dist/config/resolveModel.js +35 -0
  53. package/dist/config/resolveStageMcpServers.d.ts +35 -0
  54. package/dist/config/resolveStageMcpServers.js +207 -0
  55. package/dist/config/validateCatalog.d.ts +5 -1
  56. package/dist/config/validateCatalog.js +87 -6
  57. package/dist/config/validateCompletionContract.js +5 -0
  58. package/dist/config/yamlDialect.d.ts +29 -0
  59. package/dist/config/yamlDialect.js +327 -0
  60. package/dist/envelope/payloadSchema.d.ts +37 -5
  61. package/dist/envelope/payloadSchema.js +272 -22
  62. package/dist/mcp/catalogTools.js +1 -0
  63. package/dist/mcp/readArtifact.d.ts +2 -0
  64. package/dist/mcp/readArtifact.js +25 -1
  65. package/dist/package-meta.d.ts +1 -1
  66. package/dist/package-meta.js +1 -1
  67. package/dist/runstore/pipelineDagSnapshot.js +2 -0
  68. package/dist/runstore/port.d.ts +1 -0
  69. package/dist/runtime/completionCheckRunner.d.ts +3 -2
  70. package/dist/runtime/completionCheckRunner.js +3 -2
  71. package/dist/runtime/envelopeRouting.d.ts +2 -2
  72. package/dist/runtime/pipelineRunner.d.ts +3 -0
  73. package/dist/runtime/pipelineRunner.js +23 -3
  74. package/dist/runtime/runManager.js +3 -2
  75. package/dist/runtime/stageAttemptBootstrap.d.ts +2 -2
  76. package/dist/runtime/stageAttemptBootstrap.js +38 -2
  77. package/dist/runtime/stageRunner.d.ts +2 -2
  78. package/dist/runtime/taskInput.d.ts +7 -0
  79. package/dist/runtime/taskInput.js +60 -0
  80. package/dist/runtime/verifiedStageExecution.d.ts +2 -0
  81. package/dist/runtime/verifiedStageExecution.js +13 -2
  82. package/dist/server/http.js +23 -4
  83. package/dist/server/projectMcpRoutes.d.ts +6 -0
  84. package/dist/server/projectMcpRoutes.js +38 -0
  85. package/dist/types/completion.d.ts +6 -0
  86. package/dist/types/pipeline.d.ts +23 -2
  87. package/dist/types/preEmitCheck.d.ts +4 -6
  88. package/dist/types/stage.d.ts +24 -4
  89. package/dist/types/stageflowManifest.d.ts +2 -0
  90. package/dist/types/task.d.ts +1 -0
  91. package/dist/ui/assets/index-GtyiWiZg.js +118 -0
  92. package/dist/ui/index.html +1 -1
  93. package/package.json +4 -1
  94. package/skills/stageflow-author/SKILL.md +20 -2
  95. package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +9 -1
  96. package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +6 -2
  97. package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +9 -1
  98. package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +16 -1
  99. package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +12 -2
  100. package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +16 -1
  101. package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +11 -2
  102. package/skills/stageflow-author/assets/examples/linear-review/review.yaml +19 -1
  103. package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +9 -1
  104. package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +6 -2
  105. package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +16 -1
  106. package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +9 -1
  107. package/skills/stageflow-author/references/catalog-mapping.md +80 -17
  108. package/skills/stageflow-author/references/catalog-write-conventions.md +2 -2
  109. package/skills/stageflow-author/references/stage-prompt-template.md +63 -4
  110. package/skills/stageflow-session-capture/SKILL.md +1 -1
  111. package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +2 -1
  112. package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +13 -2
  113. package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +18 -2
  114. package/skills/stageflow-session-capture/references/catalog-authoring.md +6 -3
  115. package/dist/ui/assets/index-CXGz4nL7.js +0 -118
package/README.md CHANGED
@@ -193,6 +193,8 @@ Host MCP via `sf ui` or `sf mcp` at `http://127.0.0.1:3847/mcp` (URL printed on
193
193
 
194
194
  HITL-aware tools include `wait_run`, `answer_gate`, and `list_waiting`. Full tool list: [docs/mcp.md](docs/mcp.md).
195
195
 
196
+ Stages use project `.mcp.json` plus stage `mcp` names. See [YAML catalog — Stage MCP](docs/yaml-catalog.md#stage-mcp).
197
+
196
198
  ## Stageflow vs Conductor
197
199
 
198
200
  Both projects address multi-step agent workflows. They differ in orchestration model and runtime.
@@ -218,6 +220,9 @@ If you want deterministic YAML routing across many agents, look at [Conductor](h
218
220
  | [plan-review](examples/plan-review/) | Multi-stage with operator gate — SDLC-style **example** |
219
221
  | [conditional-fork](examples/conditional-fork/) | Exclusive fork routing with operator branch choice |
220
222
  | [clonable-fanout](examples/clonable-fanout/) | Clone one successor N times, then join |
223
+ | [stage-mcp](examples/stage-mcp/) | Stage MCP via project `.mcp.json` and a local echo fixture |
224
+ | [playwright-mcp](examples/playwright-mcp/) | One stage: open a page and save a PNG screenshot (Playwright MCP) |
225
+ | [context7-mcp](examples/context7-mcp/) | Three stages: resolve a library, fetch docs, write a brief (Context7 MCP) |
221
226
  | [github-release](examples/github-release/) | Dogfood: draft + publish GitHub Release |
222
227
  | [ci-validate](examples/ci-validate/) | Strict validate in CI |
223
228
 
@@ -241,7 +246,6 @@ Full docs: **[tejasghutukade.github.io/stageflow](https://tejasghutukade.github.
241
246
  | [docs/providers.md](docs/providers.md) | Pi providers, `sf providers` |
242
247
  | [docs/operator-console.md](docs/operator-console.md) | Console IA and settings |
243
248
  | [docs/skills-suite.md](docs/skills-suite.md) | Harness skills — router + jobs for Cursor, Claude Code, Codex, Pi, OpenCode |
244
- | [docs/compare-conductor.md](docs/compare-conductor.md) | Positioning deep dive |
245
249
 
246
250
  ## Develop from source
247
251
 
@@ -46,6 +46,7 @@ import { createClaudeActivityMapper } from "./claudeActivity.js";
46
46
  import { composeStageUserPrompt } from "./piAdapter.js";
47
47
  import { claudeSessionMarkerPath, clearClaudeSessionMarker, readClaudeSessionMarker, writeClaudeSessionMarker, } from "./claudeSession.js";
48
48
  import { DEFAULT_STAGE_TIMEOUT_MS, runStageViaOpen, runtimeStageId, } from "./port.js";
49
+ import { StageMcpError } from "../config/resolveStageMcpServers.js";
49
50
  /** Built-in tool allowlist, mirroring Pi's sealed-session base set (read/bash/write/edit). */
50
51
  const CLAUDE_BUILTIN_TOOLS = ["Read", "Write", "Edit", "Bash"];
51
52
  function resolveClaudeModel(stageModel) {
@@ -87,6 +88,20 @@ function resultFromCapture(capture) {
87
88
  }
88
89
  return { ok: true, envelope: capture.envelope };
89
90
  }
91
+ function failedPassedMcpConnect(resolved, listed) {
92
+ const names = Object.keys(resolved ?? {});
93
+ if (names.length === 0)
94
+ return undefined;
95
+ const byName = new Map(listed.map((entry) => [entry.name, entry]));
96
+ for (const name of names) {
97
+ const entry = byName.get(name);
98
+ if (entry?.status === "connected")
99
+ continue;
100
+ const statusOrError = entry?.error ?? entry?.status ?? "missing";
101
+ return new StageMcpError(`MCP server "${name}" failed to connect (status: ${statusOrError})`, "connect_failed");
102
+ }
103
+ return undefined;
104
+ }
90
105
  async function* singleUserMessage(text) {
91
106
  yield {
92
107
  type: "user",
@@ -131,6 +146,10 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
131
146
  }
132
147
  : {}),
133
148
  });
149
+ const passedServers = Object.fromEntries(Object.entries(input.resolvedMcpServers ?? {}).map(([name, config]) => [
150
+ name,
151
+ { ...config, alwaysLoad: true },
152
+ ]));
134
153
  const stream = query({
135
154
  prompt: singleUserMessage(promptText),
136
155
  options: {
@@ -140,10 +159,11 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
140
159
  systemPrompt: { type: "custom", prompt: input.stage.system_prompt },
141
160
  settingSources: [],
142
161
  tools: CLAUDE_BUILTIN_TOOLS,
143
- mcpServers: { [STAGEFLOW_MCP_SERVER_NAME]: mcpServer },
162
+ mcpServers: { [STAGEFLOW_MCP_SERVER_NAME]: mcpServer, ...passedServers },
144
163
  permissionMode: "bypassPermissions",
145
164
  allowDangerouslySkipPermissions: true,
146
165
  persistSession: true,
166
+ strictMcpConfig: true,
147
167
  ...(resumeSessionId !== undefined ? { resume: resumeSessionId } : {}),
148
168
  },
149
169
  });
@@ -154,6 +174,16 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
154
174
  if (message.type === "system" && message.subtype === "init") {
155
175
  sessionId = message.session_id;
156
176
  await writeClaudeSessionMarker(markerPath, { sessionId: message.session_id });
177
+ const connectError = failedPassedMcpConnect(input.resolvedMcpServers, message.mcp_servers);
178
+ if (connectError) {
179
+ try {
180
+ await stream.interrupt();
181
+ }
182
+ catch {
183
+ // best-effort backstop; a natural stop may have already ended the turn
184
+ }
185
+ return { kind: "completed", result: { ok: false, reason: connectError.message } };
186
+ }
157
187
  }
158
188
  if (message.type === "user" &&
159
189
  (emitCapture.envelope !== undefined || askCapture.prompt !== undefined)) {
@@ -1,4 +1,4 @@
1
- import { type AgentSession, DefaultResourceLoader, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
1
+ import { type AgentSession, type EventBus, type InlineExtension, DefaultResourceLoader, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
2
2
  import type { StageRoots } from "../runtime/stageRoots.js";
3
3
  import { type AskOperatorPrompt, type AskOperatorWaitBridge } from "../tools/askOperator.js";
4
4
  import "./cursorProvider.js";
@@ -72,9 +72,9 @@ export declare function composeFeedbackResumePrompt(input: StageRunInput): strin
72
72
  * up the consumer project's AGENTS.md, `.agents/skills/`, `.pi/extensions`,
73
73
  * and APPEND_SYSTEM.md. Stages must not inherit that context.
74
74
  *
75
- * `additionalExtensionPaths` is the only way extensions enter a sealed stage
76
- * (used by StageProviderSupport implementations). With `noExtensions: true`,
77
- * discovered global/project packages stay out; only allowlisted paths load.
75
+ * `additionalExtensionPaths` is the Cursor/provider seam. `extensionFactories`
76
+ * is the isolated MCP seam. With `noExtensions: true`, discovered
77
+ * global/project packages stay out; only those allowlists load.
78
78
  * `additionalSkillPaths` is the matching allowlist for one named skill.
79
79
  */
80
80
  export declare function createSealedResourceLoader(options: {
@@ -84,6 +84,8 @@ export declare function createSealedResourceLoader(options: {
84
84
  systemPrompt: string;
85
85
  additionalExtensionPaths?: string[];
86
86
  additionalSkillPaths?: string[];
87
+ extensionFactories?: InlineExtension[];
88
+ eventBus?: EventBus;
87
89
  }): DefaultResourceLoader;
88
90
  /** Stable Pi session JSONL path under the run workspace stage dir. */
89
91
  export declare function stageSessionFilePath(roots: Pick<StageRoots, "runWorkspaceDir" | "attempt">, stageId: string): string;
@@ -33,6 +33,8 @@ 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";
37
39
  import { formatFeedbackLoopContext } from "../prompt/feedbackLoopContext.js";
38
40
  import { formatPriorEnvelope } from "../prompt/priorEnvelope.js";
@@ -388,10 +390,10 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
388
390
  if (successor.cloneInputSchema === undefined) {
389
391
  return header;
390
392
  }
391
- return `${header}\n Assignment schema (clone_input_schema):\n${JSON.stringify(successor.cloneInputSchema, null, 2)}`;
393
+ return `${header}\n Assignment schema (io.input.schema):\n${JSON.stringify(successor.cloneInputSchema, null, 2)}`;
392
394
  });
393
395
  emitHint += `\nClonable successors — emit clone_forks covering each of these ids exactly once:\n${successorLines.join("\n")}`;
394
- 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.`;
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). io.input.schema fields belong in envelope.payload, not at the top level of the clone_forks item.`;
395
397
  emitHint += `\nAllowed clone actions: ${allowedActions.join(", ")}`;
396
398
  }
397
399
  if (input.feedbackLoopEmitContext !== undefined) {
@@ -429,6 +431,9 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
429
431
  `Goal: ${input.task.goal}`,
430
432
  input.task.context ? `Context: ${input.task.context}` : "",
431
433
  input.task.constraints ? `Constraints: ${input.task.constraints}` : "",
434
+ input.task.input !== undefined
435
+ ? `Input: ${JSON.stringify(input.task.input, null, 2)}`
436
+ : "",
432
437
  formatPriorEnvelope(input.priorEnvelope, input.priorEnvelopes, input.priorEnvelopesByStage),
433
438
  input.feedbackLoopContext !== undefined
434
439
  ? formatFeedbackLoopContext(input.feedbackLoopContext, input.sessionMode)
@@ -464,9 +469,9 @@ export function composeFeedbackResumePrompt(input) {
464
469
  * up the consumer project's AGENTS.md, `.agents/skills/`, `.pi/extensions`,
465
470
  * and APPEND_SYSTEM.md. Stages must not inherit that context.
466
471
  *
467
- * `additionalExtensionPaths` is the only way extensions enter a sealed stage
468
- * (used by StageProviderSupport implementations). With `noExtensions: true`,
469
- * discovered global/project packages stay out; only allowlisted paths load.
472
+ * `additionalExtensionPaths` is the Cursor/provider seam. `extensionFactories`
473
+ * is the isolated MCP seam. With `noExtensions: true`, discovered
474
+ * global/project packages stay out; only those allowlists load.
470
475
  * `additionalSkillPaths` is the matching allowlist for one named skill.
471
476
  */
472
477
  export function createSealedResourceLoader(options) {
@@ -478,6 +483,10 @@ export function createSealedResourceLoader(options) {
478
483
  appendSystemPromptOverride: () => [],
479
484
  additionalExtensionPaths: options.additionalExtensionPaths,
480
485
  additionalSkillPaths: options.additionalSkillPaths,
486
+ ...(options.extensionFactories !== undefined
487
+ ? { extensionFactories: options.extensionFactories }
488
+ : {}),
489
+ ...(options.eventBus !== undefined ? { eventBus: options.eventBus } : {}),
481
490
  noContextFiles: true,
482
491
  noExtensions: true,
483
492
  noSkills: true,
@@ -485,6 +494,16 @@ export function createSealedResourceLoader(options) {
485
494
  noThemes: true,
486
495
  });
487
496
  }
497
+ function collectMcpExtensionToolNames(loader) {
498
+ const inlinePath = `<inline:${STAGEFLOW_PI_MCP_EXTENSION_NAME}>`;
499
+ const names = [];
500
+ for (const ext of loader.getExtensions().extensions) {
501
+ if (ext.path !== inlinePath)
502
+ continue;
503
+ names.push(...ext.tools.keys());
504
+ }
505
+ return names;
506
+ }
488
507
  async function shutdownSession(session) {
489
508
  if (!session) {
490
509
  return;
@@ -770,6 +789,7 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
770
789
  additionalExtensionPaths.push(...prepared.extensionPaths);
771
790
  restoreProvider = prepared.restore;
772
791
  }
792
+ let attached;
773
793
  try {
774
794
  const modelRuntime = await ModelRuntime.create(roots.authPath
775
795
  ? {
@@ -785,6 +805,12 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
785
805
  const settingsManager = SettingsManager.inMemory({
786
806
  compaction: { enabled: false },
787
807
  });
808
+ attached = await attachIsolatedMcp(input.resolvedMcpServers);
809
+ const failAfterAttach = (reason) => {
810
+ attached?.cancel?.();
811
+ restoreProvider?.();
812
+ return { ok: false, reason };
813
+ };
788
814
  const loader = createSealedResourceLoader({
789
815
  cwd: roots.cwd,
790
816
  agentDir: roots.agentDir,
@@ -794,47 +820,43 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
794
820
  ...(input.skillFilePath !== undefined
795
821
  ? { additionalSkillPaths: [input.skillFilePath] }
796
822
  : {}),
823
+ ...(attached.extensionFactories !== undefined
824
+ ? { extensionFactories: attached.extensionFactories }
825
+ : {}),
826
+ ...(attached.eventBus !== undefined ? { eventBus: attached.eventBus } : {}),
797
827
  });
798
828
  await loader.reload();
799
829
  const extensionErrors = loader.getExtensions().errors;
800
830
  if (extensionErrors.length > 0) {
801
- restoreProvider?.();
802
- return {
803
- ok: false,
804
- reason: `Failed to load Pi extension(s): ${extensionErrors
805
- .map((e) => `${e.path}: ${e.error}`)
806
- .join("; ")}`,
807
- };
831
+ return failAfterAttach(`Failed to load Pi extension(s): ${extensionErrors
832
+ .map((e) => `${e.path}: ${e.error}`)
833
+ .join("; ")}`);
808
834
  }
809
835
  const skillDiagnostics = loader
810
836
  .getSkills()
811
837
  .diagnostics.filter((d) => d.type === "error" || d.type === "collision");
812
838
  if (skillDiagnostics.length > 0) {
813
- restoreProvider?.();
814
- return {
815
- ok: false,
816
- reason: `Failed to load skill(s): ${skillDiagnostics
817
- .map((d) => (d.path !== undefined ? `${d.path}: ${d.message}` : d.message))
818
- .join("; ")}`,
819
- };
839
+ return failAfterAttach(`Failed to load skill(s): ${skillDiagnostics
840
+ .map((d) => (d.path !== undefined ? `${d.path}: ${d.message}` : d.message))
841
+ .join("; ")}`);
820
842
  }
821
843
  if (input.stage.skill !== undefined &&
822
844
  !loader.getSkills().skills.some((skill) => skill.name === input.stage.skill)) {
823
- restoreProvider?.();
824
- return {
825
- ok: false,
826
- reason: `Skill "${input.stage.skill}" is not installed`,
827
- };
845
+ return failAfterAttach(`Skill "${input.stage.skill}" is not installed`);
828
846
  }
829
847
  const customTools = askTool
830
848
  ? [emitTool, askTool, artifactTool]
831
849
  : [emitTool, artifactTool];
850
+ const tools = resolveStageToolNames(emitDef.name, artifactDef.name, askDef?.name ?? "ask_operator", gateKinds);
851
+ if (attached.extensionFactories !== undefined) {
852
+ tools.push(...collectMcpExtensionToolNames(loader));
853
+ }
832
854
  return {
833
855
  sessionManager,
834
856
  modelRuntime,
835
857
  settingsManager,
836
858
  loader,
837
- tools: resolveStageToolNames(emitDef.name, artifactDef.name, askDef?.name ?? "ask_operator", gateKinds),
859
+ tools,
838
860
  customTools,
839
861
  emitDefName: emitDef.name,
840
862
  ...(askDef ? { askOperatorDefName: askDef.name } : {}),
@@ -843,10 +865,16 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
843
865
  restoreProvider,
844
866
  capture,
845
867
  askWaitChannel,
868
+ connecting: attached.connecting,
869
+ cancelConnecting: attached.cancel,
846
870
  };
847
871
  }
848
872
  catch (err) {
873
+ attached?.cancel?.();
849
874
  restoreProvider?.();
875
+ if (err instanceof StageMcpError) {
876
+ return { ok: false, reason: err.message };
877
+ }
850
878
  throw err;
851
879
  }
852
880
  }
@@ -886,6 +914,9 @@ export async function reconstructStageSessionForAnswer(input, answer) {
886
914
  });
887
915
  session = created.session;
888
916
  await session.bindExtensions({});
917
+ if (wiring.connecting !== undefined) {
918
+ await wiring.connecting;
919
+ }
889
920
  const resolved = resolveCliModel({
890
921
  cliModel: input.stage.model,
891
922
  modelRuntime: wiring.modelRuntime,
@@ -913,6 +944,7 @@ export async function reconstructStageSessionForAnswer(input, answer) {
913
944
  };
914
945
  }
915
946
  catch (err) {
947
+ wiring.cancelConnecting?.();
916
948
  await shutdownSession(session);
917
949
  wiring.restoreProvider?.();
918
950
  if (err instanceof StageSessionReconstructError)
@@ -934,7 +966,20 @@ async function bindStageSession(input, sessionManager, wiring) {
934
966
  });
935
967
  const session = created.session;
936
968
  ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
937
- await session.bindExtensions({});
969
+ try {
970
+ await session.bindExtensions({});
971
+ if (wiring.connecting !== undefined) {
972
+ await wiring.connecting;
973
+ }
974
+ }
975
+ catch (err) {
976
+ wiring.cancelConnecting?.();
977
+ await shutdownSession(session);
978
+ if (err instanceof StageMcpError) {
979
+ return { ok: false, reason: err.message };
980
+ }
981
+ throw err;
982
+ }
938
983
  const resolved = resolveCliModel({
939
984
  cliModel: input.stage.model,
940
985
  modelRuntime: wiring.modelRuntime,
@@ -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>;