stageflow 0.19.0 → 0.25.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 (120) hide show
  1. package/README.md +16 -3
  2. package/dist/a2a/configDiscovery.d.ts +9 -0
  3. package/dist/a2a/configDiscovery.js +18 -0
  4. package/dist/a2a/contracts.d.ts +93 -0
  5. package/dist/a2a/contracts.js +163 -0
  6. package/dist/a2a/limits.d.ts +14 -0
  7. package/dist/a2a/limits.js +29 -0
  8. package/dist/a2a/registry.d.ts +44 -0
  9. package/dist/a2a/registry.js +159 -0
  10. package/dist/a2a/server.d.ts +29 -0
  11. package/dist/a2a/server.js +341 -0
  12. package/dist/a2a/service.d.ts +97 -0
  13. package/dist/a2a/service.js +496 -0
  14. package/dist/a2a/store.d.ts +106 -0
  15. package/dist/a2a/store.js +245 -0
  16. package/dist/agent/activity.d.ts +5 -0
  17. package/dist/agent/activityObserver.d.ts +3 -0
  18. package/dist/agent/activityObserver.js +6 -1
  19. package/dist/agent/piAdapter.js +7 -3
  20. package/dist/agent/port.d.ts +2 -0
  21. package/dist/agent/providerInspect.d.ts +19 -0
  22. package/dist/agent/providerInspect.js +35 -0
  23. package/dist/agent/streamLogRedact.d.ts +2 -0
  24. package/dist/agent/streamLogRedact.js +13 -0
  25. package/dist/cli/a2aCommand.d.ts +8 -0
  26. package/dist/cli/a2aCommand.js +131 -0
  27. package/dist/cli/artifactCommand.js +2 -2
  28. package/dist/cli/envelopeCommand.js +2 -2
  29. package/dist/cli/exportRunCommand.js +2 -2
  30. package/dist/cli/hostClient.d.ts +43 -0
  31. package/dist/cli/hostClient.js +182 -0
  32. package/dist/cli/providersCommand.js +5 -3
  33. package/dist/cli/runCommand.d.ts +6 -5
  34. package/dist/cli/runCommand.js +23 -31
  35. package/dist/cli/runOutput.d.ts +1 -1
  36. package/dist/cli/runStageCommand.d.ts +33 -0
  37. package/dist/cli/runStageCommand.js +407 -0
  38. package/dist/cli/runsCommand.d.ts +3 -7
  39. package/dist/cli/runsCommand.js +33 -94
  40. package/dist/cli.js +20 -5
  41. package/dist/config/describePipeline.d.ts +26 -0
  42. package/dist/config/describePipeline.js +37 -0
  43. package/dist/config/loadPipeline.d.ts +13 -1
  44. package/dist/config/loadPipeline.js +34 -4
  45. package/dist/config/mergePipelineIncludes.d.ts +11 -0
  46. package/dist/config/mergePipelineIncludes.js +75 -0
  47. package/dist/config/validateCatalog.d.ts +2 -2
  48. package/dist/config/validateCatalog.js +24 -17
  49. package/dist/mcp/catalogTools.js +285 -52
  50. package/dist/mcp/controlTools.js +77 -19
  51. package/dist/mcp/deps.d.ts +3 -0
  52. package/dist/mcp/projectMcpTools.d.ts +3 -0
  53. package/dist/mcp/projectMcpTools.js +25 -0
  54. package/dist/mcp/providerTools.d.ts +3 -0
  55. package/dist/mcp/providerTools.js +17 -0
  56. package/dist/mcp/readArtifact.d.ts +9 -0
  57. package/dist/mcp/readArtifact.js +11 -0
  58. package/dist/mcp/server.js +9 -1
  59. package/dist/mcp/tailStreamLog.d.ts +8 -0
  60. package/dist/mcp/tailStreamLog.js +28 -0
  61. package/dist/mcp/toolResults.d.ts +17 -0
  62. package/dist/mcp/toolResults.js +15 -0
  63. package/dist/mcp/tools.js +4 -0
  64. package/dist/mcp/waitRun.js +4 -4
  65. package/dist/mcp/waitingGates.d.ts +1 -0
  66. package/dist/mcp/waitingGates.js +3 -2
  67. package/dist/package-meta.d.ts +1 -1
  68. package/dist/package-meta.js +1 -1
  69. package/dist/projection/projectRun.d.ts +3 -0
  70. package/dist/projection/projectRun.js +7 -0
  71. package/dist/runstore/createStore.d.ts +10 -0
  72. package/dist/runstore/createStore.js +13 -4
  73. package/dist/runstore/port.d.ts +17 -2
  74. package/dist/runstore/sqlite/SqliteRunStore.d.ts +11 -1
  75. package/dist/runstore/sqlite/SqliteRunStore.js +81 -26
  76. package/dist/runstore/sqlite/schema.d.ts +1 -1
  77. package/dist/runstore/sqlite/schema.js +6 -0
  78. package/dist/runstore/submission.d.ts +11 -0
  79. package/dist/runstore/submission.js +7 -0
  80. package/dist/runstore/workspaceLayout.d.ts +1 -0
  81. package/dist/runstore/workspaceLayout.js +3 -0
  82. package/dist/runtime/feedbackLoopCoordinator.d.ts +9 -1
  83. package/dist/runtime/feedbackLoopCoordinator.js +50 -20
  84. package/dist/runtime/feedbackLoopDecision.js +33 -1
  85. package/dist/runtime/pipelineRunner.d.ts +5 -3
  86. package/dist/runtime/pipelineRunner.js +6 -1
  87. package/dist/runtime/pipelineScheduler.js +32 -15
  88. package/dist/runtime/reloadRunCatalog.js +7 -1
  89. package/dist/runtime/resolveEnvelopeRef.d.ts +30 -0
  90. package/dist/runtime/resolveEnvelopeRef.js +79 -0
  91. package/dist/runtime/resumeReconstruct.js +2 -2
  92. package/dist/runtime/runManager.d.ts +24 -2
  93. package/dist/runtime/runManager.js +83 -19
  94. package/dist/runtime/settingsFile.d.ts +2 -0
  95. package/dist/runtime/settingsFile.js +19 -0
  96. package/dist/runtime/stageAttemptBootstrap.d.ts +3 -0
  97. package/dist/runtime/stageAttemptBootstrap.js +8 -2
  98. package/dist/runtime/stageStreamLog.d.ts +17 -0
  99. package/dist/runtime/stageStreamLog.js +89 -0
  100. package/dist/runtime/stageWorker.js +2 -1
  101. package/dist/server/bootstrap.d.ts +2 -0
  102. package/dist/server/bootstrap.js +22 -4
  103. package/dist/server/createHttpHost.js +6 -0
  104. package/dist/server/ensureGlobalService.d.ts +35 -0
  105. package/dist/server/ensureGlobalService.js +138 -0
  106. package/dist/server/http.d.ts +20 -1
  107. package/dist/server/http.js +450 -402
  108. package/dist/server/mcpHost.js +7 -8
  109. package/dist/server/operatorResults.d.ts +10 -0
  110. package/dist/server/operatorResults.js +24 -0
  111. package/dist/server/providerRoutes.d.ts +0 -6
  112. package/dist/server/providerRoutes.js +10 -18
  113. package/dist/types/pipeline.d.ts +8 -0
  114. package/dist/ui/assets/{index-DMVdl1qB.js → index-Da5EHU6M.js} +5 -5
  115. package/dist/ui/index.html +1 -1
  116. package/package.json +2 -1
  117. package/skills/stageflow/references/control-surface.md +2 -2
  118. package/skills/stageflow-run/SKILL.md +27 -9
  119. package/skills/stageflow-run/references/mcp-call.md +1 -1
  120. package/skills/stageflow-run/scripts/mcp-call.mjs +9 -0
