stageflow 0.19.0 → 0.23.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 (90) hide show
  1. package/README.md +2 -1
  2. package/dist/agent/activity.d.ts +5 -0
  3. package/dist/agent/activityObserver.d.ts +3 -0
  4. package/dist/agent/activityObserver.js +6 -1
  5. package/dist/agent/piAdapter.js +7 -3
  6. package/dist/agent/port.d.ts +2 -0
  7. package/dist/agent/providerInspect.d.ts +19 -0
  8. package/dist/agent/providerInspect.js +35 -0
  9. package/dist/agent/streamLogRedact.d.ts +2 -0
  10. package/dist/agent/streamLogRedact.js +13 -0
  11. package/dist/cli/artifactCommand.js +2 -2
  12. package/dist/cli/envelopeCommand.js +2 -2
  13. package/dist/cli/exportRunCommand.js +2 -2
  14. package/dist/cli/hostClient.d.ts +43 -0
  15. package/dist/cli/hostClient.js +182 -0
  16. package/dist/cli/providersCommand.js +5 -3
  17. package/dist/cli/runCommand.d.ts +6 -5
  18. package/dist/cli/runCommand.js +23 -31
  19. package/dist/cli/runOutput.d.ts +1 -1
  20. package/dist/cli/runsCommand.d.ts +3 -7
  21. package/dist/cli/runsCommand.js +33 -94
  22. package/dist/cli.js +1 -5
  23. package/dist/config/describePipeline.d.ts +26 -0
  24. package/dist/config/describePipeline.js +37 -0
  25. package/dist/config/loadPipeline.d.ts +13 -1
  26. package/dist/config/loadPipeline.js +34 -4
  27. package/dist/config/mergePipelineIncludes.d.ts +11 -0
  28. package/dist/config/mergePipelineIncludes.js +75 -0
  29. package/dist/config/validateCatalog.d.ts +2 -2
  30. package/dist/config/validateCatalog.js +24 -17
  31. package/dist/mcp/catalogTools.js +146 -52
  32. package/dist/mcp/controlTools.js +77 -19
  33. package/dist/mcp/deps.d.ts +3 -0
  34. package/dist/mcp/projectMcpTools.d.ts +3 -0
  35. package/dist/mcp/projectMcpTools.js +25 -0
  36. package/dist/mcp/providerTools.d.ts +3 -0
  37. package/dist/mcp/providerTools.js +17 -0
  38. package/dist/mcp/readArtifact.d.ts +9 -0
  39. package/dist/mcp/readArtifact.js +11 -0
  40. package/dist/mcp/server.js +9 -1
  41. package/dist/mcp/tailStreamLog.d.ts +8 -0
  42. package/dist/mcp/tailStreamLog.js +28 -0
  43. package/dist/mcp/toolResults.d.ts +17 -0
  44. package/dist/mcp/toolResults.js +15 -0
  45. package/dist/mcp/tools.js +4 -0
  46. package/dist/mcp/waitRun.js +4 -4
  47. package/dist/mcp/waitingGates.d.ts +1 -0
  48. package/dist/mcp/waitingGates.js +3 -2
  49. package/dist/package-meta.d.ts +1 -1
  50. package/dist/package-meta.js +1 -1
  51. package/dist/projection/projectRun.d.ts +3 -0
  52. package/dist/projection/projectRun.js +7 -0
  53. package/dist/runstore/port.d.ts +3 -1
  54. package/dist/runstore/sqlite/SqliteRunStore.d.ts +2 -1
  55. package/dist/runstore/sqlite/SqliteRunStore.js +15 -1
  56. package/dist/runstore/workspaceLayout.d.ts +1 -0
  57. package/dist/runstore/workspaceLayout.js +3 -0
  58. package/dist/runtime/feedbackLoopCoordinator.d.ts +9 -1
  59. package/dist/runtime/feedbackLoopCoordinator.js +50 -20
  60. package/dist/runtime/feedbackLoopDecision.js +33 -1
  61. package/dist/runtime/pipelineRunner.d.ts +3 -3
  62. package/dist/runtime/pipelineRunner.js +3 -1
  63. package/dist/runtime/resumeReconstruct.js +2 -2
  64. package/dist/runtime/runManager.d.ts +2 -1
  65. package/dist/runtime/runManager.js +36 -16
  66. package/dist/runtime/settingsFile.d.ts +2 -0
  67. package/dist/runtime/settingsFile.js +19 -0
  68. package/dist/runtime/stageAttemptBootstrap.d.ts +3 -0
  69. package/dist/runtime/stageAttemptBootstrap.js +8 -2
  70. package/dist/runtime/stageStreamLog.d.ts +17 -0
  71. package/dist/runtime/stageStreamLog.js +89 -0
  72. package/dist/runtime/stageWorker.js +2 -1
  73. package/dist/server/bootstrap.js +9 -2
  74. package/dist/server/ensureGlobalService.d.ts +35 -0
  75. package/dist/server/ensureGlobalService.js +138 -0
  76. package/dist/server/http.d.ts +20 -1
  77. package/dist/server/http.js +450 -402
  78. package/dist/server/mcpHost.js +7 -8
  79. package/dist/server/operatorResults.d.ts +10 -0
  80. package/dist/server/operatorResults.js +24 -0
  81. package/dist/server/providerRoutes.d.ts +0 -6
  82. package/dist/server/providerRoutes.js +10 -18
  83. package/dist/types/pipeline.d.ts +8 -0
  84. package/dist/ui/assets/{index-DMVdl1qB.js → index-Da5EHU6M.js} +5 -5
  85. package/dist/ui/index.html +1 -1
  86. package/package.json +1 -1
  87. package/skills/stageflow/references/control-surface.md +2 -2
  88. package/skills/stageflow-run/SKILL.md +27 -9
  89. package/skills/stageflow-run/references/mcp-call.md +1 -1
  90. package/skills/stageflow-run/scripts/mcp-call.mjs +9 -0
