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.
- package/README.md +5 -1
- package/dist/agent/claudeAdapter.js +31 -1
- package/dist/agent/piAdapter.d.ts +6 -4
- package/dist/agent/piAdapter.js +71 -26
- package/dist/agent/piIsolatedMcp.d.ts +23 -0
- package/dist/agent/piIsolatedMcp.js +223 -0
- package/dist/agent/piIsolatedMcpProbe.d.ts +15 -0
- package/dist/agent/piIsolatedMcpProbe.js +292 -0
- package/dist/agent/port.d.ts +4 -2
- package/dist/agent/port.js +1 -1
- package/dist/cli/initTemplates.d.ts +1 -1
- package/dist/cli/initTemplates.js +1 -1
- package/dist/cli/migrateYamlCommand.d.ts +18 -0
- package/dist/cli/migrateYamlCommand.js +180 -0
- package/dist/cli/runOutput.js +26 -18
- package/dist/cli/validateCommand.d.ts +1 -0
- package/dist/cli/validateCommand.js +2 -0
- package/dist/cli.js +15 -1
- package/dist/config/browseCatalog.js +1 -1
- package/dist/config/createPipeline.d.ts +1 -1
- package/dist/config/createPipeline.js +23 -11
- package/dist/config/createStage.d.ts +1 -1
- package/dist/config/createStage.js +10 -5
- package/dist/config/legacyYaml.d.ts +28 -0
- package/dist/config/legacyYaml.js +98 -0
- package/dist/config/loadOutcome.d.ts +2 -1
- package/dist/config/loadOutcome.js +4 -1
- package/dist/config/loadPipeline.d.ts +2 -0
- package/dist/config/loadPipeline.js +151 -8
- package/dist/config/loadStage.d.ts +9 -1
- package/dist/config/loadStage.js +207 -29
- package/dist/config/loadStageflowManifest.js +10 -0
- package/dist/config/loadTask.js +14 -0
- package/dist/config/materializeStageModels.d.ts +7 -0
- package/dist/config/materializeStageModels.js +35 -0
- package/dist/config/mergePipelineIncludes.d.ts +5 -1
- package/dist/config/mergePipelineIncludes.js +82 -4
- package/dist/config/migrateYaml.d.ts +19 -0
- package/dist/config/migrateYaml.js +627 -0
- package/dist/config/modelField.d.ts +13 -0
- package/dist/config/modelField.js +16 -0
- package/dist/config/normalizePipelineStageEntry.js +48 -3
- package/dist/config/parseCompletionContract.d.ts +5 -2
- package/dist/config/parseCompletionContract.js +16 -3
- package/dist/config/parsePreEmitChecks.js +4 -0
- package/dist/config/pipelineNeeds.js +3 -3
- package/dist/config/pipelineStageKeys.d.ts +11 -0
- package/dist/config/pipelineStageKeys.js +15 -0
- package/dist/config/printTargetYaml.d.ts +28 -0
- package/dist/config/printTargetYaml.js +243 -0
- package/dist/config/resolveModel.d.ts +20 -0
- package/dist/config/resolveModel.js +35 -0
- package/dist/config/resolveStageMcpServers.d.ts +35 -0
- package/dist/config/resolveStageMcpServers.js +207 -0
- package/dist/config/validateCatalog.d.ts +5 -1
- package/dist/config/validateCatalog.js +87 -6
- package/dist/config/validateCompletionContract.js +5 -0
- package/dist/config/yamlDialect.d.ts +29 -0
- package/dist/config/yamlDialect.js +327 -0
- package/dist/envelope/payloadSchema.d.ts +37 -5
- package/dist/envelope/payloadSchema.js +272 -22
- package/dist/mcp/catalogTools.js +1 -0
- package/dist/mcp/readArtifact.d.ts +2 -0
- package/dist/mcp/readArtifact.js +25 -1
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/runstore/pipelineDagSnapshot.js +2 -0
- package/dist/runstore/port.d.ts +1 -0
- package/dist/runtime/completionCheckRunner.d.ts +3 -2
- package/dist/runtime/completionCheckRunner.js +3 -2
- package/dist/runtime/envelopeRouting.d.ts +2 -2
- package/dist/runtime/pipelineRunner.d.ts +3 -0
- package/dist/runtime/pipelineRunner.js +23 -3
- package/dist/runtime/runManager.js +3 -2
- package/dist/runtime/stageAttemptBootstrap.d.ts +2 -2
- package/dist/runtime/stageAttemptBootstrap.js +38 -2
- package/dist/runtime/stageRunner.d.ts +2 -2
- package/dist/runtime/taskInput.d.ts +7 -0
- package/dist/runtime/taskInput.js +60 -0
- package/dist/runtime/verifiedStageExecution.d.ts +2 -0
- package/dist/runtime/verifiedStageExecution.js +13 -2
- package/dist/server/http.js +23 -4
- package/dist/server/projectMcpRoutes.d.ts +6 -0
- package/dist/server/projectMcpRoutes.js +38 -0
- package/dist/types/completion.d.ts +6 -0
- package/dist/types/pipeline.d.ts +23 -2
- package/dist/types/preEmitCheck.d.ts +4 -6
- package/dist/types/stage.d.ts +24 -4
- package/dist/types/stageflowManifest.d.ts +2 -0
- package/dist/types/task.d.ts +1 -0
- package/dist/ui/assets/index-GtyiWiZg.js +118 -0
- package/dist/ui/index.html +1 -1
- package/package.json +4 -1
- package/skills/stageflow-author/SKILL.md +20 -2
- package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +9 -1
- package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +6 -2
- package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +9 -1
- package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +16 -1
- package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +12 -2
- package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +16 -1
- package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +11 -2
- package/skills/stageflow-author/assets/examples/linear-review/review.yaml +19 -1
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +9 -1
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +6 -2
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +16 -1
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +9 -1
- package/skills/stageflow-author/references/catalog-mapping.md +80 -17
- package/skills/stageflow-author/references/catalog-write-conventions.md +2 -2
- package/skills/stageflow-author/references/stage-prompt-template.md +63 -4
- package/skills/stageflow-session-capture/SKILL.md +1 -1
- package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +2 -1
- package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +13 -2
- package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +18 -2
- package/skills/stageflow-session-capture/references/catalog-authoring.md +6 -3
- 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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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;
|
package/dist/agent/piAdapter.js
CHANGED
|
@@ -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 (
|
|
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).
|
|
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
|
|
468
|
-
*
|
|
469
|
-
*
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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>;
|