@@ -1,10 +1,33 @@
1
+ import path from "node:path";
1
2
  import { z } from "zod";
2
- import { browseCatalog } from "../config/browseCatalog.js";
3
+ import { browseCatalog, } from "../config/browseCatalog.js";
4
+ import { describePipeline } from "../config/describePipeline.js";
3
5
  import { loadPipeline } from "../config/loadPipeline.js";
6
+ import { readYamlObject } from "../config/readYamlObject.js";
4
7
  import { validateCatalog } from "../config/validateCatalog.js";
8
+ import { PACKAGE_VERSION } from "../package-meta.js";
9
+ import { findProjectRoot } from "../project/findProjectRoot.js";
10
+ import { PipelineValidationError } from "../runtime/pipelineValidationError.js";
11
+ import { EnvelopeRefError, resolveEnvelopeRefsToTask, } from "../runtime/resolveEnvelopeRef.js";
12
+ import { isTerminalProjection, isWaitingProjection, waitRun } from "./waitRun.js";
13
+ import { mapStoreLookupError } from "../server/operatorResults.js";
5
14
  import { projectRunForMcp } from "./projectRun.js";
6
- import { readRunArtifact } from "./readArtifact.js";
7
- import { textResult } from "./toolResults.js";
15
+ import { classifyArtifactContent, readRunArtifactBytes } from "./readArtifact.js";
16
+ import { imageResult, textResult } from "./toolResults.js";
17
+ /**
18
+ * Every project a host serving a global store has ever recorded a run for,
19
+ * plus this host's own launch directory (always included so a project with
20
+ * zero runs yet still sees its own catalog).
21
+ */
22
+ async function catalogRootsFor(deps) {
23
+ const known = await deps.store.listProjectRoots();
24
+ return [...new Set([...known, deps.cwd])];
25
+ }
26
+ /** Resolve which project a given catalog path (pipeline/task) belongs to. */
27
+ function projectRootForPath(deps, catalogPath) {
28
+ const absDir = path.dirname(path.resolve(deps.cwd, catalogPath));
29
+ return findProjectRoot(absDir) ?? deps.cwd;
30
+ }
8
31
  const taskFileSchema = z.object({
9
32
  id: z.string(),
10
33
  goal: z.string(),
@@ -13,31 +36,119 @@ const taskFileSchema = z.object({
13
36
  checkout: z.string().optional(),
14
37
  input: z.record(z.string(), z.unknown()).optional(),
15
38
  });
39
+ /**
40
+ * Deliberately thin: only enough shape to route to the inline-pipeline
41
+ * loader. Real structural validation (required io schemas, DAG shape, stage
42
+ * id uniqueness) happens downstream through the same validator a file-based
43
+ * pipeline goes through, so a caller gets the same ValidationFinding-shaped
44
+ * error either way instead of a raw schema-validation error.
45
+ */
46
+ const inlinePipelineSchema = z
47
+ .object({
48
+ id: z.string().min(1),
49
+ stages: z.array(z.record(z.string(), z.unknown())).min(1),
50
+ agent: z.unknown().optional(),
51
+ model: z.unknown().optional(),
52
+ schemas: z.unknown().optional(),
53
+ })
54
+ .strict();
16
55
  const startRunSchema = z
17
56
  .object({
18
- pipeline: z.string().describe("Filesystem path to a pipeline YAML file"),
57
+ pipeline: z
58
+ .union([z.string(), inlinePipelineSchema])
59
+ .describe("Filesystem path to a pipeline YAML file, or an inline pipeline definition object ({ id, stages: [...] }) authored directly in this call"),
19
60
  task_path: z.string().optional(),
20
61
  task: taskFileSchema.optional(),
21
62
  })
22
63
  .refine((data) => Boolean(data.task_path) !== Boolean(data.task), {
23
64
  message: "Exactly one of task_path or task is required",
24
65
  });
66
+ /**
67
+ * Deliberately thin, same reasoning as inlinePipelineSchema above: real
68
+ * structural validation of the stage body happens downstream through the
69
+ * same stage loader a pipeline's inline stage goes through.
70
+ */
71
+ const envelopeRefSchema = z.object({
72
+ runId: z.string(),
73
+ stageId: z.string(),
74
+ attempt: z.number().int().positive().optional(),
75
+ });
76
+ const runStageSchema = z
77
+ .object({
78
+ stage: z
79
+ .union([z.string(), z.record(z.string(), z.unknown())])
80
+ .describe("Filesystem path to a stage YAML file, or a bare inline stage body object ({ id, system_prompt, io, model?, gate_kinds?, mcp?, verify?, timeout_ms? } — no uses:/route/pipeline wrapper) authored directly in this call"),
81
+ task_path: z.string().optional(),
82
+ task: taskFileSchema.optional(),
83
+ envelope_ref: z
84
+ .union([envelopeRefSchema, z.array(envelopeRefSchema).min(1)])
85
+ .optional()
86
+ .describe("Resolve one or more previously stored StageEnvelopes (from another run_stage call or from any stage inside a full pipeline run) and use them as this stage's input, instead of an inline task/task_path. A single ref: its payload becomes input, its summary becomes goal. Multiple refs (an array): each resolved payload is namespaced under its stageId in input (disambiguated by runId on a stageId collision), and summaries are combined into goal."),
87
+ checkout: z
88
+ .string()
89
+ .optional()
90
+ .describe("Optional checkout to use with envelope_ref (ignored with task/task_path, which carry their own checkout). Resolving envelope_ref never implies a checkout on its own."),
91
+ model: z
92
+ .string()
93
+ .optional()
94
+ .describe("Override the model/backend for this call only, taking precedence over the stage's own declared model and the global default. Omit to use the stage's own model (or the global default)."),
95
+ blocking: z
96
+ .boolean()
97
+ .optional()
98
+ .describe("When true, wait for the run to reach a terminal or waiting state and return the result in this same call, instead of returning immediately with just { runId }"),
99
+ timeout_ms: z
100
+ .number()
101
+ .optional()
102
+ .describe("Wait budget in ms when blocking is true (same bounds as wait_run)"),
103
+ })
104
+ .refine((data) => [data.task_path, data.task, data.envelope_ref].filter(Boolean).length === 1, { message: "Exactly one of task_path, task, or envelope_ref is required" });
25
105
  const runStatusSchema = z.enum(["created", "running", "succeeded", "failed"]);
26
106
  export function registerCatalogTools(server, deps) {
27
107
  const { manager, store, cwd } = deps;
28
108
  server.registerTool("list_pipelines", {
29
- description: "List manifest-declared pipeline paths from the project catalog",
109
+ description: "List manifest-declared pipeline paths across every project this host knows about (this host's own project plus any project a run has ever been recorded for). Each entry is tagged with project_root. A project with no runs yet won't appear until its first run exists.",
30
110
  inputSchema: z.object({}),
31
111
  }, async () => {
32
- const catalog = await browseCatalog(cwd);
33
- return textResult({ pipelines: catalog.pipelines });
112
+ const roots = await catalogRootsFor(deps);
113
+ const pipelines = [];
114
+ for (const root of roots) {
115
+ try {
116
+ const catalog = await browseCatalog(root);
117
+ for (const p of catalog.pipelines) {
118
+ pipelines.push({ ...p, project_root: root });
119
+ }
120
+ }
121
+ catch {
122
+ // stale/unreadable project root recorded on an old run; skip it
123
+ }
124
+ }
125
+ return textResult({ pipelines });
34
126
  });
35
127
  server.registerTool("list_tasks", {
36
- description: "List manifest-declared task paths from the project catalog",
128
+ description: "List manifest-declared task paths across every project this host knows about (this host's own project plus any project a run has ever been recorded for). Each entry is tagged with project_root. A project with no runs yet won't appear until its first run exists.",
129
+ inputSchema: z.object({}),
130
+ }, async () => {
131
+ const roots = await catalogRootsFor(deps);
132
+ const tasks = [];
133
+ for (const root of roots) {
134
+ try {
135
+ const catalog = await browseCatalog(root);
136
+ for (const t of catalog.tasks) {
137
+ tasks.push({ ...t, project_root: root });
138
+ }
139
+ }
140
+ catch {
141
+ // stale/unreadable project root recorded on an old run; skip it
142
+ }
143
+ }
144
+ return textResult({ tasks });
145
+ });
146
+ server.registerTool("list_models", {
147
+ description: "List catalog model ids from the project catalog (same source as GET /api/models)",
37
148
  inputSchema: z.object({}),
38
149
  }, async () => {
39
150
  const catalog = await browseCatalog(cwd);
40
- return textResult({ tasks: catalog.tasks });
151
+ return textResult({ models: catalog.models });
41
152
  });
42
153
  server.registerTool("list_runs", {
43
154
  description: "List known pipeline runs. Optional filters: status, since (ISO created_at lower bound), pipeline (id or path).",
@@ -62,27 +173,133 @@ export function registerCatalogTools(server, deps) {
62
173
  return textResult({ runs });
63
174
  });
64
175
  server.registerTool("get_health", {
65
- description: "Server health and soft-max run capacity: activeRunIds, activeCount, maxConcurrent, slotsAvailable. Start until slotsAvailable is 0; then wait for a run to finish or raise STAGEFLOW_MAX_CONCURRENT_RUNS.",
176
+ description: "Server health and soft-max run capacity: activeRunIds, activeCount, maxConcurrent, slotsAvailable, version. Start until slotsAvailable is 0; then wait for a run to finish or raise STAGEFLOW_MAX_CONCURRENT_RUNS.",
66
177
  inputSchema: z.object({}),
67
- }, async () => textResult(manager.getHealth()));
178
+ }, async () => textResult({ ...manager.getHealth(), version: PACKAGE_VERSION }));
68
179
  server.registerTool("start_run", {
69
- description: "Start a pipeline run using a filesystem pipeline path and either task_path (catalog task file) or an inline task object. Returns { runId }. On conflict returns isError with code busy_capacity (soft max full) or busy_checkout (same checkout leased), plus activeCount/maxConcurrent/activeRunIds and optional conflictingRunId/conflictingCheckout.",
180
+ description: "Start a pipeline run using a filesystem pipeline path or an inline pipeline definition ({ id, stages: [...] }, each stage the same shape as a YAML stage body — no uses: refs), and either task_path (catalog task file) or an inline task object. Returns { runId }. On conflict returns isError with code busy_capacity (soft max full) or busy_checkout (same checkout leased), plus activeCount/maxConcurrent/activeRunIds and optional conflictingRunId/conflictingCheckout.",
70
181
  inputSchema: startRunSchema,
71
182
  }, async ({ pipeline, task_path, task }) => {
72
- if (!pipeline.trim()) {
183
+ if (typeof pipeline === "string" && !pipeline.trim()) {
73
184
  return textResult({ error: "pipeline is required" }, true);
74
185
  }
75
186
  const taskInput = task_path ?? task;
76
187
  if (taskInput === undefined) {
77
188
  return textResult({ error: "Exactly one of task_path or task is required" }, true);
78
189
  }
79
- const result = await manager.startRun({ pipeline, task: taskInput });
190
+ let result;
191
+ try {
192
+ result = await manager.startRun({ pipeline, task: taskInput });
193
+ }
194
+ catch (err) {
195
+ if (err instanceof PipelineValidationError) {
196
+ return textResult({ error: "Pipeline validation failed", validation: err.result }, true);
197
+ }
198
+ throw err;
199
+ }
80
200
  if (!result.ok) {
81
201
  const { ok: _ok, reason, ...rest } = result;
82
202
  return textResult({ error: reason, ...rest }, true);
83
203
  }
84
204
  return textResult({ runId: result.runId });
85
205
  });
206
+ server.registerTool("run_stage", {
207
+ description: "Run a single stage directly, without authoring a pipeline. `stage` is a filesystem path to a stage YAML file, or a bare inline stage body ({ id, system_prompt, io, model?, gate_kinds?, mcp?, verify?, timeout_ms? } — no uses:/route/pipeline wrapper), and exactly one of task_path, an inline task, or envelope_ref ({ runId, stageId, attempt? }) to resolve a previously stored envelope — from another run_stage call or any stage in a full pipeline run — as this stage's input. Internally this synthesizes a one-stage pipeline and executes it through the normal run path, so it shows up in list_runs/get_run and is polled with wait_run / get_envelope exactly like any other run. By default returns { runId, stageId } immediately (async); pass blocking:true to wait in this same call and get back { runId, stageId, status: \"completed\"|\"needs_input\"|\"timeout\", envelope? , pending_prompt? }.",
208
+ inputSchema: runStageSchema,
209
+ }, async ({ stage, task_path, task, envelope_ref, checkout, model, blocking, timeout_ms }) => {
210
+ let taskInput = task_path ?? task;
211
+ if (envelope_ref) {
212
+ try {
213
+ taskInput = await resolveEnvelopeRefsToTask(store, envelope_ref, checkout);
214
+ }
215
+ catch (err) {
216
+ if (err instanceof EnvelopeRefError) {
217
+ return textResult({ error: err.message, status: err.status }, true);
218
+ }
219
+ throw err;
220
+ }
221
+ }
222
+ if (taskInput === undefined) {
223
+ return textResult({ error: "Exactly one of task_path, task, or envelope_ref is required" }, true);
224
+ }
225
+ let stageBody;
226
+ if (typeof stage === "string") {
227
+ if (!stage.trim()) {
228
+ return textResult({ error: "stage is required" }, true);
229
+ }
230
+ const absPath = path.resolve(cwd, stage);
231
+ try {
232
+ stageBody = await readYamlObject(absPath);
233
+ }
234
+ catch (err) {
235
+ const message = err instanceof Error ? err.message : String(err);
236
+ return textResult({ error: `Failed to read stage file: ${message}` }, true);
237
+ }
238
+ }
239
+ else {
240
+ stageBody = stage;
241
+ }
242
+ if (typeof stageBody.id !== "string" || !stageBody.id.trim()) {
243
+ return textResult({ error: "stage.id is required" }, true);
244
+ }
245
+ const stageId = stageBody.id;
246
+ // A call-level model override wins over both the stage's own declared
247
+ // model and the global default: it replaces the field the stage body
248
+ // itself would otherwise resolve through, rather than adding a new
249
+ // tier to resolveModel's stage > pipeline > global chain.
250
+ const effectiveStageBody = model !== undefined ? { ...stageBody, model } : stageBody;
251
+ const pipeline = {
252
+ id: `standalone-${stageId}`,
253
+ stages: [effectiveStageBody],
254
+ };
255
+ let result;
256
+ try {
257
+ result = await manager.startRun({ pipeline, task: taskInput });
258
+ }
259
+ catch (err) {
260
+ if (err instanceof PipelineValidationError) {
261
+ return textResult({ error: "Stage validation failed", validation: err.result }, true);
262
+ }
263
+ throw err;
264
+ }
265
+ if (!result.ok) {
266
+ const { ok: _ok, reason, ...rest } = result;
267
+ return textResult({ error: reason, ...rest }, true);
268
+ }
269
+ const runId = result.runId;
270
+ if (!blocking) {
271
+ return textResult({ runId, stageId });
272
+ }
273
+ const waited = await waitRun({ store, runId, timeoutMs: timeout_ms, until: "any" });
274
+ if (!waited.ok) {
275
+ return textResult({
276
+ error: waited.error,
277
+ ...(waited.status !== undefined ? { status: waited.status } : {}),
278
+ ...(waited.code !== undefined ? { code: waited.code } : {}),
279
+ }, true);
280
+ }
281
+ const stageProjection = waited.run.stages.find((s) => s.stage_id === stageId);
282
+ if (isTerminalProjection(waited.run)) {
283
+ return textResult({
284
+ runId,
285
+ stageId,
286
+ status: "completed",
287
+ envelope: stageProjection?.envelope ?? null,
288
+ });
289
+ }
290
+ if (isWaitingProjection(waited.run)) {
291
+ return textResult({
292
+ runId,
293
+ stageId,
294
+ status: "needs_input",
295
+ pending_prompt: stageProjection?.pending_prompt,
296
+ waiting_kind: waited.run.waiting_kind,
297
+ waiting_prompt_id: waited.run.waiting_prompt_id,
298
+ waiting_summary: waited.run.waiting_summary,
299
+ });
300
+ }
301
+ return textResult({ runId, stageId, status: "timeout" });
302
+ });
86
303
  server.registerTool("get_run", {
87
304
  description: "Poll a run: status, stage statuses, waiting_* / pending_prompt HITL fields, and envelope summary/payload/artifact paths (no events). Use list_stage_events, get_envelope, or get_stage_verification for stage detail.",
88
305
  inputSchema: z.object({
@@ -94,34 +311,39 @@ export function registerCatalogTools(server, deps) {
94
311
  return textResult(projectRunForMcp(detail));
95
312
  }
96
313
  catch (err) {
97
- const message = err instanceof Error ? err.message : String(err);
98
- const notFound = /not found|no such|unknown run/i.test(message);
99
- return textResult({
100
- error: message,
101
- status: notFound ? 404 : 500,
102
- }, true);
314
+ const mapped = mapStoreLookupError(err, { policy: "run" });
315
+ return textResult({ error: mapped.error, status: mapped.status }, true);
103
316
  }
104
317
  });
105
318
  server.registerTool("read_artifact", {
106
- description: "Read a text artifact from a run workspace by relative path (contained under the run workspace).",
319
+ description: "Read a run-workspace artifact by relative path (contained under the run workspace). Known image extensions (png, jpeg, gif, webp) return an MCP image content block. UTF-8 text returns JSON { runId, path, content }. Non-UTF-8 non-image files return isError 400.",
107
320
  inputSchema: z.object({
108
321
  runId: z.string(),
109
322
  path: z.string(),
110
323
  }),
111
324
  }, async ({ runId, path: artifactPath }) => {
112
325
  try {
113
- const content = await readRunArtifact(store, runId, artifactPath);
114
- return textResult({ runId, path: artifactPath, content });
326
+ const bytes = await readRunArtifactBytes(store, runId, artifactPath);
327
+ const classified = classifyArtifactContent(artifactPath, bytes);
328
+ if (classified.kind === "image") {
329
+ return imageResult(classified.mimeType, bytes, {
330
+ runId,
331
+ path: artifactPath,
332
+ mimeType: classified.mimeType,
333
+ });
334
+ }
335
+ if (classified.kind === "utf8") {
336
+ return textResult({
337
+ runId,
338
+ path: artifactPath,
339
+ content: bytes.toString("utf8"),
340
+ });
341
+ }
342
+ return textResult({ error: "Artifact is not valid UTF-8 text", status: 400 }, true);
115
343
  }
116
344
  catch (err) {
117
- const message = err instanceof Error ? err.message : String(err);
118
- const notFound = message.startsWith("Run not found") ||
119
- message.startsWith("Artifact not found") ||
120
- /no such|not found/i.test(message);
121
- return textResult({
122
- error: message,
123
- status: notFound ? 404 : 400,
124
- }, true);
345
+ const mapped = mapStoreLookupError(err, { policy: "artifact" });
346
+ return textResult({ error: mapped.error, status: mapped.status }, true);
125
347
  }
126
348
  });
127
349
  server.registerTool("validate", {
@@ -138,11 +360,36 @@ export function registerCatalogTools(server, deps) {
138
360
  scope = "pipeline";
139
361
  else if (task !== undefined && task.trim())
140
362
  scope = "task";
363
+ if (scope === "full") {
364
+ const roots = await catalogRootsFor(deps);
365
+ const results = [];
366
+ for (const root of roots) {
367
+ try {
368
+ results.push(await validateCatalog({ cwd: root, scope: "full", strict: strict ?? false }));
369
+ }
370
+ catch {
371
+ // stale/unreadable project root recorded on an old run; skip it
372
+ }
373
+ }
374
+ const merged = {
375
+ scope: "full",
376
+ ok: results.every((r) => r.ok),
377
+ summary: {
378
+ errors: results.reduce((n, r) => n + r.summary.errors, 0),
379
+ warnings: results.reduce((n, r) => n + r.summary.warnings, 0),
380
+ },
381
+ findings: results.flatMap((r) => r.findings),
382
+ };
383
+ return textResult(merged);
384
+ }
385
+ const target = scope === "pipeline" ? pipeline.trim() : task.trim();
386
+ const projectRoot = projectRootForPath(deps, target);
141
387
  const result = await validateCatalog({
142
- cwd,
388
+ cwd: projectRoot,
143
389
  scope,
144
- ...(scope === "pipeline" ? { pipeline: pipeline.trim() } : {}),
145
- ...(scope === "task" ? { task: task.trim() } : {}),
390
+ projectRoot,
391
+ ...(scope === "pipeline" ? { pipeline: target } : {}),
392
+ ...(scope === "task" ? { task: target } : {}),
146
393
  strict: strict ?? false,
147
394
  });
148
395
  return textResult(result);
@@ -153,7 +400,7 @@ export function registerCatalogTools(server, deps) {
153
400
  }
154
401
  });
155
402
  server.registerTool("describe_pipeline", {
156
- description: "Describe a pipeline DAG: stages with needs, fork, and gate_kinds. Input is a filesystem pipeline path (same as start_run).",
403
+ description: "Describe a pipeline DAG from a filesystem path (same as start_run): stages with inbound needs (id, on, optional if), fork, gate_kinds, Clone Chain clone_cap and clone_mode, and feedback_loop, entry, and replay_safe when set.",
157
404
  inputSchema: z.object({
158
405
  pipeline: z.string(),
159
406
  }),
@@ -162,24 +409,10 @@ export function registerCatalogTools(server, deps) {
162
409
  return textResult({ error: "pipeline is required", status: 400 }, true);
163
410
  }
164
411
  try {
165
- const loaded = await loadPipeline(pipeline.trim(), { cwd });
166
- const gateById = new Map(loaded.stages.map((s) => [s.id, s.gate_kinds]));
167
- const stages = loaded.dag.nodes.map((node) => ({
168
- id: node.id,
169
- needs: node.needsEdges.length > 1
170
- ? node.needsEdges.map((edge) => ({ id: edge.id, on: [...edge.on] }))
171
- : node.needs,
172
- ...(node.fork !== undefined ? { fork: node.fork } : {}),
173
- ...(node.clone_cap !== undefined ? { clone_cap: node.clone_cap } : {}),
174
- ...(gateById.get(node.id) !== undefined
175
- ? { gate_kinds: gateById.get(node.id) }
176
- : {}),
177
- }));
178
- return textResult({
179
- id: loaded.pipeline.id,
180
- path: loaded.pipelinePath,
181
- stages,
412
+ const loaded = await loadPipeline(pipeline.trim(), {
413
+ cwd: projectRootForPath(deps, pipeline.trim()),
182
414
  });
415
+ return textResult(describePipeline(loaded));
183
416
  }
184
417
  catch (err) {
185
418
  const message = err instanceof Error ? err.message : String(err);
@@ -1,11 +1,15 @@
1
+ import path from "node:path";
1
2
  import { z } from "zod";
2
- import { mapRetryStageFailure, mapStartFailure, } from "../server/operatorResults.js";
3
+ import { findProjectRoot } from "../project/findProjectRoot.js";
4
+ import { mapRetryStageFailure, mapStartFailure, mapStoreLookupError, } from "../server/operatorResults.js";
5
+ import { attemptStreamLogPath } from "../runstore/workspaceLayout.js";
3
6
  import { parseAskOperatorAnswer } from "../tools/askOperator.js";
7
+ import { readStreamLogTail } from "./tailStreamLog.js";
4
8
  import { textResult } from "./toolResults.js";
5
9
  import { projectWaitingGates } from "./waitingGates.js";
6
10
  import { readStageVerificationHistory } from "../runstore/verificationHistory.js";
7
11
  export function registerControlTools(server, deps) {
8
- const { manager, store } = deps;
12
+ const { manager, store, cwd } = deps;
9
13
  server.registerTool("list_waiting", {
10
14
  description: "List stages waiting for operator input across runs (optional runId filter). Includes pending_prompt and waiting_* fields per gate.",
11
15
  inputSchema: z.object({
@@ -15,6 +19,24 @@ export function registerControlTools(server, deps) {
15
19
  const waiting = await projectWaitingGates(store, { runId });
16
20
  return textResult({ waiting });
17
21
  });
22
+ server.registerTool("get_waiting_summary", {
23
+ description: "Lightweight count and identity of stages waiting for operator input — no pending_prompt, waiting_artifacts, or waiting_questions (use list_waiting for full detail). Optional runId scopes to one run; optional path scopes to one project (derived via findProjectRoot, same rule as elsewhere); omitting both spans every project the store knows about.",
24
+ inputSchema: z.object({
25
+ runId: z.string().optional(),
26
+ path: z.string().optional(),
27
+ }),
28
+ }, async ({ runId, path: scopePath }) => {
29
+ const projectRoot = runId === undefined && scopePath !== undefined
30
+ ? (findProjectRoot(path.resolve(cwd, scopePath)) ?? path.resolve(cwd, scopePath))
31
+ : undefined;
32
+ const waiting = await projectWaitingGates(store, { runId, projectRoot });
33
+ const runs = waiting.map((item) => ({
34
+ runId: item.runId,
35
+ stageId: item.stageId,
36
+ ...(item.waiting_kind !== undefined ? { kind: item.waiting_kind } : {}),
37
+ }));
38
+ return textResult({ count: runs.length, runs });
39
+ });
18
40
  server.registerTool("answer_gate", {
19
41
  description: "Deliver an AskOperatorAnswer for a stage in waiting_for_input (same semantics as POST /api/runs/:id/stages/:stageId/answer). Pass answer as { promptId, kind, ... } matching the pending prompt.",
20
42
  inputSchema: z.object({
@@ -73,8 +95,8 @@ export function registerControlTools(server, deps) {
73
95
  await store.readRunMeta(runId);
74
96
  }
75
97
  catch (err) {
76
- const message = err instanceof Error ? err.message : String(err);
77
- return textResult({ error: message, status: 404 }, true);
98
+ const mapped = mapStoreLookupError(err, { policy: "run" });
99
+ return textResult({ error: mapped.error, status: 404 }, true);
78
100
  }
79
101
  try {
80
102
  const detail = await store.readRun(runId);
@@ -86,9 +108,46 @@ export function registerControlTools(server, deps) {
86
108
  return textResult({ runId, stageId, attempt, events });
87
109
  }
88
110
  catch (err) {
89
- const message = err instanceof Error ? err.message : String(err);
90
- const notFound = /not found|no such/i.test(message);
91
- return textResult({ error: message, status: notFound ? 404 : 500 }, true);
111
+ const mapped = mapStoreLookupError(err, { policy: "run" });
112
+ return textResult({ error: mapped.error, status: mapped.status }, true);
113
+ }
114
+ });
115
+ server.registerTool("tail_stage_log", {
116
+ description: "Poll for new live assistant text a running (or recently finished) stage attempt has produced, since a byte-offset cursor. Separate from list_stage_events — no prompt/artifact/question detail, just the raw redacted text as it streamed. Omit since_offset to catch up on everything currently retained.",
117
+ inputSchema: z.object({
118
+ runId: z.string(),
119
+ stageId: z.string(),
120
+ attempt: z.number().int().positive().optional(),
121
+ since_offset: z.number().int().nonnegative().optional(),
122
+ }),
123
+ }, async ({ runId, stageId, attempt, since_offset }) => {
124
+ try {
125
+ await store.readRunMeta(runId);
126
+ }
127
+ catch (err) {
128
+ const mapped = mapStoreLookupError(err, { policy: "run" });
129
+ return textResult({ error: mapped.error, status: 404 }, true);
130
+ }
131
+ try {
132
+ const detail = await store.readRun(runId);
133
+ const stage = detail.stages.find((s) => s.stage_id === stageId);
134
+ if (!stage) {
135
+ return textResult({ error: `Stage not found: ${stageId}`, status: 404 }, true);
136
+ }
137
+ const effectiveAttempt = attempt ?? stage.attempt_count;
138
+ const attemptComplete = effectiveAttempt < stage.attempt_count || stage.status !== "running";
139
+ const streamLogPath = attemptStreamLogPath(store.getWorkspaceDir(runId), stageId, effectiveAttempt);
140
+ const tail = await readStreamLogTail(streamLogPath, since_offset);
141
+ return textResult({
142
+ text: tail.text,
143
+ next_offset: tail.nextOffset,
144
+ attempt_complete: attemptComplete,
145
+ ...(tail.truncated ? { truncated: true, earliest_offset: tail.earliestOffset } : {}),
146
+ });
147
+ }
148
+ catch (err) {
149
+ const mapped = mapStoreLookupError(err, { policy: "run" });
150
+ return textResult({ error: mapped.error, status: mapped.status }, true);
92
151
  }
93
152
  });
94
153
  server.registerTool("get_stage_verification", {
@@ -102,9 +161,8 @@ export function registerControlTools(server, deps) {
102
161
  return textResult(await readStageVerificationHistory(store, runId, stageId));
103
162
  }
104
163
  catch (err) {
105
- const message = err instanceof Error ? err.message : String(err);
106
- const notFound = /not found|no such/i.test(message);
107
- return textResult({ error: message, status: notFound ? 404 : 500 }, true);
164
+ const mapped = mapStoreLookupError(err, { policy: "run" });
165
+ return textResult({ error: mapped.error, status: mapped.status }, true);
108
166
  }
109
167
  });
110
168
  server.registerTool("recover_manual_stage", {
@@ -139,31 +197,31 @@ export function registerControlTools(server, deps) {
139
197
  return textResult(result);
140
198
  });
141
199
  server.registerTool("get_envelope", {
142
- description: "Read the full StageEnvelope for a run stage (latest attempt). Returns 404 when absent.",
200
+ description: "Read the full StageEnvelope for a run stage. Optional attempt (omit = latest). Returns 404 when absent.",
143
201
  inputSchema: z.object({
144
202
  runId: z.string(),
145
203
  stageId: z.string(),
204
+ attempt: z.number().int().positive().optional(),
146
205
  }),
147
- }, async ({ runId, stageId }) => {
206
+ }, async ({ runId, stageId, attempt }) => {
148
207
  try {
149
208
  await store.readRunMeta(runId);
150
209
  }
151
210
  catch (err) {
152
- const message = err instanceof Error ? err.message : String(err);
153
- return textResult({ error: message, status: 404 }, true);
211
+ const mapped = mapStoreLookupError(err, { policy: "run" });
212
+ return textResult({ error: mapped.error, status: 404 }, true);
154
213
  }
155
214
  try {
156
215
  const detail = await store.readRun(runId);
157
216
  if (!detail.stages.some((s) => s.stage_id === stageId)) {
158
217
  return textResult({ error: `Stage not found: ${stageId}`, status: 404 }, true);
159
218
  }
160
- const envelope = await store.readEnvelope(runId, stageId);
161
- return textResult({ runId, stageId, envelope });
219
+ const envelope = await store.readEnvelope(runId, stageId, attempt);
220
+ return textResult({ runId, stageId, attempt, envelope });
162
221
  }
163
222
  catch (err) {
164
- const message = err instanceof Error ? err.message : String(err);
165
- const notFound = /not found|no such|envelope/i.test(message);
166
- return textResult({ error: message, status: notFound ? 404 : 500 }, true);
223
+ const mapped = mapStoreLookupError(err, { policy: "envelope" });
224
+ return textResult({ error: mapped.error, status: mapped.status }, true);
167
225
  }
168
226
  });
169
227
  server.registerTool("retry_stage", {
@@ -1,3 +1,4 @@
1
+ import type { ProviderAuthContext } from "../agent/providerAuth.js";
1
2
  import type { RunStore } from "../runstore/port.js";
2
3
  import type { RunChangeBus } from "../runtime/runChangeBus.js";
3
4
  import type { RunManager } from "../runtime/runManager.js";
@@ -5,6 +6,8 @@ export type McpToolDeps = {
5
6
  manager: RunManager;
6
7
  store: RunStore;
7
8
  cwd: string;
9
+ providerAuthContext?: ProviderAuthContext;
10
+ projectRoot?: string;
8
11
  };
9
12
  export type McpHttpDeps = McpToolDeps & {
10
13
  runChangeBus: RunChangeBus;
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ import type { McpToolDeps } from "./deps.js";
3
+ export declare function registerProjectMcpTools(server: McpServer, deps: McpToolDeps): void;
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ import { probeProjectMcpServer } from "../agent/piIsolatedMcpProbe.js";
3
+ import { listProjectMcpCatalog } from "../config/resolveStageMcpServers.js";
4
+ import { textResult } from "./toolResults.js";
5
+ export function registerProjectMcpTools(server, deps) {
6
+ const { cwd, projectRoot } = deps;
7
+ const root = projectRoot ?? cwd;
8
+ server.registerTool("list_project_mcp", {
9
+ description: "List git-root .mcp.json servers as names and transport only (same as GET /api/project-mcp). Inspect is not attach; it does not interpolate, spawn, or change stage MCP.",
10
+ inputSchema: z.object({}),
11
+ }, async () => textResult(await listProjectMcpCatalog(root)));
12
+ server.registerTool("probe_project_mcp", {
13
+ description: "Probe one named git-root .mcp.json server with connect-and-exit (same as POST /api/project-mcp/:name/probe). Inspect is not attach; YAML mcp: still allowlists what a stage receives.",
14
+ inputSchema: z.object({
15
+ name: z.string(),
16
+ }),
17
+ }, async ({ name }, ctx) => {
18
+ const result = await probeProjectMcpServer({
19
+ projectRoot: root,
20
+ name,
21
+ signal: ctx.mcpReq.signal,
22
+ });
23
+ return textResult(result);
24
+ });
25
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ import type { McpToolDeps } from "./deps.js";
3
+ export declare function registerProviderTools(server: McpServer, deps: McpToolDeps): void;