@@ -1,10 +1,30 @@
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";
4
6
  import { validateCatalog } from "../config/validateCatalog.js";
7
+ import { PACKAGE_VERSION } from "../package-meta.js";
8
+ import { findProjectRoot } from "../project/findProjectRoot.js";
9
+ import { PipelineValidationError } from "../runtime/pipelineValidationError.js";
10
+ import { mapStoreLookupError } from "../server/operatorResults.js";
5
11
  import { projectRunForMcp } from "./projectRun.js";
6
- import { readRunArtifact } from "./readArtifact.js";
7
- import { textResult } from "./toolResults.js";
12
+ import { classifyArtifactContent, readRunArtifactBytes } from "./readArtifact.js";
13
+ import { imageResult, textResult } from "./toolResults.js";
14
+ /**
15
+ * Every project a host serving a global store has ever recorded a run for,
16
+ * plus this host's own launch directory (always included so a project with
17
+ * zero runs yet still sees its own catalog).
18
+ */
19
+ async function catalogRootsFor(deps) {
20
+ const known = await deps.store.listProjectRoots();
21
+ return [...new Set([...known, deps.cwd])];
22
+ }
23
+ /** Resolve which project a given catalog path (pipeline/task) belongs to. */
24
+ function projectRootForPath(deps, catalogPath) {
25
+ const absDir = path.dirname(path.resolve(deps.cwd, catalogPath));
26
+ return findProjectRoot(absDir) ?? deps.cwd;
27
+ }
8
28
  const taskFileSchema = z.object({
9
29
  id: z.string(),
10
30
  goal: z.string(),
@@ -13,9 +33,27 @@ const taskFileSchema = z.object({
13
33
  checkout: z.string().optional(),
14
34
  input: z.record(z.string(), z.unknown()).optional(),
15
35
  });
36
+ /**
37
+ * Deliberately thin: only enough shape to route to the inline-pipeline
38
+ * loader. Real structural validation (required io schemas, DAG shape, stage
39
+ * id uniqueness) happens downstream through the same validator a file-based
40
+ * pipeline goes through, so a caller gets the same ValidationFinding-shaped
41
+ * error either way instead of a raw schema-validation error.
42
+ */
43
+ const inlinePipelineSchema = z
44
+ .object({
45
+ id: z.string().min(1),
46
+ stages: z.array(z.record(z.string(), z.unknown())).min(1),
47
+ agent: z.unknown().optional(),
48
+ model: z.unknown().optional(),
49
+ schemas: z.unknown().optional(),
50
+ })
51
+ .strict();
16
52
  const startRunSchema = z
17
53
  .object({
18
- pipeline: z.string().describe("Filesystem path to a pipeline YAML file"),
54
+ pipeline: z
55
+ .union([z.string(), inlinePipelineSchema])
56
+ .describe("Filesystem path to a pipeline YAML file, or an inline pipeline definition object ({ id, stages: [...] }) authored directly in this call"),
19
57
  task_path: z.string().optional(),
20
58
  task: taskFileSchema.optional(),
21
59
  })
@@ -26,18 +64,49 @@ const runStatusSchema = z.enum(["created", "running", "succeeded", "failed"]);
26
64
  export function registerCatalogTools(server, deps) {
27
65
  const { manager, store, cwd } = deps;
28
66
  server.registerTool("list_pipelines", {
29
- description: "List manifest-declared pipeline paths from the project catalog",
67
+ 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
68
  inputSchema: z.object({}),
31
69
  }, async () => {
32
- const catalog = await browseCatalog(cwd);
33
- return textResult({ pipelines: catalog.pipelines });
70
+ const roots = await catalogRootsFor(deps);
71
+ const pipelines = [];
72
+ for (const root of roots) {
73
+ try {
74
+ const catalog = await browseCatalog(root);
75
+ for (const p of catalog.pipelines) {
76
+ pipelines.push({ ...p, project_root: root });
77
+ }
78
+ }
79
+ catch {
80
+ // stale/unreadable project root recorded on an old run; skip it
81
+ }
82
+ }
83
+ return textResult({ pipelines });
34
84
  });
35
85
  server.registerTool("list_tasks", {
36
- description: "List manifest-declared task paths from the project catalog",
86
+ 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.",
87
+ inputSchema: z.object({}),
88
+ }, async () => {
89
+ const roots = await catalogRootsFor(deps);
90
+ const tasks = [];
91
+ for (const root of roots) {
92
+ try {
93
+ const catalog = await browseCatalog(root);
94
+ for (const t of catalog.tasks) {
95
+ tasks.push({ ...t, project_root: root });
96
+ }
97
+ }
98
+ catch {
99
+ // stale/unreadable project root recorded on an old run; skip it
100
+ }
101
+ }
102
+ return textResult({ tasks });
103
+ });
104
+ server.registerTool("list_models", {
105
+ description: "List catalog model ids from the project catalog (same source as GET /api/models)",
37
106
  inputSchema: z.object({}),
38
107
  }, async () => {
39
108
  const catalog = await browseCatalog(cwd);
40
- return textResult({ tasks: catalog.tasks });
109
+ return textResult({ models: catalog.models });
41
110
  });
42
111
  server.registerTool("list_runs", {
43
112
  description: "List known pipeline runs. Optional filters: status, since (ISO created_at lower bound), pipeline (id or path).",
@@ -62,21 +131,30 @@ export function registerCatalogTools(server, deps) {
62
131
  return textResult({ runs });
63
132
  });
64
133
  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.",
134
+ 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
135
  inputSchema: z.object({}),
67
- }, async () => textResult(manager.getHealth()));
136
+ }, async () => textResult({ ...manager.getHealth(), version: PACKAGE_VERSION }));
68
137
  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.",
