pi-subagents 0.58.0 → 0.59.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 (105) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/docs/agents.md +5 -3
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +1 -1
  5. package/docs/models.md +24 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +89 -4
  8. package/docs/workflows.md +93 -2
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  11. package/skills/council-mode/SKILL.md +1 -1
  12. package/skills/pi-subagents/SKILL.md +1 -1
  13. package/skills/pi-subagents/references/execution-controls.md +5 -1
  14. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -2
  15. package/skills/pi-subagents/references/prompting-and-roles.md +24 -3
  16. package/src/agents/agent-management.ts +5 -17
  17. package/src/agents/agent-serializer.ts +4 -2
  18. package/src/agents/agents.ts +67 -26
  19. package/src/agents/runtime-agent-registry.ts +9 -16
  20. package/src/api/background-work.ts +5 -1
  21. package/src/api/delegation.ts +0 -7
  22. package/src/api/preflight.ts +6 -9
  23. package/src/extension/fanout-child.ts +5 -3
  24. package/src/extension/index.ts +51 -23
  25. package/src/extension/public-execution.ts +15 -2
  26. package/src/extension/schemas.ts +35 -10
  27. package/src/extension/tool-description.ts +18 -3
  28. package/src/intercom/result-intercom.ts +2 -0
  29. package/src/profiles/profiles.ts +5 -6
  30. package/src/runs/background/active-async-capacity.ts +2 -2
  31. package/src/runs/background/async-execution.ts +50 -35
  32. package/src/runs/background/async-job-tracker.ts +14 -12
  33. package/src/runs/background/async-resume.ts +23 -25
  34. package/src/runs/background/async-status-snapshot.ts +23 -261
  35. package/src/runs/background/async-status.ts +66 -7
  36. package/src/runs/background/chain-append.ts +6 -3
  37. package/src/runs/background/chain-root-attachment.ts +60 -8
  38. package/src/runs/background/fleet-view.ts +21 -11
  39. package/src/runs/background/notify.ts +158 -6
  40. package/src/runs/background/result-files.ts +2 -1
  41. package/src/runs/background/result-watcher.ts +2 -0
  42. package/src/runs/background/resume-guidance.ts +1 -1
  43. package/src/runs/background/retained-children.ts +1 -1
  44. package/src/runs/background/run-status.ts +18 -8
  45. package/src/runs/background/scheduled-runs.ts +86 -7
  46. package/src/runs/background/stale-run-reconciler.ts +10 -4
  47. package/src/runs/background/steering.ts +4 -14
  48. package/src/runs/background/subagent-runner.ts +346 -357
  49. package/src/runs/background/subagent-wait.ts +58 -10
  50. package/src/runs/background/terminal-run-index.ts +1 -1
  51. package/src/runs/background/wait-completions.ts +22 -1
  52. package/src/runs/background/wait-config.ts +23 -9
  53. package/src/runs/background/wait-tool.ts +9 -2
  54. package/src/runs/foreground/async-steering-action.ts +2 -2
  55. package/src/runs/foreground/execution.ts +114 -120
  56. package/src/runs/foreground/foreground-control.ts +3 -0
  57. package/src/runs/foreground/foreground-history.ts +1 -0
  58. package/src/runs/foreground/subagent-executor.ts +478 -239
  59. package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
  60. package/src/runs/shared/abort-recovery.ts +119 -0
  61. package/src/runs/shared/async-status-projection.ts +463 -0
  62. package/src/runs/shared/child-identity.ts +19 -4
  63. package/src/runs/shared/child-launch-plan.ts +151 -0
  64. package/src/runs/shared/completion-evidence.ts +89 -0
  65. package/src/runs/shared/completion-guard.ts +1 -1
  66. package/src/runs/shared/dynamic-fanout.ts +2 -2
  67. package/src/runs/shared/host-step-status.ts +230 -0
  68. package/src/runs/shared/lane-metadata.ts +105 -0
  69. package/src/runs/shared/mcp-config-sources.ts +42 -6
  70. package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
  71. package/src/runs/shared/mcp-direct-tool-grant.ts +197 -0
  72. package/src/runs/shared/model-fallback.ts +9 -2
  73. package/src/runs/shared/nested-events.ts +6 -2
  74. package/src/runs/shared/nested-render.ts +7 -3
  75. package/src/runs/shared/parallel-handoff.ts +419 -7
  76. package/src/runs/shared/parallel-utils.ts +7 -0
  77. package/src/runs/shared/pi-args.ts +20 -2
  78. package/src/runs/shared/single-output.ts +27 -4
  79. package/src/runs/shared/subagent-prompt-runtime.ts +13 -4
  80. package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
  81. package/src/runs/shared/worktree.ts +18 -0
  82. package/src/shared/child-session-name.ts +46 -0
  83. package/src/shared/extension-context.ts +24 -0
  84. package/src/shared/formatters.ts +5 -2
  85. package/src/shared/launch-contract.ts +1 -1
  86. package/src/shared/settings.ts +9 -103
  87. package/src/shared/types.ts +198 -29
  88. package/src/shared/utils.ts +35 -55
  89. package/src/slash/delegation-adapters.ts +1 -8
  90. package/src/slash/delegation-request.ts +0 -4
  91. package/src/slash/slash-bridge.ts +1 -2
  92. package/src/slash/slash-commands.ts +369 -90
  93. package/src/slash/slash-live-state.ts +22 -11
  94. package/src/tui/fleet-status.ts +125 -74
  95. package/src/tui/fleet.ts +11 -5
  96. package/src/tui/render.ts +316 -27
  97. package/src/watchdog/turn-delta.ts +1 -1
  98. package/src/workflows/chat-progress.ts +6 -3
  99. package/src/workflows/host-command.ts +230 -0
  100. package/src/workflows/scripted-workflow.ts +452 -38
  101. package/src/workflows/workflow-child-summary.ts +9 -5
  102. package/src/workflows/workflow-preflight.ts +270 -0
  103. package/src/workflows/workflow-receipt.ts +43 -4
  104. package/src/workflows/workflow-settlement.ts +246 -0
  105. package/src/runs/shared/turn-budget.ts +0 -98
