@loucompanion/forge-bridge 0.1.3-dev.f2b8822ce953 → 0.1.3-dev.fbe15023cdfe

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 (143) hide show
  1. package/README.md +78 -0
  2. package/dist/bridge/bridge.base.d.ts +6 -1
  3. package/dist/bridge/bridge.service.d.ts +60 -3
  4. package/dist/bridge/bridge.service.js +970 -24
  5. package/dist/bridge/bridge.types.d.ts +172 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.js +47 -1
  14. package/dist/cli/cli.service.d.ts +42 -2
  15. package/dist/cli/cli.service.js +351 -22
  16. package/dist/cli/local-config.d.ts +6 -0
  17. package/dist/cli/local-config.js +47 -3
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  20. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  22. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  23. package/dist/runtime/attempt-process-supervisor.js +350 -0
  24. package/dist/runtime/generated-output-reset.d.ts +10 -0
  25. package/dist/runtime/generated-output-reset.js +103 -0
  26. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  27. package/dist/runtime/host-listener-inspector.js +324 -0
  28. package/dist/runtime/host-port-broker.d.ts +76 -0
  29. package/dist/runtime/host-port-broker.js +333 -0
  30. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  31. package/dist/runtime/host-runtime-contract.js +700 -0
  32. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  33. package/dist/runtime/host-runtime-lease.js +1460 -0
  34. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  35. package/dist/runtime/host-runtime-promotion.js +159 -0
  36. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  37. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  38. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  40. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  41. package/dist/runtime/runtime-isolation-lease.js +158 -0
  42. package/dist/sandbox/command-runner.d.ts +4 -0
  43. package/dist/sandbox/command-runner.js +47 -0
  44. package/dist/sandbox/contract.d.ts +5 -0
  45. package/dist/sandbox/contract.js +276 -0
  46. package/dist/sandbox/event-budget.d.ts +11 -0
  47. package/dist/sandbox/event-budget.js +27 -0
  48. package/dist/sandbox/image-policy.d.ts +35 -0
  49. package/dist/sandbox/image-policy.js +247 -0
  50. package/dist/sandbox/index.d.ts +5 -0
  51. package/dist/sandbox/index.js +5 -0
  52. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  53. package/dist/sandbox/lease-watchdog.js +67 -0
  54. package/dist/sandbox/preflight.d.ts +54 -0
  55. package/dist/sandbox/preflight.js +335 -0
  56. package/dist/sandbox/process.d.ts +20 -0
  57. package/dist/sandbox/process.js +209 -0
  58. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  59. package/dist/sandbox/provider-auth/codex.js +212 -0
  60. package/dist/sandbox/repository-lock.d.ts +5 -0
  61. package/dist/sandbox/repository-lock.js +119 -0
  62. package/dist/sandbox/runtime.d.ts +297 -0
  63. package/dist/sandbox/runtime.js +2338 -0
  64. package/dist/sandbox/sandbox.types.d.ts +86 -0
  65. package/dist/sandbox/sandbox.types.js +1 -0
  66. package/dist/sandbox/smoke.d.ts +76 -0
  67. package/dist/sandbox/smoke.js +380 -0
  68. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  69. package/dist/sandbox/worker-supervisor.js +36 -0
  70. package/dist/sandbox/worker.d.ts +1 -0
  71. package/dist/sandbox/worker.js +225 -0
  72. package/dist/sandbox/workspace.service.d.ts +13 -0
  73. package/dist/sandbox/workspace.service.js +72 -0
  74. package/dist/session/managed-attempt-tools.d.ts +12 -0
  75. package/dist/session/managed-attempt-tools.js +561 -0
  76. package/dist/session/phase-completion.artifacts.js +32 -4
  77. package/dist/session/phase-completion.d.ts +4 -2
  78. package/dist/session/phase-completion.helpers.js +27 -3
  79. package/dist/session/phase-completion.js +12 -3
  80. package/dist/session/phase-completion.prompts.d.ts +3 -3
  81. package/dist/session/phase-completion.prompts.js +20 -6
  82. package/dist/session/phase-completion.runtime.d.ts +12 -1
  83. package/dist/session/phase-completion.runtime.js +129 -7
  84. package/dist/session/phase-completion.schemas.js +132 -5
  85. package/dist/session/phase-completion.validation.d.ts +4 -1
  86. package/dist/session/phase-completion.validation.js +106 -2
  87. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  88. package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
  89. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  90. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  91. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  92. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  93. package/dist/session/provider-cli/provider-cli.deterministic.js +66 -9
  94. package/dist/session/provider-cli/provider-cli.process.d.ts +17 -0
  95. package/dist/session/provider-cli/provider-cli.process.js +223 -14
  96. package/dist/session/provider-cli/provider-cli.prompts.d.ts +17 -2
  97. package/dist/session/provider-cli/provider-cli.prompts.js +226 -18
  98. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  99. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  100. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  101. package/dist/session/provider-cli/provider-cli.service.d.ts +19 -3
  102. package/dist/session/provider-cli/provider-cli.service.js +503 -98
  103. package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
  104. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  105. package/dist/session/provider-cli/provider-output-state.js +30 -2
  106. package/dist/session/session-redaction.d.ts +3 -0
  107. package/dist/session/session-redaction.js +23 -0
  108. package/dist/session/session.base.d.ts +6 -1
  109. package/dist/session/session.service.d.ts +8 -2
  110. package/dist/session/session.service.js +463 -58
  111. package/dist/session/session.types.d.ts +144 -1
  112. package/dist/session/session.types.js +279 -3
  113. package/dist/shared/path/path.js +3 -1
  114. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  115. package/dist/shared/process/host-process-recovery.js +78 -0
  116. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  117. package/dist/shared/process/host-process-supervisor.js +282 -0
  118. package/dist/shared/process/process.d.ts +1 -0
  119. package/dist/shared/process/process.js +1 -0
  120. package/dist/shared/process/windows-launch.d.ts +1 -1
  121. package/dist/shared/process/windows-launch.js +6 -3
  122. package/dist/shared/provider-capabilities.d.ts +1 -0
  123. package/dist/shared/provider-capabilities.js +3 -0
  124. package/dist/shared/redaction/redaction.js +10 -3
  125. package/dist/version.d.ts +1 -1
  126. package/dist/version.js +1 -1
  127. package/dist/worktree/artifact-injector.d.ts +3 -2
  128. package/dist/worktree/artifact-injector.js +43 -4
  129. package/dist/worktree/dependency-warmer.d.ts +21 -1
  130. package/dist/worktree/dependency-warmer.js +138 -5
  131. package/dist/worktree/index.d.ts +1 -0
  132. package/dist/worktree/index.js +1 -0
  133. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  134. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  135. package/dist/worktree/worktree-paths.d.ts +4 -0
  136. package/dist/worktree/worktree-paths.js +30 -0
  137. package/dist/worktree/worktree.base.d.ts +3 -0
  138. package/dist/worktree/worktree.service.d.ts +7 -2
  139. package/dist/worktree/worktree.service.js +72 -44
  140. package/dist/worktree/worktree.types.d.ts +3 -1
  141. package/package.json +6 -3
  142. package/runtime/windows-job-supervisor.ps1 +356 -0
  143. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -0,0 +1,561 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { resetGeneratedOutputs } from "../runtime/generated-output-reset.js";