138
+ 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
139
  inputSchema: startRunSchema,
71
140
  }, async ({ pipeline, task_path, task }) => {
72
- if (!pipeline.trim()) {
141
+ if (typeof pipeline === "string" && !pipeline.trim()) {
73
142
  return textResult({ error: "pipeline is required" }, true);
74
143
  }
75
144
  const taskInput = task_path ?? task;
76
145
  if (taskInput === undefined) {
77
146
  return textResult({ error: "Exactly one of task_path or task is required" }, true);
78
147
  }
79
- const result = await manager.startRun({ pipeline, task: taskInput });
148
+ let result;
149
+ try {
150
+ result = await manager.startRun({ pipeline, task: taskInput });
151
+ }
152
+ catch (err) {
153
+ if (err instanceof PipelineValidationError) {
154
+ return textResult({ error: "Pipeline validation failed", validation: err.result }, true);
155
+ }
156
+ throw err;
157
+ }
80
158
  if (!result.ok) {
81
159
  const { ok: _ok, reason, ...rest } = result;
82
160
  return textResult({ error: reason, ...rest }, true);
@@ -94,34 +172,39 @@ export function registerCatalogTools(server, deps) {
94
172
  return textResult(projectRunForMcp(detail));
95
173
  }
96
174
  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);
175
+ const mapped = mapStoreLookupError(err, { policy: "run" });
176
+ return textResult({ error: mapped.error, status: mapped.status }, true);
103
177
  }
104
178
  });