@@ -0,0 +1,89 @@
1
+ import type {
2
+ FileMutationEffect,
3
+ SettlementDiagnostic,
4
+ TrackedMutationEvidence,
5
+ } from "../../shared/types.ts";
6
+ import type { CompletionMutationGuardResult } from "./completion-guard.ts";
7
+
8
+ export const MISSING_IMPLEMENTATION_MUTATION_MESSAGE = "Subagent completed without making edits for an implementation task.";
9
+ export const MISSING_IMPLEMENTATION_MUTATION_ERROR = `${MISSING_IMPLEMENTATION_MUTATION_MESSAGE}\nIt appears to have returned planning or scratchpad output instead of applying changes.`;
10
+
11
+ export interface CompletionEvidencePlan {
12
+ guardTriggered: boolean;
13
+ guardBlocked: boolean;
14
+ mutationExpected: boolean;
15
+ mutationAttempted: boolean;
16
+ fileMutation?: FileMutationEffect;
17
+ legacyFailureError?: string;
18
+ }
19
+
20
+ export function planCompletionEvidence(input: {
21
+ guard?: CompletionMutationGuardResult;
22
+ guardTriggered?: boolean;
23
+ completionGuardEnabled: boolean;
24
+ mutationCapable: boolean;
25
+ implementationMutationExpected: boolean;
26
+ mutationAttemptObserved: boolean;
27
+ mutationEvidence?: TrackedMutationEvidence;
28
+ arbiterRescued?: boolean;
29
+ agentContractV1: boolean;
30
+ }): CompletionEvidencePlan {
31
+ const guardBlocked = input.guard?.blocked === true;
32
+ const guardTriggered = input.guardTriggered
33
+ ?? (input.guard?.triggered === true && !input.mutationAttemptObserved);
34
+ const mutationExpected = input.guard?.expectedMutation
35
+ ?? (input.completionGuardEnabled && input.mutationCapable && input.implementationMutationExpected);
36
+ const mutationAttempted = input.guard?.attemptedMutation === true || input.mutationAttemptObserved;
37
+ const fileMutation = input.guard
38
+ ? {
39
+ status: guardBlocked
40
+ ? "blocked" as const
41
+ : input.guard.expectedMutation
42
+ ? guardTriggered
43
+ ? "missing" as const
44
+ : input.arbiterRescued
45
+ ? "not-applicable" as const
46
+ : "observed" as const
47
+ : "not-applicable" as const,
48
+ expected: input.guard.expectedMutation,
49
+ attempted: guardBlocked ? false : mutationAttempted,
50
+ ...(input.mutationEvidence ? { evidence: input.mutationEvidence } : {}),
51
+ ...(guardBlocked && input.guard.message ? { message: input.guard.message } : {}),
52
+ ...(guardTriggered ? { message: MISSING_IMPLEMENTATION_MUTATION_MESSAGE } : {}),
53
+ ...(input.arbiterRescued ? { resolvedBy: "llm-intent-arbiter" as const } : {}),
54
+ }
55
+ : undefined;
56
+ return {
57
+ guardTriggered,
58
+ guardBlocked,
59
+ mutationExpected,
60
+ mutationAttempted,
61
+ fileMutation,
62
+ legacyFailureError: guardTriggered && !input.agentContractV1
63
+ ? MISSING_IMPLEMENTATION_MUTATION_ERROR
64
+ : undefined,
65
+ };
66
+ }
67
+
68
+ export function projectSettlementDiagnostic(
69
+ plan: Pick<CompletionEvidencePlan, "guardTriggered" | "guardBlocked" | "mutationExpected" | "mutationAttempted">,
70
+ input: {
71
+ terminalFailed: boolean;
72
+ finalTextPresent: boolean;
73
+ mutationObserved: boolean;
74
+ requiredOutput?: SettlementDiagnostic["requiredOutput"];
75
+ afterCompactionSettlement?: boolean;
76
+ },
77
+ ): SettlementDiagnostic | undefined {
78
+ if (!input.terminalFailed && !plan.guardTriggered && !plan.guardBlocked) return undefined;
79
+ return {
80
+ finalTextPresent: input.finalTextPresent,
81
+ mutation: {
82
+ expected: plan.mutationExpected,
83
+ attempted: plan.mutationAttempted,
84
+ observed: input.mutationObserved,
85
+ },
86
+ ...(input.requiredOutput ? { requiredOutput: input.requiredOutput } : {}),
87
+ afterCompactionSettlement: input.afterCompactionSettlement === true,
88
+ };
89
+ }
@@ -52,7 +52,7 @@ interface CompletionMutationGuardInput {
52
52
  mutationEvidence?: TrackedMutationEvidence;
53
53
  }