5
+ import { StreamingProviderProcessRunner } from "./provider-cli/provider-cli.process.js";
6
+ const managedCommandNames = new Set(["forge.run_managed_command", "run_managed_command"]);
7
+ const startManagedCommandNames = new Set(["forge.start_managed_command", "start_managed_command"]);
8
+ const getManagedCommandNames = new Set(["forge.get_managed_command", "get_managed_command"]);
9
+ const waitManagedCommandNames = new Set(["forge.wait_managed_command", "wait_managed_command"]);
10
+ const resetGeneratedOutputNames = new Set(["forge.reset_generated_outputs", "reset_generated_outputs"]);
11
+ const maximumCapturedCharacters = 200_000;
12
+ const maximumReturnedOutputCharacters = 12_000;
13
+ const maximumReportedFailedTests = 50;
14
+ const maximumSynchronousWaitMs = 240_000;
15
+ const managedCommandLaunchTimeoutMs = 60_000;
16
+ const managedCommandProgressPersistIntervalMs = 5_000;
17
+ const managedCommandReceiptPollIntervalMs = 1_000;
18
+ const managedCommandReceiptReplaceAttempts = 40;
19
+ const managedCommandReceiptReplaceRetryMs = 25;
20
+ const terminalManagedCommandStatuses = new Set([
21
+ "pass",
22
+ "fail",
23
+ "timed_out",
24
+ "timeout_termination_failed",
25
+ "runner_error",
26
+ ]);
27
+ const managedCommandTasks = new Map();
28
+ export const managedAttemptToolNames = [
29
+ "forge.run_managed_command",
30
+ "run_managed_command",
31
+ "forge.start_managed_command",
32
+ "start_managed_command",
33
+ "forge.get_managed_command",
34
+ "get_managed_command",
35
+ "forge.wait_managed_command",
36
+ "wait_managed_command",
37
+ "forge.reset_generated_outputs",
38
+ "reset_generated_outputs",
39
+ ];
40
+ export function isManagedAttemptTool(name) {
41
+ return managedCommandNames.has(name)
42
+ || startManagedCommandNames.has(name)
43
+ || getManagedCommandNames.has(name)
44
+ || waitManagedCommandNames.has(name)
45
+ || resetGeneratedOutputNames.has(name);
46
+ }
47
+ export function managedAttemptToolDefinition(name) {
48
+ if (managedCommandNames.has(name) || startManagedCommandNames.has(name)) {
49
+ return {
50
+ name,
51
+ description: startManagedCommandNames.has(name)
52
+ ? "Start a long command in an attempt-owned process tree and return a durable command ID immediately. Continue independent work, then call forge.wait_managed_command once for its terminal receipt."
53
+ : "Run a short command in an attempt-owned process tree. Commands with a timeout above four minutes automatically start asynchronously; continue independent work, then call forge.wait_managed_command once for the durable result.",
54
+ inputSchema: {
55
+ type: "object",
56
+ additionalProperties: false,
57
+ required: ["command", "args"],
58
+ properties: {
59
+ command: { type: "string", minLength: 1 },
60
+ args: { type: "array", items: { type: "string" } },
61
+ cwd: { type: "string", description: "Worktree-relative directory; defaults to the worktree root." },
62
+ timeout_ms: { type: "integer", minimum: 1000, maximum: 7200000, default: 2700000 },
63
+ },
64
+ },
65
+ };
66
+ }
67
+ if (getManagedCommandNames.has(name)) {
68
+ return {
69
+ name,
70
+ description: "Read the durable status and final receipt for a command started by Forge's managed command runner.",
71
+ inputSchema: {
72
+ type: "object",
73
+ additionalProperties: false,
74
+ required: ["command_id"],
75
+ properties: {
76
+ command_id: { type: "string", minLength: 1 },
77
+ },
78
+ },
79
+ };
80
+ }
81
+ if (waitManagedCommandNames.has(name)) {
82
+ return {
83
+ name,
84
+ description: "Wait for a command started by Forge's managed command runner to reach a terminal state. Use this once after completing work that is independent of the command; Forge streams durable progress while this call waits.",
85
+ inputSchema: {
86
+ type: "object",
87
+ additionalProperties: false,
88
+ required: ["command_id"],
89
+ properties: {
90
+ command_id: { type: "string", minLength: 1 },
91
+ wait_timeout_ms: { type: "integer", minimum: 1000, maximum: 7200000, default: 7200000 },
92
+ },
93
+ },
94
+ };
95
+ }
96
+ return {
97
+ name,
98
+ description: "Quarantine known untracked generated test-output directories into this attempt's private output directory.",
99
+ inputSchema: { type: "object", additionalProperties: false, properties: {} },
100
+ };
101
+ }
102
+ export async function callManagedAttemptTool(name, input, worktreePath) {
103
+ if (resetGeneratedOutputNames.has(name)) {
104
+ const outputDirectory = requiredAttemptEnvironment("FORGE_TEST_OUTPUT_DIR");
105
+ const result = await resetGeneratedOutputs(worktreePath, outputDirectory);
106
+ return {
107
+ content: [{
108
+ type: "text",
109
+ text: result.quarantinedPaths.length === 0
110
+ ? "Forge found no stale generated output in the source worktree."
111
+ : `Forge quarantined ${result.quarantinedPaths.length} generated-output director${result.quarantinedPaths.length === 1 ? "y" : "ies"}: ${result.quarantinedPaths.join(", ")}.`,
112
+ }],
113
+ structuredContent: {
114
+ status: "pass",
115
+ quarantined_paths: result.quarantinedPaths,
116
+ quarantine_directory: result.quarantineDirectory,
117
+ },
118
+ };
119
+ }
120
+ if (getManagedCommandNames.has(name)) {
121
+ return await readManagedCommand(input);
122
+ }
123
+ if (waitManagedCommandNames.has(name)) {
124
+ return await waitManagedCommand(input);
125
+ }
126
+ if (!managedCommandNames.has(name) && !startManagedCommandNames.has(name)) {
127
+ throw new Error(`Unknown managed attempt tool: ${name}`);
128
+ }
129
+ const record = asRecord(input);
130
+ const command = requiredInputString(record, "command");
131
+ const args = requiredStringArray(record, "args");
132
+ const timeoutMs = optionalTimeout(record.timeout_ms);
133
+ const cwd = await containedWorkingDirectory(worktreePath, record.cwd);
134
+ const logDirectory = requiredAttemptEnvironment("FORGE_LOG_DIR");
135
+ await fs.mkdir(path.join(logDirectory, "managed-commands"), { recursive: true, mode: 0o700 });
136
+ const commandId = randomUUID();
137
+ const metadataPath = path.join(logDirectory, "managed-commands", `${commandId}.json`);
138
+ const receiptPath = managedCommandReceiptPath(logDirectory, commandId);
139
+ const invocation = {
140
+ commandId,
141
+ command,
142
+ args,
143
+ cwd,
144
+ timeoutMs,
145
+ logDirectory,
146
+ metadataPath,
147
+ receiptPath,
148
+ startedAt: Date.now(),
149
+ };
150
+ if (startManagedCommandNames.has(name) || timeoutMs > maximumSynchronousWaitMs) {
151
+ const started = managedCommandResult({
152
+ ...invocation,
153
+ timedOut: false,
154
+ status: "starting",
155
+ exitCode: null,
156
+ stdout: "",
157
+ stderr: "",
158
+ });
159
+ await persistManagedCommandReceipt(receiptPath, started.structuredContent);
160
+ const task = executeManagedCommand(invocation);
161
+ managedCommandTasks.set(commandId, task);
162
+ void task.finally(() => managedCommandTasks.delete(commandId)).catch(() => undefined);
163
+ return {
164
+ ...started,
165
+ content: [{
166
+ type: "text",
167
+ text: `Forge managed command ${commandId} started asynchronously. Continue independent work, then call forge.wait_managed_command once with this command_id for the terminal receipt.`,
168
+ }],
169
+ };
170
+ }
171
+ return await executeManagedCommand(invocation);
172
+ }
173
+ async function executeManagedCommand(invocation) {
174
+ const environment = inheritedEnvironment({
175
+ FORGE_RUNTIME_MODE: "host",
176
+ FORGE_RESOURCE_LEASE_ID: requiredAttemptEnvironment("FORGE_RESOURCE_LEASE_ID"),
177
+ FORGE_DISPATCH_ATTEMPT_ID: requiredAttemptEnvironment("FORGE_DISPATCH_ATTEMPT_ID"),
178
+ // This nested command owns a distinct supervisor identity. A command-local
179
+ // fence avoids exposing the parent attempt's fencing capability to Codex's
180
+ // MCP subprocess configuration while preserving exact-tree cleanup proof.
181
+ FORGE_FENCING_TOKEN: randomUUID(),
182
+ FORGE_LOG_DIR: invocation.logDirectory,
183
+ FORGE_TEST_OUTPUT_DIR: requiredAttemptEnvironment("FORGE_TEST_OUTPUT_DIR"),
184
+ FORGE_PROCESS_SUPERVISOR_ID: `managed-${invocation.commandId.replaceAll("-", "")}`,
185
+ FORGE_PROCESS_SUPERVISOR_METADATA_PATH: invocation.metadataPath,
186
+ });
187
+ let stdout = "";
188
+ let stderr = "";
189
+ const controller = new AbortController();
190
+ let timedOut = false;
191
+ let timer = null;
192
+ let outputRevision = 0;
193
+ let lastOutputAt = null;
194
+ let lastProgressPersistedAt = 0;
195
+ let progressPersistence = Promise.resolve();
196
+ const launchTimer = setTimeout(() => {
197
+ timedOut = true;
198
+ controller.abort(new Error(`managed_command_launch_timeout: process supervisor was not ready within ${managedCommandLaunchTimeoutMs}ms.`));
199
+ }, managedCommandLaunchTimeoutMs);
200
+ try {
201
+ const result = await new StreamingProviderProcessRunner().run({
202
+ command: invocation.command,
203
+ args: invocation.args,
204
+ cwd: invocation.cwd,
205
+ env: environment,
206
+ captureOutput: false,
207
+ }, {
208
+ onStarted: async () => {
209
+ clearTimeout(launchTimer);
210
+ const running = managedCommandResult({
211
+ ...invocation,
212
+ timedOut: false,
213
+ status: "running",
214
+ exitCode: null,
215
+ stdout,
216
+ stderr,
217
+ outputRevision,
218
+ lastOutputAt,
219
+ });
220
+ await persistManagedCommandReceipt(invocation.receiptPath, running.structuredContent);
221
+ timer = setTimeout(() => {
222
+ timedOut = true;
223
+ controller.abort(new Error(`managed_command_timeout: exceeded ${invocation.timeoutMs}ms.`));
224
+ }, invocation.timeoutMs);
225
+ },
226
+ onStdoutLine: (line) => {
227
+ stdout = appendBounded(stdout, line);
228
+ outputRevision += 1;
229
+ lastOutputAt = Date.now();
230
+ progressPersistence = persistManagedCommandProgressIfDue(invocation, () => ({ stdout, stderr, outputRevision, lastOutputAt }), progressPersistence, () => lastProgressPersistedAt, value => { lastProgressPersistedAt = value; });
231
+ return progressPersistence;
232
+ },
233
+ onStderrLine: (line) => {
234
+ stderr = appendBounded(stderr, line);
235
+ outputRevision += 1;
236
+ lastOutputAt = Date.now();
237
+ progressPersistence = persistManagedCommandProgressIfDue(invocation, () => ({ stdout, stderr, outputRevision, lastOutputAt }), progressPersistence, () => lastProgressPersistedAt, value => { lastProgressPersistedAt = value; });
238
+ return progressPersistence;
239
+ },
240
+ }, controller.signal);
241
+ await progressPersistence;
242
+ const status = timedOut ? "timed_out" : result.exitCode === 0 ? "pass" : "fail";
243
+ const receipt = managedCommandResult({
244
+ ...invocation,
245
+ timedOut,
246
+ status,
247
+ exitCode: result.exitCode,
248
+ stdout,
249
+ stderr,
250
+ outputRevision,
251
+ lastOutputAt,
252
+ });
253
+ await persistManagedCommandReceipt(invocation.receiptPath, receipt.structuredContent);
254
+ return receipt;
255
+ }
256
+ catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ stderr = appendBounded(stderr, message);
259
+ const receipt = managedCommandResult({
260
+ ...invocation,
261
+ timedOut,
262
+ status: timedOut ? "timeout_termination_failed" : "runner_error",
263
+ exitCode: null,
264
+ stdout,
265
+ stderr,
266
+ outputRevision,
267
+ lastOutputAt,
268
+ });
269
+ await persistManagedCommandReceipt(invocation.receiptPath, receipt.structuredContent);
270
+ return receipt;
271
+ }
272
+ finally {
273
+ clearTimeout(launchTimer);
274
+ if (timer)
275
+ clearTimeout(timer);
276
+ }
277
+ }
278
+ async function readManagedCommand(input) {
279
+ const commandId = validatedCommandId(asRecord(input));
280
+ const receiptPath = managedCommandReceiptPath(requiredAttemptEnvironment("FORGE_LOG_DIR"), commandId);
281
+ let receipt;
282
+ try {
283
+ receipt = JSON.parse(await fs.readFile(receiptPath, "utf8"));
284
+ }
285
+ catch (error) {
286
+ if (error.code === "ENOENT") {
287
+ throw new Error(`managed_command_not_found: no durable receipt exists for ${commandId}.`, { cause: error });
288
+ }
289
+ throw error;
290
+ }
291
+ const status = typeof receipt.status === "string" ? receipt.status : "unknown";
292
+ const stdout = typeof receipt.stdout === "string" ? receipt.stdout : "";
293
+ const stderr = typeof receipt.stderr === "string" ? receipt.stderr : "";
294
+ return {
295
+ content: [{
296
+ type: "text",
297
+ text: terminalManagedCommandStatuses.has(status)
298
+ ? terminalManagedCommandText({
299
+ commandId,
300
+ status,
301
+ exitCode: typeof receipt.exit_code === "number" ? receipt.exit_code : null,
302
+ elapsedMs: typeof receipt.elapsed_ms === "number" ? receipt.elapsed_ms : null,
303
+ stdout,
304
+ stderr,
305
+ })
306
+ : `Forge managed command ${commandId} is ${status}. Continue independent work and use forge.wait_managed_command when ready for its terminal receipt.`,
307
+ }],
308
+ structuredContent: receipt,
309
+ isError: terminalManagedCommandStatuses.has(status) && status !== "pass",
310
+ };
311
+ }
312
+ async function waitManagedCommand(input) {
313
+ const record = asRecord(input);
314
+ const commandId = validatedCommandId(record);
315
+ const waitTimeoutMs = optionalWaitTimeout(record.wait_timeout_ms);
316
+ const deadline = Date.now() + waitTimeoutMs;
317
+ const receiptPath = managedCommandReceiptPath(requiredAttemptEnvironment("FORGE_LOG_DIR"), commandId);
318
+ const task = managedCommandTasks.get(commandId);
319
+ if (task) {
320
+ const result = await Promise.race([
321
+ task,
322
+ delay(waitTimeoutMs).then(() => null),
323
+ ]);
324
+ if (result)
325
+ return result;
326
+ }
327
+ while (Date.now() < deadline) {
328
+ const result = await readManagedCommand({ command_id: commandId });
329
+ if (terminalManagedCommandStatuses.has(String(result.structuredContent.status))) {
330
+ return result;
331
+ }
332
+ await delay(Math.min(managedCommandReceiptPollIntervalMs, Math.max(1, deadline - Date.now())));
333
+ }
334
+ const receipt = await readManagedCommand({ command_id: commandId });
335
+ return {
336
+ ...receipt,
337
+ content: [{
338
+ type: "text",
339
+ text: `Forge is still supervising managed command ${commandId} after waiting ${waitTimeoutMs}ms. Its durable receipt remains at ${receiptPath}.`,
340
+ }],
341
+ };
342
+ }
343
+ function managedCommandReceiptPath(logDirectory, commandId) {
344
+ return path.join(logDirectory, "managed-commands", `${commandId}.receipt.json`);
345
+ }
346
+ async function persistManagedCommandReceipt(receiptPath, receipt) {
347
+ const temporaryPath = `${receiptPath}.${randomUUID()}.tmp`;
348
+ await fs.writeFile(temporaryPath, `${JSON.stringify(receipt, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
349
+ try {
350
+ for (let attempt = 1;; attempt += 1) {
351
+ try {
352
+ await fs.rename(temporaryPath, receiptPath);
353
+ break;
354
+ }
355
+ catch (error) {
356
+ const code = error.code;
357
+ if (attempt >= managedCommandReceiptReplaceAttempts
358
+ || (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES")) {
359
+ throw error;
360
+ }
361
+ await delay(managedCommandReceiptReplaceRetryMs);
362
+ }
363
+ }
364
+ }
365
+ finally {
366
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
367
+ }
368
+ }
369
+ function managedCommandResult(values) {
370
+ const elapsedMs = Date.now() - values.startedAt;
371
+ const isTerminal = terminalManagedCommandStatuses.has(values.status);
372
+ const diagnostics = isTerminal
373
+ ? managedCommandDiagnostics(values.stdout, values.stderr)
374
+ : undefined;
375
+ return {
376
+ content: [{
377
+ type: "text",
378
+ text: isTerminal
379
+ ? terminalManagedCommandText({
380
+ commandId: values.commandId,
381
+ status: values.status,
382
+ exitCode: values.exitCode,
383
+ elapsedMs,
384
+ stdout: values.stdout,
385
+ stderr: values.stderr,
386
+ }, diagnostics)
387
+ : `Forge managed command ${values.commandId} is ${values.status} after ${elapsedMs}ms.`,
388
+ }],
389
+ structuredContent: {
390
+ command_id: values.commandId,
391
+ status: values.status,
392
+ command: [values.command, ...values.args],
393
+ cwd: values.cwd,
394
+ timeout_ms: values.timeoutMs,
395
+ elapsed_ms: elapsedMs,
396
+ started_at: new Date(values.startedAt).toISOString(),
397
+ timed_out: values.timedOut,
398
+ exit_code: values.exitCode,
399
+ stdout: values.stdout,
400
+ stderr: values.stderr,
401
+ ...(diagnostics ? { diagnostics } : {}),
402
+ output_truncated: values.stdout.length >= maximumCapturedCharacters
403
+ || values.stderr.length >= maximumCapturedCharacters,
404
+ output_revision: values.outputRevision ?? 0,
405
+ last_output_at: values.lastOutputAt ? new Date(values.lastOutputAt).toISOString() : null,
406
+ supervisor_metadata_path: values.metadataPath,
407
+ receipt_path: values.receiptPath,
408
+ process_tree_terminated: values.status === "timed_out" ? true : values.timedOut ? false : undefined,
409
+ },
410
+ isError: isTerminal && values.status !== "pass",
411
+ };
412
+ }
413
+ function persistManagedCommandProgressIfDue(invocation, snapshot, previousPersistence, lastPersistedAt, setLastPersistedAt) {
414
+ const now = Date.now();
415
+ if (now - lastPersistedAt() < managedCommandProgressPersistIntervalMs)
416
+ return previousPersistence;
417
+ setLastPersistedAt(now);
418
+ const current = snapshot();
419
+ return previousPersistence.then(async () => {
420
+ const receipt = managedCommandResult({
421
+ ...invocation,
422
+ timedOut: false,
423
+ status: "running",
424
+ exitCode: null,
425
+ ...current,
426
+ });
427
+ await persistManagedCommandReceipt(invocation.receiptPath, receipt.structuredContent);
428
+ });
429
+ }
430
+ async function containedWorkingDirectory(worktreePath, input) {
431
+ const root = await fs.realpath(path.resolve(worktreePath));
432
+ const relative = input === undefined ? "." : requiredStandaloneString(input, "cwd");
433
+ if (path.isAbsolute(relative))
434
+ throw new Error("managed_command_invalid: cwd must be worktree-relative.");
435
+ const target = await fs.realpath(path.resolve(root, relative));
436
+ const containment = path.relative(root, target);
437
+ if (containment.startsWith(`..${path.sep}`) || path.isAbsolute(containment)) {
438
+ throw new Error("managed_command_invalid: cwd resolves outside the source worktree.");
439
+ }
440
+ return target;
441
+ }
442
+ function inheritedEnvironment(overrides) {
443
+ const env = {};
444
+ for (const [key, value] of Object.entries(process.env)) {
445
+ if (typeof value === "string" && !looksSensitiveEnvironmentName(key))
446
+ env[key] = value;
447
+ }
448
+ return { ...env, ...overrides };
449
+ }
450
+ function looksSensitiveEnvironmentName(name) {
451
+ const normalized = name.trim().toUpperCase();
452
+ return normalized.includes("SECRET")
453
+ || normalized.includes("TOKEN")
454
+ || normalized.includes("PASSWORD")
455
+ || normalized.includes("CREDENTIAL")
456
+ || normalized.endsWith("_API_KEY")
457
+ || normalized === "OPENAI_API_KEY";
458
+ }
459
+ function requiredAttemptEnvironment(name) {
460
+ const value = process.env[name]?.trim();
461
+ if (!value)
462
+ throw new Error(`managed_attempt_tool_unavailable: ${name} is not configured.`);
463
+ return value;
464
+ }
465
+ function asRecord(value) {
466
+ if (!value || typeof value !== "object" || Array.isArray(value))
467
+ throw new Error("managed_command_invalid: input must be an object.");
468
+ return value;
469
+ }
470
+ function requiredInputString(record, name) {
471
+ return requiredStandaloneString(record[name], name);
472
+ }
473
+ function validatedCommandId(record) {
474
+ const commandId = requiredInputString(record, "command_id");
475
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(commandId)) {
476
+ throw new Error("managed_command_invalid: command_id must be a UUID returned by Forge.");
477
+ }
478
+ return commandId;
479
+ }
480
+ function requiredStandaloneString(value, name) {
481
+ if (typeof value !== "string" || !value.trim())
482
+ throw new Error(`managed_command_invalid: ${name} is required.`);
483
+ return value.trim();
484
+ }
485
+ function requiredStringArray(record, name) {
486
+ const value = record[name];
487
+ if (!Array.isArray(value) || value.some(entry => typeof entry !== "string")) {
488
+ throw new Error(`managed_command_invalid: ${name} must be a string array.`);
489
+ }
490
+ return value;
491
+ }
492
+ function optionalTimeout(value) {
493
+ if (value === undefined)
494
+ return 2_700_000;
495
+ if (!Number.isSafeInteger(value) || value < 1_000 || value > 7_200_000) {
496
+ throw new Error("managed_command_invalid: timeout_ms must be an integer from 1000 through 7200000.");
497
+ }
498
+ return value;
499
+ }
500
+ function optionalWaitTimeout(value) {
501
+ if (value === undefined)
502
+ return 7_200_000;
503
+ if (!Number.isSafeInteger(value) || value < 1_000 || value > 7_200_000) {
504
+ throw new Error("managed_command_invalid: wait_timeout_ms must be an integer from 1000 through 7200000.");
505
+ }
506
+ return value;
507
+ }
508
+ async function delay(ms) {
509
+ await new Promise(resolve => setTimeout(resolve, ms));
510
+ }
511
+ function appendBounded(current, line) {
512
+ const next = `${current}${line}\n`;
513
+ return next.length <= maximumCapturedCharacters
514
+ ? next
515
+ : next.slice(-maximumCapturedCharacters);
516
+ }
517
+ function managedCommandDiagnostics(stdout, stderr) {
518
+ const lines = `${stdout}\n${stderr}`
519
+ .split(/\r?\n/u)
520
+ .map(line => line.trim())
521
+ .filter(Boolean);
522
+ const failedTests = new Set();
523
+ for (const line of lines) {
524
+ const xunit = line.match(/^\[xUnit\.net[^\]]*\]\s+(.+?)\s+\[FAIL\]$/u);
525
+ if (xunit?.[1])
526
+ failedTests.add(xunit[1].trim());
527
+ const vstest = line.match(/^Failed\s+(.+?)(?:\s+\[(?:\d|<).*)?$/u);
528
+ if (vstest?.[1] && !vstest[1].startsWith("!"))
529
+ failedTests.add(vstest[1].trim());
530
+ }
531
+ const summaryLines = lines
532
+ .filter(line => /(?:Failed!|Passed!|Test Run|Tests?:\s|Test Files|\[FAIL\]|error\s+[A-Z]+\d+)/iu.test(line))
533
+ .slice(-20);
534
+ return {
535
+ failed_tests: [...failedTests].slice(0, maximumReportedFailedTests),
536
+ failed_test_count: failedTests.size,
537
+ summary_lines: summaryLines,
538
+ };
539
+ }
540
+ function terminalManagedCommandText(values, diagnostics = managedCommandDiagnostics(values.stdout, values.stderr)) {
541
+ const failedTests = diagnostics.failed_tests;
542
+ const summaryLines = diagnostics.summary_lines;
543
+ const duration = values.elapsedMs === null ? "an unknown duration" : `${values.elapsedMs}ms`;
544
+ const sections = [
545
+ `Forge managed command ${values.commandId} ${values.status} after ${duration} (exit ${values.exitCode ?? "unavailable"}).`,
546
+ ];
547
+ if (failedTests.length > 0) {
548
+ sections.push(`Failed tests (${diagnostics.failed_test_count}):\n${failedTests.map(test => `- ${test}`).join("\n")}`);
549
+ }
550
+ if (summaryLines.length > 0) {
551
+ sections.push(`Test summary:\n${summaryLines.join("\n")}`);
552
+ }
553
+ const stdoutTail = tail(values.stdout, maximumReturnedOutputCharacters);
554
+ const stderrTail = tail(values.stderr, maximumReturnedOutputCharacters);
555
+ sections.push(`stdout tail:\n${stdoutTail || "(empty)"}`);
556
+ sections.push(`stderr tail:\n${stderrTail || "(empty)"}`);
557
+ return sections.join("\n\n");
558
+ }
559
+ function tail(value, maximumCharacters) {
560
+ return value.length <= maximumCharacters ? value : value.slice(-maximumCharacters);
561
+ }
@@ -237,12 +237,19 @@ export async function writeQaGateCompletionArtifact(config, gate, input) {
237
237
  const status = normalizeQaGateStatus(requiredString(payload, "status"));
238
238
  validateQaGatePayloadShape(gate, payload, status);
239
239
  validateOptionalWorkItemId(payload, config.workItemId);
240
- const redactedPayload = redactSecrets({ ...payload, status }, config.redactionSecrets ?? []);
240
+ const ownerFields = artifactOwnerFields(config);
241
+ const validatedSidecars = gate === "qa_manual_web"
242
+ ? await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", qaEvidenceSource(payload)), ownerFields.owner_type, ownerFields.owner_id)
243
+ : null;
244
+ const redactedPayload = redactSecrets({
245
+ ...payload,
246
+ status,
247
+ ...(validatedSidecars ? { sidecars: validatedSidecars } : {})
248
+ }, config.redactionSecrets ?? []);
241
249
  const producedBy = optionalString(redactedPayload, "produced_by")
242
250
  ?? optionalString(redactedPayload, "producedBy")
243
251
  ?? config.producedBy
244
252
  ?? "forge-completion-tool";
245
- const ownerFields = artifactOwnerFields(config);
246
253
  const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
247
254
  ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
248
255
  : artifactRootFor(config.worktreePath, config.workItemId);
@@ -424,7 +431,7 @@ async function synthesizeQaCycleVerdictPayload(config, payload, status) {
424
431
  ]);
425
432
  merged.browser_checks = dedupeUnknownArray([
426
433
  ...qaArray(payload, "browser_checks", payloadEvidence),
427
- ...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
434
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks").map(normalizeInheritedQaBrowserCheck))
428
435
  ]);
429
436
  merged.sidecars = dedupeUnknownArray([
430
437
  ...qaArray(payload, "sidecars", payloadEvidence),
@@ -516,6 +523,27 @@ function dedupeStrings(values) {
516
523
  }
517
524
  return deduped;
518
525
  }
526
+ function normalizeInheritedQaBrowserCheck(browserCheck) {
527
+ if (!browserCheck || typeof browserCheck !== "object" || Array.isArray(browserCheck)) {
528
+ return browserCheck;
529
+ }
530
+ const record = browserCheck;
531
+ if (optionalString(record, "summary")) {
532
+ return record;
533
+ }
534
+ const target = optionalString(record, "url") ?? optionalString(record, "route") ?? "the configured browser target";
535
+ const status = optionalString(record, "status") ?? "recorded";
536
+ return {
537
+ ...record,
538
+ summary: `Inherited browser check ${status} for ${target}.`
539
+ };
540
+ }
541
+ function normalizeSha256ContentHash(contentHash) {
542
+ const normalized = contentHash.trim().toLowerCase();
543
+ return normalized.startsWith("sha256:")
544
+ ? normalized.slice("sha256:".length)
545
+ : normalized;
546
+ }
519
547
  async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
520
548
  if (sidecars.length === 0) {
521
549
  return [];
@@ -557,7 +585,7 @@ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, o
557
585
  throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
558
586
  }
559
587
  const actualHash = await sha256File(fullPath);
560
- if (actualHash !== contentHash.trim().toLowerCase()) {
588
+ if (actualHash !== normalizeSha256ContentHash(contentHash)) {
561
589
  throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
562
590
  }
563
591
  validated.push({
@@ -9,7 +9,8 @@ export type DebugCompletionPhase = "debug_repro" | "debug_root_cause" | "debug_f
9
9
  export type ArchitectCompletionPhase = "architect";
10
10
  export type DraftCompletionPhase = "draft";
11
11
  export type DecomposeCompletionPhase = "decompose";
12
- export type CompletionPhase = DevelopCompletionPhase | "qa" | DebugCompletionPhase | ArchitectCompletionPhase | DraftCompletionPhase | DecomposeCompletionPhase;
12
+ export type CiRepairCompletionPhase = "ci_repair";
13
+ export type CompletionPhase = DevelopCompletionPhase | "qa" | DebugCompletionPhase | ArchitectCompletionPhase | DraftCompletionPhase | DecomposeCompletionPhase | CiRepairCompletionPhase;
13
14
  export type QaCompletionGate = "qa_plan" | "qa_regression" | "qa_api" | "qa_manual_web" | "qa_bug_repro" | "qa_findings" | "qa_verdict";
14
15
  export type DraftCompletionGate = "draft_intent" | "draft_repo_context" | "draft_graph_context" | "draft_product_scope" | "draft_work_order_plan" | "draft_critic" | "draft_proposal";
15
16
  export type DecomposeCompletionGate = "decompose_subject_intake" | "decompose_context_review" | "decompose_breakdown_strategy" | "decompose_child_items" | "decompose_dependency_graph" | "decompose_critic" | "decompose_proposal";
@@ -25,6 +26,7 @@ export interface CompletionToolRuntimeOptions {
25
26
  readonly dispatch: RunOnceDispatchPacket;
26
27
  readonly worktree: WorktreeHandle;
27
28
  readonly phase?: CompletionPhase;
29
+ readonly runtimeEnvironment?: Readonly<Record<string, string>>;
28
30
  }
29
31
  export interface CompletionToolServerConfig {
30
32
  readonly worktreePath: string;
@@ -56,7 +58,7 @@ export interface CompletionToolCallResult {
56
58
  readonly allowed: boolean;
57
59
  readonly reason: string;
58
60
  };
59
- readonly nextPhase: CompletionPhase | "dev_test_ready" | "qa_ready" | "sign_off_ready";
61
+ readonly nextPhase: CompletionPhase | "dev_test_ready" | "qa_ready" | "sign_off_ready" | "provider_checks_ready";
60
62
  readonly nextInstruction: string;
61
63
  };
62
64
  }