105
179
  server.registerTool("read_artifact", {
106
- description: "Read a text artifact from a run workspace by relative path (contained under the run workspace).",
180
+ 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
181
  inputSchema: z.object({
108
182
  runId: z.string(),
109
183
  path: z.string(),
110
184
  }),
111
185
  }, async ({ runId, path: artifactPath }) => {
112
186
  try {
113
- const content = await readRunArtifact(store, runId, artifactPath);
114
- return textResult({ runId, path: artifactPath, content });
187
+ const bytes = await readRunArtifactBytes(store, runId, artifactPath);
188
+ const classified = classifyArtifactContent(artifactPath, bytes);
189
+ if (classified.kind === "image") {
190
+ return imageResult(classified.mimeType, bytes, {
191
+ runId,
192
+ path: artifactPath,
193
+ mimeType: classified.mimeType,
194
+ });
195
+ }
196
+ if (classified.kind === "utf8") {
197
+ return textResult({
198
+ runId,
199
+ path: artifactPath,
200
+ content: bytes.toString("utf8"),
201
+ });
202
+ }
203
+ return textResult({ error: "Artifact is not valid UTF-8 text", status: 400 }, true);
115
204
  }
116
205
  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);
206
+ const mapped = mapStoreLookupError(err, { policy: "artifact" });
207
+ return textResult({ error: mapped.error, status: mapped.status }, true);
125
208
  }
126
209
  });