54
54
 
55
- interface CompletionMutationGuardResult {
55
+ export interface CompletionMutationGuardResult {
56
56
  expectedMutation: boolean;
57
57
  attemptedMutation: boolean;
58
58
  triggered: boolean;
@@ -49,8 +49,8 @@ const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
49
49
  const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "fast", "toolBudget", "acceptance", "agentContract", "gateOn"]);
50
50
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
51
51
  ...DYNAMIC_PARALLEL_KEYS,
52
- "outputName", "structured", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "timeoutMs", "waitToolEnabled",
53
- "structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "capabilityCeiling", "completionGuard", "systemPrompt",
52
+ "outputName", "structured", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "timeoutMs", "waitToolEnabled", "waitToolDefaultTimeoutMs",
53
+ "structuredOutput", "structuredOutputSchema", "tools", "allowNestedSubagents", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "capabilityCeiling", "completionGuard", "systemPrompt",
54
54
  "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "parentSessionId", "launchResolvedExtensions", "requestedCwd",
55
55
  ]);
56
56
  const DYNAMIC_COLLECT_KEYS = new Set(["as", "outputSchema"]);
@@ -0,0 +1,230 @@
1
+ import type {
2
+ AsyncStatus,
3
+ HostStepNodeV1,
4
+ HostStepState,
5
+ HostStepVerdict,
6
+ WorkflowGraphNode,
7
+ WorkflowGraphSnapshot,
8
+ WorkflowNodeStatus,
9
+ } from "../../shared/types.ts";
10
+
11
+ export const HOST_STEP_MAX_ID_CHARS = 128;
12
+ export const HOST_STEP_MAX_LABEL_CHARS = 80;
13
+ export const HOST_STEP_MAX_ROLE_CHARS = 32;
14
+ export const HOST_STEP_MAX_PROVIDER_CHARS = 64;
15
+ export const HOST_STEP_MAX_REASON_CHARS = 64;
16
+ export const HOST_STEP_MAX_DETAIL_CHARS = 200;
17
+ export const HOST_STEP_MAX_TARGET_CHARS = 128;
18
+ export const HOST_STEP_MAX_REF_CHARS = 128;
19
+ export const HOST_STEP_MAX_REPORT_PATH_CHARS = 240;
20
+ export const HOST_STEP_MAX_COUNT = 32;
21
+
22
+ const HOST_STEP_FIELDS = new Set([
23
+ "version",
24
+ "kind",
25
+ "monitorKind",
26
+ "id",
27
+ "label",
28
+ "role",
29
+ "provider",
30
+ "state",
31
+ "verdict",
32
+ "reasonCode",
33
+ "detail",
34
+ "target",
35
+ "freshness",
36
+ "reportPath",
37
+ "exitCode",
38
+ "updatedAt",
39
+ "deadlineAt",
40
+ ]);
41
+
42
+ const FRESHNESS_FIELDS = new Set(["expectedRef", "observedRef", "stale"]);
43
+
44
+ function isRecord(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+
48
+ function assertBoundedString(value: unknown, field: string, maxChars: number, source: string, required = false): asserts value is string {
49
+ if (value === undefined && !required) return;
50
+ if (typeof value !== "string" || !value.trim()) throw new Error(`Invalid host step '${source}': ${field} must be a non-empty string.`);
51
+ if (value.length > maxChars) throw new Error(`Invalid host step '${source}': ${field} exceeds ${maxChars} characters.`);
52
+ if (/\r|\n/.test(value)) throw new Error(`Invalid host step '${source}': ${field} must be single-line.`);
53
+ }
54
+
55
+ function assertTimestamp(value: unknown, field: string, source: string, required = false): asserts value is number {
56
+ if (value === undefined && !required) return;
57
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid host step '${source}': ${field} must be a non-negative safe integer.`);
58
+ }
59
+
60
+ function assertFreshness(value: unknown, source: string): asserts value is HostStepNodeV1["freshness"] {
61
+ if (!isRecord(value)) throw new Error(`Invalid host step '${source}': freshness must be an object.`);
62
+ const unknownFields = Object.keys(value).filter((field) => !FRESHNESS_FIELDS.has(field));
63
+ if (unknownFields.length > 0) throw new Error(`Invalid host step '${source}': freshness has unsupported fields: ${unknownFields.join(", ")}.`);
64
+ assertBoundedString(value.expectedRef, "freshness.expectedRef", HOST_STEP_MAX_REF_CHARS, source, true);
65
+ assertBoundedString(value.observedRef, "freshness.observedRef", HOST_STEP_MAX_REF_CHARS, source);
66
+ if (value.stale !== undefined && typeof value.stale !== "boolean") throw new Error(`Invalid host step '${source}': freshness.stale must be a boolean.`);
67
+ }
68
+
69
+ /**
70
+ * Validate the persisted host-step contract. This deliberately rejects unknown
71
+ * fields and unbounded values so status and receipt loaders fail closed.
72
+ */
73
+ export function assertHostStepNode(value: unknown, source = "status"): asserts value is HostStepNodeV1 {
74
+ if (!isRecord(value)) throw new Error(`Invalid host step '${source}': expected an object.`);
75
+ const unknownFields = Object.keys(value).filter((field) => !HOST_STEP_FIELDS.has(field));
76
+ if (unknownFields.length > 0) throw new Error(`Invalid host step '${source}': unsupported fields: ${unknownFields.join(", ")}.`);
77
+ if (value.version !== 1) throw new Error(`Invalid host step '${source}': version must be 1.`);
78
+ if (value.kind !== "host-step") throw new Error(`Invalid host step '${source}': kind must be 'host-step'.`);
79
+ if (value.monitorKind !== "command" && value.monitorKind !== "ci" && value.monitorKind !== "gate") throw new Error(`Invalid host step '${source}': monitorKind must be 'command', 'ci', or 'gate'.`);
80
+ assertBoundedString(value.id, "id", HOST_STEP_MAX_ID_CHARS, source, true);
81
+ assertBoundedString(value.label, "label", HOST_STEP_MAX_LABEL_CHARS, source, true);
82
+ assertBoundedString(value.role, "role", HOST_STEP_MAX_ROLE_CHARS, source);
83
+ assertBoundedString(value.provider, "provider", HOST_STEP_MAX_PROVIDER_CHARS, source);
84
+ if (value.state !== "pending" && value.state !== "running" && value.state !== "done" && value.state !== "cancelled" && value.state !== "error") {
85
+ throw new Error(`Invalid host step '${source}': state is invalid.`);
86
+ }
87
+ if (value.verdict !== undefined && value.verdict !== "pass" && value.verdict !== "fail" && value.verdict !== "inconclusive") {
88
+ throw new Error(`Invalid host step '${source}': verdict is invalid.`);
89
+ }
90
+ if (value.state !== "done" && value.verdict !== undefined) throw new Error(`Invalid host step '${source}': verdict is only valid for done state.`);
91
+ assertBoundedString(value.reasonCode, "reasonCode", HOST_STEP_MAX_REASON_CHARS, source);
92
+ assertBoundedString(value.detail, "detail", HOST_STEP_MAX_DETAIL_CHARS, source);
93
+ assertBoundedString(value.target, "target", HOST_STEP_MAX_TARGET_CHARS, source);
94
+ if (value.freshness !== undefined) assertFreshness(value.freshness, source);
95
+ if (value.freshness && value.freshness.stale === true && (value.state !== "done" || value.verdict !== "inconclusive")) {
96
+ throw new Error(`Invalid host step '${source}': stale freshness requires done/inconclusive state.`);
97
+ }
98
+ assertBoundedString(value.reportPath, "reportPath", HOST_STEP_MAX_REPORT_PATH_CHARS, source);
99
+ const reportName = hostStepReportName(value.reportPath);
100
+ if (value.reportPath !== undefined && (!reportName || reportName === "." || reportName === "..")) throw new Error(`Invalid host step '${source}': reportPath must name a report.`);
101
+ if (value.exitCode !== undefined && value.exitCode !== null && (typeof value.exitCode !== "number" || !Number.isSafeInteger(value.exitCode) || value.exitCode < 0)) throw new Error(`Invalid host step '${source}': exitCode must be a non-negative safe integer or null.`);
102
+ if (value.monitorKind !== "command" && value.exitCode !== undefined) throw new Error(`Invalid host step '${source}': exitCode is only valid for command steps.`);
103
+ if ((value.state === "pending" || value.state === "running") && value.exitCode !== undefined) throw new Error(`Invalid host step '${source}': exitCode is only valid after command settlement.`);
104
+ assertTimestamp(value.updatedAt, "updatedAt", source, true);
105
+ assertTimestamp(value.deadlineAt, "deadlineAt", source);
106
+ }
107
+
108
+ export function parseHostStepNode(value: unknown, source = "status"): HostStepNodeV1 {
109
+ assertHostStepNode(value, source);
110
+ return {
111
+ ...value,
112
+ ...(value.freshness ? { freshness: { ...value.freshness } } : {}),
113
+ };
114
+ }
115
+
116
+ export function assertUniqueHostStepIds(hostSteps: readonly HostStepNodeV1[], source = "status"): void {
117
+ const ids = new Set<string>();
118
+ for (const hostStep of hostSteps) {
119
+ if (ids.has(hostStep.id)) throw new Error(`Invalid host step '${source}': duplicate host step id '${hostStep.id}'.`);
120
+ ids.add(hostStep.id);
121
+ }
122
+ }
123
+
124
+ /** Return only valid host nodes so an untrusted in-memory projection fails closed. */
125
+ export function validHostStepNodes(graph: WorkflowGraphSnapshot | undefined): HostStepNodeV1[] {
126
+ const nodes = graph?.nodes ?? [];
127
+ const nodeIdCounts = new Map<string, number>();
128
+ for (const node of nodes) nodeIdCounts.set(node.id, (nodeIdCounts.get(node.id) ?? 0) + 1);
129
+ const hostSteps: HostStepNodeV1[] = [];
130
+ for (const [index, node] of nodes.entries()) {
131
+ if (node.kind !== "host-step") continue;
132
+ try {
133
+ const hostStep = parseHostStepNode(node.hostStep, `workflowGraph.nodes[${index}].hostStep`);
134
+ if (node.id !== hostStep.id || node.label !== hostStep.label) continue;
135
+ if (nodeIdCounts.get(hostStep.id) !== 1) continue;
136
+ hostSteps.push(hostStep);
137
+ } catch {
138
+ // Renderers must not turn malformed host data into a fake child row.
139
+ }
140
+ }
141
+ return hostSteps.slice(0, HOST_STEP_MAX_COUNT);
142
+ }
143
+
144
+ export function assertWorkflowGraphHostSteps(graph: WorkflowGraphSnapshot | undefined, source = "status", expectedRunId?: string): void {
145
+ if (!graph) return;
146
+ if (expectedRunId !== undefined && graph.runId !== expectedRunId) throw new Error(`Invalid host step '${source}': workflowGraph.runId does not match the status run id.`);
147
+ const hostStepCount = graph.nodes.filter((node) => node.kind === "host-step").length;
148
+ if (hostStepCount > HOST_STEP_MAX_COUNT) throw new Error(`Invalid host step '${source}': workflowGraph contains more than ${HOST_STEP_MAX_COUNT} host steps.`);
149
+ const hostSteps: HostStepNodeV1[] = [];
150
+ for (const [index, node] of graph.nodes.entries()) {
151
+ if (node.kind !== "host-step") continue;
152
+ const hostStep = parseHostStepNode(node.hostStep, `${source}.workflowGraph.nodes[${index}].hostStep`);
153
+ if (node.id !== hostStep.id) throw new Error(`Invalid host step '${source}': workflowGraph.nodes[${index}].id does not match hostStep.id.`);
154
+ if (node.label !== hostStep.label) throw new Error(`Invalid host step '${source}': workflowGraph.nodes[${index}].label does not match hostStep.label.`);
155
+ hostSteps.push(hostStep);
156
+ }
157
+ assertUniqueHostStepIds(hostSteps, source);
158
+ for (const hostStep of hostSteps) {
159
+ if (graph.nodes.filter((node) => node.id === hostStep.id).length !== 1) throw new Error(`Invalid host step '${source}': workflowGraph id '${hostStep.id}' is not unique.`);
160
+ }
161
+ }
162
+
163
+ export function hostStepWorkflowNode(hostStep: HostStepNodeV1): WorkflowGraphNode {
164
+ assertHostStepNode(hostStep, hostStep.id);
165
+ return {
166
+ id: hostStep.id,
167
+ kind: "host-step",
168
+ label: hostStep.label,
169
+ status: workflowNodeStatus(hostStep),
170
+ hostStep: { ...hostStep, ...(hostStep.freshness ? { freshness: { ...hostStep.freshness } } : {}) },
171
+ };
172
+ }
173
+
174
+ function workflowNodeStatus(hostStep: HostStepNodeV1): WorkflowNodeStatus {
175
+ if (hostStep.state === "pending") return "pending";
176
+ if (hostStep.state === "running") return "running";
177
+ if (hostStep.state === "cancelled") return "stopped";
178
+ if (hostStep.state === "error") return "failed";
179
+ if (hostStep.verdict === undefined || hostStep.verdict === "inconclusive") return "partial";
180
+ return hostStep.verdict === "fail" ? "failed" : "completed";
181
+ }
182
+
183
+ /**
184
+ * Host-only registration/update boundary. The caller supplies the existing
185
+ * status writer; monitor providers never receive a path or write status.json.
186
+ */
187
+ export function upsertHostStep(input: {
188
+ status: AsyncStatus;
189
+ hostStep: HostStepNodeV1;
190
+ persist: (status: AsyncStatus) => void;
191
+ }): AsyncStatus {
192
+ const hostStep = parseHostStepNode(input.hostStep, input.hostStep.id);
193
+ assertWorkflowGraphHostSteps(input.status.workflowGraph, "host-step", input.status.runId);
194
+ const graph: WorkflowGraphSnapshot = input.status.workflowGraph
195
+ ? {
196
+ ...input.status.workflowGraph,
197
+ nodes: [...input.status.workflowGraph.nodes],
198
+ }
199
+ : {
200
+ runId: input.status.runId,
201
+ mode: input.status.mode,
202
+ phases: [],
203
+ nodes: [],
204
+ };
205
+ const existingIndex = graph.nodes.findIndex((node) => node.id === hostStep.id);
206
+ const existing = existingIndex >= 0 ? graph.nodes[existingIndex] : undefined;
207
+ if (existing && existing.kind !== "host-step") throw new Error(`Host step '${hostStep.id}' conflicts with an existing workflow node.`);
208
+ if (existingIndex < 0 && graph.nodes.filter((candidate) => candidate.kind === "host-step").length >= HOST_STEP_MAX_COUNT) {
209
+ throw new Error(`Host step limit of ${HOST_STEP_MAX_COUNT} has been reached.`);
210
+ }
211
+ const node = hostStepWorkflowNode(hostStep);
212
+ if (existingIndex >= 0) graph.nodes[existingIndex] = node;
213
+ else graph.nodes.push(node);
214
+ const nextStatus: AsyncStatus = {
215
+ ...input.status,
216
+ workflowGraph: graph,
217
+ lastUpdate: Math.max(input.status.lastUpdate ?? 0, hostStep.updatedAt),
218
+ };
219
+ input.persist(nextStatus);
220
+ return nextStatus;
221
+ }
222
+
223
+ export function hostStepVerdictLabel(state: HostStepState, verdict: HostStepVerdict | undefined): string {
224
+ return state === "done" ? verdict ?? "inconclusive" : state;
225
+ }
226
+
227
+ export function hostStepReportName(reportPath: string | undefined): string | undefined {
228
+ if (!reportPath) return undefined;
229
+ return reportPath.split(/[\\/]/).filter(Boolean).at(-1);
230
+ }
@@ -0,0 +1,105 @@
1
+ import type { AsyncStatus, WorkflowLaneMetadata, WorkflowLaneMode } from "../../shared/types.ts";
2
+
3
+ export const WORKFLOW_LANE_KEY_MAX_BYTES = 128;
4
+ export const WORKFLOW_LANE_SOURCE_REF_MAX_BYTES = 128;
5
+ export const WORKFLOW_LANE_CLAIM_MAX_BYTES = 160;
6
+ export const WORKFLOW_LANE_CLAIMS_MAX = 20;
7
+ export const WORKFLOW_LANE_OUTPUT_PATH_MAX_BYTES = 256;
8
+ export const WORKFLOW_LANE_OUTPUT_PATHS_MAX = 10;
9
+ export const WORKTREE_STATUS_PATH_MAX_BYTES = 4096;
10
+ export const WORKTREE_STATUS_BRANCH_MAX_BYTES = 256;
11
+
12
+ const WORKFLOW_LANE_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
13
+ const WORKFLOW_LANE_MODES = new Set<WorkflowLaneMode>(["mutation", "review", "scout", "gate"]);
14
+
15
+ function byteLength(value: string): number {
16
+ return Buffer.byteLength(value, "utf8");
17
+ }
18
+
19
+ function boundedNonEmptyString(value: unknown, label: string, maxBytes: number): string {
20
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a non-empty string.`);
21
+ const normalized = value.trim();
22
+ if (byteLength(normalized) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} UTF-8 bytes.`);
23
+ if ([...normalized].some((character) => character === "\n" || character === "\r" || character === "\u0000")) throw new Error(`${label} must not contain newlines or NUL bytes.`);
24
+ return normalized;
25
+ }
26
+
27
+ function assertPlainObject(value: unknown, label: string): asserts value is Record<string, unknown> {
28
+ if (!value || typeof value !== "object" || Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) {
29
+ throw new Error(`${label} must be a plain JSON object.`);
30
+ }
31
+ }
32
+
33
+ function assertKnownFields(value: Record<string, unknown>, fields: readonly string[], label: string): void {
34
+ const unknown = Object.keys(value).filter((field) => !fields.includes(field));
35
+ if (unknown.length > 0) throw new Error(`${label} has unsupported fields: ${unknown.join(", ")}.`);
36
+ }
37
+
38
+ function boundedStringArray(value: unknown, label: string, maxItems: number, maxBytes: number): string[] {
39
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
40
+ if (value.length > maxItems) throw new Error(`${label} supports at most ${maxItems} entries.`);
41
+ return value.map((entry, index) => {
42
+ if (!Object.hasOwn(value, index)) throw new Error(`${label} must not contain sparse entries.`);
43
+ return boundedNonEmptyString(entry, `${label}[${index}]`, maxBytes);
44
+ });
45
+ }
46
+
47
+ /** Normalize and validate launch-declared lane metadata. */
48
+ export function normalizeWorkflowLaneMetadata(value: unknown, label = "lane"): WorkflowLaneMetadata | undefined {
49
+ if (value === undefined) return undefined;
50
+ assertPlainObject(value, label);
51
+ assertKnownFields(value, ["version", "key", "mode", "sourceRef", "claims", "outputPaths"], label);
52
+ if (value.version !== 1) throw new Error(`${label}.version must be 1.`);
53
+ const key = boundedNonEmptyString(value.key, `${label}.key`, WORKFLOW_LANE_KEY_MAX_BYTES);
54
+ if (!WORKFLOW_LANE_KEY_PATTERN.test(key)) throw new Error(`${label}.key is invalid.`);
55
+ const mode = value.mode;
56
+ if (mode !== undefined && (typeof mode !== "string" || !WORKFLOW_LANE_MODES.has(mode as WorkflowLaneMode))) throw new Error(`${label}.mode is invalid.`);
57
+ const sourceRef = value.sourceRef === undefined ? undefined : boundedNonEmptyString(value.sourceRef, `${label}.sourceRef`, WORKFLOW_LANE_SOURCE_REF_MAX_BYTES);
58
+ const claims = value.claims === undefined ? undefined : boundedStringArray(value.claims, `${label}.claims`, WORKFLOW_LANE_CLAIMS_MAX, WORKFLOW_LANE_CLAIM_MAX_BYTES);
59
+ const outputPaths = value.outputPaths === undefined ? undefined : boundedStringArray(value.outputPaths, `${label}.outputPaths`, WORKFLOW_LANE_OUTPUT_PATHS_MAX, WORKFLOW_LANE_OUTPUT_PATH_MAX_BYTES);
60
+ return {
61
+ version: 1,
62
+ key,
63
+ ...(mode !== undefined ? { mode: mode as WorkflowLaneMode } : {}),
64
+ ...(sourceRef !== undefined ? { sourceRef } : {}),
65
+ ...(claims !== undefined ? { claims } : {}),
66
+ ...(outputPaths !== undefined ? { outputPaths } : {}),
67
+ };
68
+ }
69
+
70
+ export function assertWorkflowLaneKey(lane: WorkflowLaneMetadata | undefined, workflowKey: string | undefined, label = "lane"): void {
71
+ if (!lane || workflowKey === undefined) return;
72
+ if (lane.key !== workflowKey) throw new Error(`${label}.key '${lane.key}' does not match workflow key '${workflowKey}'.`);
73
+ }
74
+
75
+ export interface WorktreeStatusReference {
76
+ worktreePath: string;
77
+ branch: string;
78
+ }
79
+
80
+ /** Validate the display-only worktree fields copied into status.json. */
81
+ export function normalizeWorktreeStatusReference(value: unknown, label = "worktree status reference"): WorktreeStatusReference | undefined {
82
+ if (value === undefined) return undefined;
83
+ assertPlainObject(value, label);
84
+ assertKnownFields(value, ["worktreePath", "branch"], label);
85
+ return {
86
+ worktreePath: boundedNonEmptyString(value.worktreePath, `${label}.worktreePath`, WORKTREE_STATUS_PATH_MAX_BYTES),
87
+ branch: boundedNonEmptyString(value.branch, `${label}.branch`, WORKTREE_STATUS_BRANCH_MAX_BYTES),
88
+ };
89
+ }
90
+
91
+ /** Validate only the additive lane/worktree fields of a persisted async status. */
92
+ export function validateAsyncStatusLaneMetadata(status: Pick<AsyncStatus, "runId" | "workflowKey" | "lane" | "steps">, label = "async status"): void {
93
+ const lane = normalizeWorkflowLaneMetadata(status.lane, `${label}.lane`);
94
+ assertWorkflowLaneKey(lane, status.workflowKey, `${label}.lane`);
95
+ if (lane) status.lane = lane;
96
+ for (const [index, step] of (status.steps ?? []).entries()) {
97
+ const stepLane = normalizeWorkflowLaneMetadata(step.lane, `${label}.steps[${index}].lane`);
98
+ assertWorkflowLaneKey(stepLane, step.workflowKey, `${label}.steps[${index}].lane`);
99
+ if (stepLane) step.lane = stepLane;
100
+ const hasPath = step.worktreePath !== undefined;
101
+ const hasBranch = step.branch !== undefined;
102
+ if (hasPath !== hasBranch) throw new Error(`${label}.steps[${index}] must include both worktreePath and branch.`);
103
+ if (hasPath) normalizeWorktreeStatusReference({ worktreePath: step.worktreePath, branch: step.branch }, `${label}.steps[${index}]`);
104
+ }
105
+ }
@@ -39,6 +39,44 @@ export interface McpServerDefinition {
39
39
  literalEnv?: boolean;
40
40
  }
41
41
 
42
+ export function isMcpServerDefinition(value: unknown): value is McpServerDefinition {
43
+ if (!isRecord(value)) return false;
44
+ for (const field of ["command", "socket", "cwd", "url", "bearerToken", "bearerTokenEnv", "protocolVersion", "httpTransport", "pluginDataDir"] as const) {
45
+ if (value[field] !== undefined && typeof value[field] !== "string") return false;
46
+ }
47
+ for (const field of ["args", "includeTools", "excludeTools"] as const) {
48
+ if (value[field] !== undefined && !isStringArray(value[field])) return false;
49
+ }
50
+ for (const field of ["env", "headers"] as const) {
51
+ if (value[field] !== undefined && !isStringRecord(value[field])) return false;
52
+ }
53
+ for (const field of ["exposeResources", "literalEnv"] as const) {
54
+ if (value[field] !== undefined && typeof value[field] !== "boolean") return false;
55
+ }
56
+ if (value.requestHeadersCommand !== undefined && !isRequestHeadersCommand(value.requestHeadersCommand)) return false;
57
+ if (value.auth !== undefined && value.auth !== "oauth" && value.auth !== "bearer" && value.auth !== false) return false;
58
+ return value.directTools === undefined || typeof value.directTools === "boolean" || isStringArray(value.directTools);
59
+ }
60
+
61
+ function isRecord(value: unknown): value is Record<string, unknown> {
62
+ return value !== null && typeof value === "object" && !Array.isArray(value);
63
+ }
64
+
65
+ function isStringArray(value: unknown): value is string[] {
66
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
67
+ }
68
+
69
+ function isStringRecord(value: unknown): value is Record<string, string> {
70
+ return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
71
+ }
72
+
73
+ function isRequestHeadersCommand(value: unknown): value is NonNullable<McpServerDefinition["requestHeadersCommand"]> {
74
+ if (!isRecord(value) || typeof value.command !== "string") return false;
75
+ if (value.args !== undefined && !isStringArray(value.args)) return false;
76
+ if (value.env !== undefined && !isStringRecord(value.env)) return false;
77
+ return value.timeoutMs === undefined || (typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs));
78
+ }
79
+
42
80
  export function loadPackageMcpServers(cwd: string): Record<string, McpServerDefinition> {
43
81
  const servers: Record<string, McpServerDefinition> = {};
44
82
  const seen = new Set<string>();
@@ -64,11 +102,11 @@ export function loadPackageMcpServers(cwd: string): Record<string, McpServerDefi
64
102
  const rawServers = (config as { mcpServers?: unknown }).mcpServers;
65
103
  if (!rawServers || typeof rawServers !== "object" || Array.isArray(rawServers)) continue;
66
104
  for (const [serverName, definition] of Object.entries(rawServers)) {
67
- if (!definition || typeof definition !== "object" || Array.isArray(definition)) continue;
105
+ if (!isMcpServerDefinition(definition)) continue;
68
106
  const normalizedName = `${packagePrefix}__${formatName(serverName, "server")}`;
69
107
  if (seen.has(normalizedName)) continue;
70
108
  seen.add(normalizedName);
71
- servers[normalizedName] = definition as McpServerDefinition;
109
+ servers[normalizedName] = definition;
72
110
  }
73
111
  }
74
112
  }
@@ -90,7 +128,7 @@ export function loadAgentPluginMcpServers(paths: unknown, cwd: string): Record<s
90
128
  const rawServers = (config as { mcpServers: Record<string, unknown> }).mcpServers;
91
129
 
92
130
  for (const [serverName, rawDefinition] of Object.entries(rawServers)) {
93
- const definition = translatePluginServer(manifest.name, pluginRoot, serverName, rawDefinition);
131
+ const definition = translatePluginServer(manifest.name, pluginRoot, rawDefinition);
94
132
  if (!definition) continue;
95
133
  const normalizedName = `${formatName(manifest.name, "plugin")}__${formatName(serverName, "server")}`;
96
134
  if (Object.hasOwn(servers, normalizedName)) continue;
@@ -254,12 +292,11 @@ function isValidPluginConfig(value: unknown): value is { mcpServers: Record<stri
254
292
  function translatePluginServer(
255
293
  pluginName: string,
256
294
  pluginRoot: string,
257
- serverName: string,
258
295
  value: unknown,
259
296
  ): McpServerDefinition | undefined {
260
297
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
261
298
  const raw = value as Record<string, unknown>;
262
- if (raw.type === "stdio") return translatePluginStdioServer(pluginName, pluginRoot, serverName, raw);
299
+ if (raw.type === "stdio") return translatePluginStdioServer(pluginName, pluginRoot, raw);
263
300
  if (raw.type === "streamable-http" || raw.type === "sse") return translatePluginHttpServer(raw);
264
301
  return undefined;
265
302
  }
@@ -267,7 +304,6 @@ function translatePluginServer(
267
304
  function translatePluginStdioServer(
268
305
  pluginName: string,
269
306
  pluginRoot: string,
270
- serverName: string,
271
307
  raw: Record<string, unknown>,
272
308
  ): McpServerDefinition | undefined {
273
309
  if ([...Object.keys(raw)].some((key) => !PLUGIN_STDIO_FIELDS.has(key))) return undefined;