127
210
  server.registerTool("validate", {
@@ -138,11 +221,36 @@ export function registerCatalogTools(server, deps) {
138
221
  scope = "pipeline";
139
222
  else if (task !== undefined && task.trim())
140
223
  scope = "task";
224
+ if (scope === "full") {
225
+ const roots = await catalogRootsFor(deps);
226
+ const results = [];
227
+ for (const root of roots) {
228
+ try {
229
+ results.push(await validateCatalog({ cwd: root, scope: "full", strict: strict ?? false }));
230
+ }
231
+ catch {
232
+ // stale/unreadable project root recorded on an old run; skip it
233
+ }
234
+ }
235
+ const merged = {
236
+ scope: "full",
237
+ ok: results.every((r) => r.ok),
238
+ summary: {
239
+ errors: results.reduce((n, r) => n + r.summary.errors, 0),
240
+ warnings: results.reduce((n, r) => n + r.summary.warnings, 0),
241
+ },
242
+ findings: results.flatMap((r) => r.findings),
243
+ };
244
+ return textResult(merged);
245
+ }
246
+ const target = scope === "pipeline" ? pipeline.trim() : task.trim();
247
+ const projectRoot = projectRootForPath(deps, target);
141
248
  const result = await validateCatalog({
142
- cwd,
249
+ cwd: projectRoot,
143
250
  scope,
144
- ...(scope === "pipeline" ? { pipeline: pipeline.trim() } : {}),
145
- ...(scope === "task" ? { task: task.trim() } : {}),
251
+ projectRoot,
252
+ ...(scope === "pipeline" ? { pipeline: target } : {}),
253
+ ...(scope === "task" ? { task: target } : {}),
146
254
  strict: strict ?? false,
147
255
  });
148
256
  return textResult(result);
@@ -153,7 +261,7 @@ export function registerCatalogTools(server, deps) {
153
261
  }
154
262
  });
155
263
  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).",
264
+ 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
265
  inputSchema: z.object({
158
266
  pipeline: z.string(),
159
267
  }),
@@ -162,24 +270,10 @@ export function registerCatalogTools(server, deps) {
162
270
  return textResult({ error: "pipeline is required", status: 400 }, true);
163
271
  }
164
272
  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,
273
+ const loaded = await loadPipeline(pipeline.trim(), {
274
+ cwd: projectRootForPath(deps, pipeline.trim()),
182
275
  });
276
+ return textResult(describePipeline(loaded));
183
277
  }
184
278
  catch (err) {
185
279
  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;
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ import { inspectProviderReadiness, mapProviderAuthError, } from "../agent/providerInspect.js";
3
+ import { textResult } from "./toolResults.js";
4
+ export function registerProviderTools(server, deps) {
5
+ server.registerTool("list_providers", {
6
+ description: "List login-capable model providers with auth readiness and a Pi-home detect summary. Read-only; does not log in or return secrets.",
7
+ inputSchema: z.object({}),
8
+ }, async () => {
9
+ try {
10
+ return textResult(await inspectProviderReadiness(deps.cwd, deps.providerAuthContext));
11
+ }
12
+ catch (err) {
13
+ const mapped = mapProviderAuthError(err);
14
+ return textResult({ error: mapped.body.error, status: mapped.status }, true);
15
+ }
16
+ });
17
+ }
@@ -1,4 +1,13 @@
1
1
  import type { RunStore } from "../runstore/port.js";
2
2
  export declare function artifactMediaType(relativePath: string): string | undefined;
3
+ export type ClassifiedArtifactContent = {
4
+ kind: "image";
5
+ mimeType: string;
6
+ } | {
7
+ kind: "utf8";
8
+ } | {
9
+ kind: "binary";
10
+ };
11
+ export declare function classifyArtifactContent(relativePath: string, bytes: Buffer): ClassifiedArtifactContent;
3
12
  export declare function readRunArtifact(store: RunStore, runId: string, relativePath: string): Promise<string>;
4
13
  export declare function readRunArtifactBytes(store: RunStore, runId: string, relativePath: string): Promise<Buffer>;
@@ -1,3 +1,4 @@
1
+ import { isUtf8 } from "node:buffer";
1
2
  import { readFile, realpath } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { isInsideDir } from "../runstore/workspaceLayout.js";
@@ -28,6 +29,16 @@ export function artifactMediaType(relativePath) {
28
29
  return undefined;
29
30
  }
30
31
  }
32
+ export function classifyArtifactContent(relativePath, bytes) {
33
+ const mimeType = artifactMediaType(relativePath);
34
+ if (mimeType !== undefined) {
35
+ return { kind: "image", mimeType };
36
+ }
37
+ if (isUtf8(bytes)) {
38
+ return { kind: "utf8" };
39
+ }
40
+ return { kind: "binary" };
41
+ }
31
42
  async function resolveRunArtifactFile(store, runId, relativePath) {
32
43
  assertSafeRunId(runId);
33
44
  if (typeof relativePath !== "string" || relativePath.trim().length === 0) {
@@ -34,7 +34,15 @@ function sessionIdFromRequest(req) {
34
34
  return undefined;
35
35
  }
36
36
  function toolDepsFrom(deps) {
37
- return { manager: deps.manager, store: deps.store, cwd: deps.cwd };
37
+ return {
38
+ manager: deps.manager,
39
+ store: deps.store,
40
+ cwd: deps.cwd,
41
+ ...(deps.providerAuthContext !== undefined
42
+ ? { providerAuthContext: deps.providerAuthContext }
43
+ : {}),
44
+ ...(deps.projectRoot !== undefined ? { projectRoot: deps.projectRoot } : {}),
45
+ };
38
46
  }
39
47
  function attachBusNotifications(server, bus) {
40
48
  return bus.on((event) => {
@@ -0,0 +1,8 @@
1
+ export type StreamLogTail = {
2
+ text: string;
3
+ nextOffset: number;
4
+ truncated?: boolean;
5
+ earliestOffset?: number;
6
+ };
7
+ /** Read new content from a stage's stream.log since a caller's last cursor. */
8
+ export declare function readStreamLogTail(streamLogPath: string, sinceOffset: number | undefined): Promise<StreamLogTail>;
@@ -0,0 +1,28 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parseStreamLogHeader } from "../runtime/stageStreamLog.js";
3
+ /** Read new content from a stage's stream.log since a caller's last cursor. */
4
+ export async function readStreamLogTail(streamLogPath, sinceOffset) {
5
+ let raw;
6
+ try {
7
+ raw = await readFile(streamLogPath);
8
+ }
9
+ catch {
10
+ return { text: "", nextOffset: 0 };
11
+ }
12
+ const { base, headerLength } = parseStreamLogHeader(raw);
13
+ const contentBytes = raw.subarray(headerLength);
14
+ const eof = base + contentBytes.length;
15
+ if (sinceOffset === undefined || sinceOffset < base) {
16
+ const truncated = sinceOffset !== undefined && sinceOffset < base;
17
+ return {
18
+ text: contentBytes.toString("utf8"),
19
+ nextOffset: eof,
20
+ ...(truncated ? { truncated: true, earliestOffset: base } : {}),
21
+ };
22
+ }
23
+ if (sinceOffset >= eof) {
24
+ return { text: "", nextOffset: eof };
25
+ }
26
+ const slice = contentBytes.subarray(sinceOffset - base);
27
+ return { text: slice.toString("utf8"), nextOffset: eof };
28
+ }
@@ -5,3 +5,20 @@ export declare function textResult(data: unknown, isError?: boolean): {
5
5
  }[];
6
6
  isError?: boolean | undefined;
7
7
  };
8
+ export declare function imageResult(mimeType: string, bytes: Buffer, identity: {
9
+ runId: string;
10
+ path: string;
11
+ mimeType: string;
12
+ }): {
13
+ content: ({
14
+ type: "image";
15
+ mimeType: string;
16
+ data: string;
17
+ text?: undefined;
18
+ } | {
19
+ mimeType?: undefined;
20
+ data?: undefined;
21
+ type: "text";
22
+ text: string;
23
+ })[];
24
+ };
@@ -4,3 +4,18 @@ export function textResult(data, isError = false) {
4
4
  ...(isError ? { isError: true } : {}),
5
5
  };
6
6
  }
7
+ export function imageResult(mimeType, bytes, identity) {
8
+ return {
9
+ content: [
10
+ {
11
+ type: "image",
12
+ mimeType,
13
+ data: bytes.toString("base64"),
14
+ },
15
+ {
16
+ type: "text",
17
+ text: JSON.stringify(identity, null, 2),
18
+ },
19
+ ],
20
+ };
21
+ }