@loucompanion/forge-bridge 0.1.3-dev.ba7d36726eda → 0.1.3-dev.bca7d0ccee53

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 (73) hide show
  1. package/dist/bridge/bridge.base.d.ts +2 -1
  2. package/dist/bridge/bridge.service.d.ts +8 -0
  3. package/dist/bridge/bridge.service.js +184 -12
  4. package/dist/bridge/bridge.types.d.ts +37 -0
  5. package/dist/cli/cli.help.js +1 -1
  6. package/dist/cli/cli.service.js +1 -1
  7. package/dist/cli/local-config.js +2 -1
  8. package/dist/runtime/attempt-node-runtime-launcher.d.ts +3 -1
  9. package/dist/runtime/attempt-node-runtime-launcher.js +62 -11
  10. package/dist/runtime/attempt-process-supervisor-registry.js +1 -1
  11. package/dist/runtime/attempt-process-supervisor.d.ts +12 -0
  12. package/dist/runtime/attempt-process-supervisor.js +75 -15
  13. package/dist/runtime/generated-output-reset.d.ts +10 -0
  14. package/dist/runtime/generated-output-reset.js +103 -0
  15. package/dist/runtime/host-listener-inspector.d.ts +16 -0
  16. package/dist/runtime/host-listener-inspector.js +143 -4
  17. package/dist/runtime/host-runtime-contract.d.ts +9 -1
  18. package/dist/runtime/host-runtime-contract.js +411 -6
  19. package/dist/runtime/host-runtime-lease.d.ts +12 -1
  20. package/dist/runtime/host-runtime-lease.js +271 -50
  21. package/dist/runtime/host-runtime-promotion.d.ts +2 -0
  22. package/dist/runtime/host-runtime-promotion.js +35 -4
  23. package/dist/runtime/node-host-runtime-adapter.d.ts +7 -0
  24. package/dist/runtime/node-host-runtime-adapter.js +71 -8
  25. package/dist/runtime/playwright-host-runtime-adapter.js +84 -0
  26. package/dist/sandbox/preflight.d.ts +9 -0
  27. package/dist/sandbox/preflight.js +39 -4
  28. package/dist/sandbox/workspace.service.js +7 -2
  29. package/dist/session/managed-attempt-tools.d.ts +17 -0
  30. package/dist/session/managed-attempt-tools.js +712 -0
  31. package/dist/session/phase-completion.artifacts.js +9 -2
  32. package/dist/session/phase-completion.d.ts +11 -2
  33. package/dist/session/phase-completion.helpers.d.ts +5 -0
  34. package/dist/session/phase-completion.helpers.js +45 -9
  35. package/dist/session/phase-completion.js +12 -3
  36. package/dist/session/phase-completion.prompts.d.ts +3 -3
  37. package/dist/session/phase-completion.prompts.js +21 -6
  38. package/dist/session/phase-completion.runtime.d.ts +13 -1
  39. package/dist/session/phase-completion.runtime.js +183 -8
  40. package/dist/session/phase-completion.schemas.js +108 -3
  41. package/dist/session/phase-completion.validation.d.ts +4 -1
  42. package/dist/session/phase-completion.validation.js +144 -18
  43. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  44. package/dist/session/provider-cli/ci-repair-readiness.js +95 -0
  45. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  46. package/dist/session/provider-cli/provider-cli.process.d.ts +14 -0
  47. package/dist/session/provider-cli/provider-cli.process.js +176 -18
  48. package/dist/session/provider-cli/provider-cli.prompts.js +30 -8
  49. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  50. package/dist/session/provider-cli/provider-cli.service.d.ts +19 -2
  51. package/dist/session/provider-cli/provider-cli.service.js +390 -83
  52. package/dist/session/provider-cli/provider-cli.types.d.ts +3 -0
  53. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  54. package/dist/session/provider-cli/provider-output-state.js +30 -2
  55. package/dist/session/session.base.d.ts +2 -1
  56. package/dist/session/session.service.d.ts +2 -1
  57. package/dist/session/session.service.js +303 -30
  58. package/dist/session/session.types.d.ts +38 -0
  59. package/dist/session/session.types.js +17 -0
  60. package/dist/shared/process/host-process-supervisor.d.ts +18 -1
  61. package/dist/shared/process/host-process-supervisor.js +94 -7
  62. package/dist/shared/process/process.d.ts +1 -0
  63. package/dist/shared/process/process.js +1 -0
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/dist/worktree/artifact-injector.d.ts +3 -2
  67. package/dist/worktree/artifact-injector.js +43 -4
  68. package/dist/worktree/dependency-warmer.d.ts +10 -1
  69. package/dist/worktree/dependency-warmer.js +94 -5
  70. package/dist/worktree/worktree.service.d.ts +1 -0
  71. package/dist/worktree/worktree.service.js +12 -1
  72. package/package.json +1 -1
  73. package/runtime/windows-job-supervisor.ps1 +89 -20
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  const terminalChildStatuses = new Set(["exited", "failed"]);
5
+ const defaultMetadataReadyTimeoutMs = process.platform === "win32" ? 60_000 : 10_000;
5
6
  /**
6
7
  * Owns the independently supervised process trees that belong to one host
7
8
  * dispatch attempt. Each named child gets a distinct Windows Job Object or
@@ -23,7 +24,7 @@ export class AttemptProcessSupervisor {
23
24
  constructor(identity, runner, options = {}) {
24
25
  this.runner = runner;
25
26
  this.identity = validateIdentity(identity);
26
- this.metadataReadyTimeoutMs = boundedInteger(options.metadataReadyTimeoutMs ?? 10_000, 100, 120_000, "metadataReadyTimeoutMs");
27
+ this.metadataReadyTimeoutMs = boundedInteger(options.metadataReadyTimeoutMs ?? defaultMetadataReadyTimeoutMs, 100, 120_000, "metadataReadyTimeoutMs");
27
28
  this.ledgerPath = path.join(this.identity.logDirectory, "attempt-process-supervisor.json");
28
29
  if (options.signal?.aborted) {
29
30
  this.status = "stopping";
@@ -111,6 +112,36 @@ export class AttemptProcessSupervisor {
111
112
  stop: async (fencingToken) => await this.stopChild(name, generation, fencingToken),
112
113
  };
113
114
  }
115
+ /**
116
+ * Starts a child and proves that the OS process tree is supervised before
117
+ * returning it to the caller. A launch that settles before producing its
118
+ * provider-ready supervision record is safe to retry: the raw runner only
119
+ * persists that record after the actual provider child has started, before
120
+ * Forge announces the provider as active.
121
+ */
122
+ async startChildReady(name, request, handlers = {}, signal, options = {}) {
123
+ const maxStartAttempts = boundedInteger(options.maxStartAttempts ?? 1, 1, 5, "maxStartAttempts");
124
+ const retryDelayMs = boundedInteger(options.retryDelayMs ?? 100, 0, 5_000, "retryDelayMs");
125
+ let lastError;
126
+ for (let attempt = 1; attempt <= maxStartAttempts; attempt += 1) {
127
+ const child = await this.startChild(name, request, handlers, signal);
128
+ try {
129
+ await child.ready;
130
+ return child;
131
+ }
132
+ catch (error) {
133
+ lastError = error;
134
+ await child.result.catch(() => undefined);
135
+ if (!isRetryableStartFailure(error) || attempt === maxStartAttempts)
136
+ throw error;
137
+ if (signal?.aborted)
138
+ throw cancellationError(signal);
139
+ if (retryDelayMs > 0)
140
+ await wait(retryDelayMs * attempt);
141
+ }
142
+ }
143
+ throw lastError;
144
+ }
114
145
  async stopChild(name, generation, fencingToken, reason = "Forge stopped the supervised child.") {
115
146
  this.assertFence(fencingToken);
116
147
  const record = this.currentByName.get(name);
@@ -126,16 +157,19 @@ export class AttemptProcessSupervisor {
126
157
  }
127
158
  async stopAll(fencingToken, reason = "Forge stopped the supervised attempt.") {
128
159
  this.assertFence(fencingToken);
160
+ // The durable terminal ledger is part of the stop operation's contract.
161
+ // Join an in-flight stop before inspecting terminal state so lease cleanup
162
+ // cannot remove the log directory underneath its final persistence.
163
+ if (this.stopOperation)
164
+ return await this.stopOperation;
129
165
  if (this.status === "stopped")
130
166
  return;
131
- if (!this.stopOperation) {
132
- const operation = this.performStopAll(reason);
133
- this.stopOperation = operation;
134
- void operation.catch(() => undefined).finally(() => {
135
- if (this.stopOperation === operation && this.status !== "stopped")
136
- this.stopOperation = null;
137
- });
138
- }
167
+ const operation = this.performStopAll(reason);
168
+ this.stopOperation = operation;
169
+ void operation.catch(() => undefined).finally(() => {
170
+ if (this.stopOperation === operation && this.status !== "stopped")
171
+ this.stopOperation = null;
172
+ });
139
173
  return await this.stopOperation;
140
174
  }
141
175
  async performStopAll(reason) {
@@ -147,8 +181,13 @@ export class AttemptProcessSupervisor {
147
181
  await this.persist();
148
182
  const results = await Promise.allSettled(active.map(record => record.result));
149
183
  const rejected = results.filter((result) => result.status === "rejected");
150
- this.status = rejected.length > 0 || this.persistenceFailure !== undefined ? "degraded" : "stopped";
151
- await this.persist();
184
+ const terminalStatus = rejected.length > 0 || this.persistenceFailure !== undefined ? "degraded" : "stopped";
185
+ await this.persist({
186
+ ...this.snapshot(),
187
+ status: terminalStatus,
188
+ updatedAt: new Date().toISOString(),
189
+ });
190
+ this.status = terminalStatus;
152
191
  if (rejected.length > 0) {
153
192
  throw new AggregateError(rejected.map(result => result.reason), "attempt_process_supervisor_cleanup_degraded");
154
193
  }
@@ -188,12 +227,14 @@ export class AttemptProcessSupervisor {
188
227
  return;
189
228
  }
190
229
  if (terminalChildStatuses.has(record.status)) {
191
- throw new Error(`attempt_process_supervisor_start_failed: child '${record.childId}' exited before supervision metadata was produced.`);
230
+ const detail = await childStartFailureDetail(record);
231
+ throw new Error(`attempt_process_supervisor_start_failed: child '${record.childId}' exited before supervision metadata was produced${detail ? `: ${detail}` : "."}`);
192
232
  }
193
233
  await wait(25);
194
234
  }
195
235
  record.controller.abort("Supervised child metadata was not produced before the readiness deadline.");
196
- throw new Error(`attempt_process_supervisor_start_timeout: child '${record.childId}' did not become supervised.`);
236
+ const detail = await childStartFailureDetail(record);
237
+ throw new Error(`attempt_process_supervisor_start_timeout: child '${record.childId}' did not become supervised${detail ? `: ${detail}` : "."}`);
197
238
  }
198
239
  async settleChild(record, status, exitCode, failure) {
199
240
  record.status = status;
@@ -210,8 +251,7 @@ export class AttemptProcessSupervisor {
210
251
  throw new Error("attempt_process_supervisor_fenced: fencing token does not own this attempt.");
211
252
  }
212
253
  }
213
- persist() {
214
- const snapshot = this.snapshot();
254
+ persist(snapshot = this.snapshot()) {
215
255
  const operation = this.persistTail.then(async () => {
216
256
  await ensurePrivateDirectory(this.identity.logDirectory);
217
257
  const temporary = `${this.ledgerPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
@@ -274,6 +314,26 @@ function boundedFailure(error) {
274
314
  const message = error instanceof Error ? error.message : String(error);
275
315
  return message.replaceAll(/[\r\n\t]+/gu, " ").slice(0, 512);
276
316
  }
317
+ async function childStartFailureDetail(record) {
318
+ try {
319
+ const result = await record.result;
320
+ const stderr = boundedDiagnostic(result.stderr);
321
+ return stderr
322
+ ? `exit ${result.exitCode}; stderr: ${stderr}`
323
+ : `exit ${result.exitCode}`;
324
+ }
325
+ catch (error) {
326
+ return boundedFailure(error);
327
+ }
328
+ }
329
+ function boundedDiagnostic(value) {
330
+ return value.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(0, 512);
331
+ }
332
+ function isRetryableStartFailure(error) {
333
+ return error instanceof Error
334
+ && (error.message.startsWith("attempt_process_supervisor_start_failed:")
335
+ || error.message.startsWith("attempt_process_supervisor_start_timeout:"));
336
+ }
277
337
  function cancellationError(signal) {
278
338
  return new Error(String(signal.reason ?? "Supervised child launch was cancelled."));
279
339
  }
@@ -0,0 +1,10 @@
1
+ export interface GeneratedOutputResetResult {
2
+ readonly quarantineDirectory: string;
3
+ readonly quarantinedPaths: readonly string[];
4
+ }
5
+ /**
6
+ * Moves only known, untracked test-output directories out of a worktree before
7
+ * a managed attempt starts. The quarantine lives in the attempt-owned output
8
+ * directory, so cleanup is recoverable and cannot cross the attempt boundary.
9
+ */
10
+ export declare function resetGeneratedOutputs(worktreePath: string, testOutputDirectory: string): Promise<GeneratedOutputResetResult>;
@@ -0,0 +1,103 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ const generatedDirectoryNames = new Set(["TestResults", "test-results", "playwright-report"]);
8
+ const skippedDirectoryNames = new Set([
9
+ ".git",
10
+ ".forge",
11
+ ".idea",
12
+ ".next",
13
+ ".pnpm",
14
+ ".vs",
15
+ ".vscode",
16
+ "bin",
17
+ "build",
18
+ "coverage",
19
+ "dist",
20
+ "node_modules",
21
+ "obj",
22
+ "target",
23
+ "vendor",
24
+ ]);
25
+ /**
26
+ * Moves only known, untracked test-output directories out of a worktree before
27
+ * a managed attempt starts. The quarantine lives in the attempt-owned output
28
+ * directory, so cleanup is recoverable and cannot cross the attempt boundary.
29
+ */
30
+ export async function resetGeneratedOutputs(worktreePath, testOutputDirectory) {
31
+ const worktreeRoot = await fs.realpath(path.resolve(worktreePath));
32
+ const outputRoot = await fs.realpath(path.resolve(testOutputDirectory));
33
+ if (isContainedPath(worktreeRoot, outputRoot)) {
34
+ throw new Error("generated_output_reset_invalid: attempt output must be outside the source worktree.");
35
+ }
36
+ const candidates = await findGeneratedDirectories(worktreeRoot);
37
+ const quarantineDirectory = path.join(outputRoot, "preflight-reset");
38
+ const quarantinedPaths = [];
39
+ for (const candidate of candidates) {
40
+ const relativePath = path.relative(worktreeRoot, candidate);
41
+ if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
42
+ throw new Error("generated_output_reset_invalid: generated output escaped the source worktree.");
43
+ }
44
+ await assertUntrackedGeneratedDirectory(worktreeRoot, relativePath);
45
+ await fs.mkdir(quarantineDirectory, { recursive: true, mode: 0o700 });
46
+ const digest = createHash("sha256").update(relativePath).digest("hex").slice(0, 12);
47
+ const destination = path.join(quarantineDirectory, `${digest}-${randomUUID()}-${path.basename(candidate)}`);
48
+ try {
49
+ await fs.rename(candidate, destination);
50
+ }
51
+ catch (error) {
52
+ const code = error.code ?? "unknown";
53
+ throw new Error(`generated_output_reset_failed: could not quarantine ${relativePath} (${code}).`, { cause: error });
54
+ }
55
+ quarantinedPaths.push(relativePath.split(path.sep).join("/"));
56
+ }
57
+ if (quarantinedPaths.length > 0) {
58
+ await fs.writeFile(path.join(quarantineDirectory, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, worktreePath: worktreeRoot, quarantinedPaths }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
59
+ }
60
+ return { quarantineDirectory, quarantinedPaths };
61
+ }
62
+ async function findGeneratedDirectories(root) {
63
+ const candidates = [];
64
+ const pending = [root];
65
+ while (pending.length > 0) {
66
+ const current = pending.pop();
67
+ const entries = await fs.readdir(current, { withFileTypes: true });
68
+ for (const entry of entries) {
69
+ if (!entry.isDirectory() || entry.isSymbolicLink())
70
+ continue;
71
+ if (skippedDirectoryNames.has(entry.name))
72
+ continue;
73
+ const candidate = path.join(current, entry.name);
74
+ if (generatedDirectoryNames.has(entry.name)) {
75
+ const realCandidate = await fs.realpath(candidate);
76
+ if (!isContainedPath(root, realCandidate)) {
77
+ throw new Error("generated_output_reset_invalid: generated output resolves outside the source worktree.");
78
+ }
79
+ candidates.push(realCandidate);
80
+ continue;
81
+ }
82
+ pending.push(candidate);
83
+ }
84
+ }
85
+ return candidates.sort((left, right) => left.localeCompare(right));
86
+ }
87
+ async function assertUntrackedGeneratedDirectory(worktreeRoot, relativePath) {
88
+ const pathspec = relativePath.split(path.sep).join("/");
89
+ let stdout;
90
+ try {
91
+ ({ stdout } = await execFileAsync("git", ["-C", worktreeRoot, "ls-files", "-z", "--", pathspec], { encoding: "utf8", windowsHide: true, maxBuffer: 4 * 1024 * 1024 }));
92
+ }
93
+ catch (error) {
94
+ throw new Error(`generated_output_reset_unverified: git could not verify ${pathspec}.`, { cause: error });
95
+ }
96
+ if (stdout.length > 0) {
97
+ throw new Error(`generated_output_reset_unsafe: ${pathspec} contains tracked files and was not changed.`);
98
+ }
99
+ }
100
+ function isContainedPath(root, candidate) {
101
+ const relative = path.relative(root, candidate);
102
+ return relative.length > 0 && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
103
+ }
@@ -16,9 +16,25 @@ export declare class HostListenerInspector {
16
16
  private readonly platform;
17
17
  constructor(commands?: HostInspectionCommandRunner, platform?: NodeJS.Platform);
18
18
  inspect(child: AttemptProcessChildHandle, reservation: HostPortReservation, owner: HostPortLeaseOwner): Promise<HostListenerOwnership>;
19
+ /** Returns loopback TCP ports opened by one exact supervised child process tree. */
20
+ inspectOwnedLoopbackListeners(child: AttemptProcessChildHandle, owner: HostPortLeaseOwner): Promise<readonly number[]>;
19
21
  private inspectWindows;
22
+ private inspectAllWindows;
20
23
  private inspectUnix;
24
+ private inspectAllUnix;
21
25
  }
26
+ export declare function parseWindowsLoopbackListeners(output: string): readonly {
27
+ pid: number;
28
+ port: number;
29
+ }[];
30
+ export declare function parseLsofLoopbackListeners(output: string): readonly {
31
+ pid: number;
32
+ port: number;
33
+ }[];
34
+ export declare function parseSsLoopbackListeners(output: string): readonly {
35
+ pid: number;
36
+ port: number;
37
+ }[];
22
38
  export declare class ExecFileInspectionCommandRunner implements HostInspectionCommandRunner {
23
39
  run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
24
40
  }
@@ -25,6 +25,16 @@ export class HostListenerInspector {
25
25
  }
26
26
  return await this.inspectUnix(metadata.rootPid, reservation.hostPort);
27
27
  }
28
+ /** Returns loopback TCP ports opened by one exact supervised child process tree. */
29
+ async inspectOwnedLoopbackListeners(child, owner) {
30
+ const metadata = await loadMetadata(child.metadataPath);
31
+ if (!metadata || !matchesOwner(metadata, child, owner)) {
32
+ throw new Error("host_listener_inspection_invalid: supervised provider ownership metadata is unavailable.");
33
+ }
34
+ return this.platform === "win32"
35
+ ? await this.inspectAllWindows(metadata.rootPid)
36
+ : await this.inspectAllUnix(metadata.rootPid);
37
+ }
28
38
  async inspectWindows(rootPid, port) {
29
39
  const netstat = await this.commands.run("netstat.exe", ["-ano", "-p", "TCP"]);
30
40
  if (netstat.exitCode !== 0) {
@@ -59,6 +69,43 @@ export class HostListenerInspector {
59
69
  const values = Array.isArray(parsed) ? parsed : [parsed];
60
70
  return values.length === listenerPids.length && values.every(value => value === true) ? "owned" : "foreign";
61
71
  }
72
+ async inspectAllWindows(rootPid) {
73
+ const netstat = await this.commands.run("netstat.exe", ["-ano", "-p", "TCP"]);
74
+ if (netstat.exitCode !== 0)
75
+ throw commandError("netstat", netstat);
76
+ const listeners = parseWindowsLoopbackListeners(netstat.stdout);
77
+ if (listeners.length === 0)
78
+ return [];
79
+ const pids = [...new Set(listeners.map(listener => listener.pid))];
80
+ const ownership = await this.commands.run(windowsPowerShellPath(), [
81
+ "-NoLogo",
82
+ "-NoProfile",
83
+ "-NonInteractive",
84
+ "-ExecutionPolicy",
85
+ "Bypass",
86
+ "-File",
87
+ windowsListenerOwnerPath,
88
+ "-RootProcessId",
89
+ String(rootPid),
90
+ "-ListenerProcessIds",
91
+ pids.join(","),
92
+ ]);
93
+ if (ownership.exitCode !== 0)
94
+ throw commandError("Windows process ancestry", ownership);
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(ownership.stdout.trim());
98
+ }
99
+ catch (error) {
100
+ throw new Error("host_listener_inspection_failed: Windows ancestry output was invalid.", { cause: error });
101
+ }
102
+ const values = Array.isArray(parsed) ? parsed : [parsed];
103
+ if (values.length !== pids.length || values.some(value => typeof value !== "boolean")) {
104
+ throw new Error("host_listener_inspection_failed: Windows ancestry output was incomplete.");
105
+ }
106
+ const ownedPids = new Set(pids.filter((_pid, index) => values[index] === true));
107
+ return uniqueSorted(listeners.filter(listener => ownedPids.has(listener.pid)).map(listener => listener.port));
108
+ }
62
109
  async inspectUnix(rootPid, port) {
63
110
  let listenerPids = null;
64
111
  const lsof = await this.commands.run("lsof", [
@@ -93,13 +140,84 @@ export class HostListenerInspector {
93
140
  const groups = parseProcessGroups(processGroups.stdout);
94
141
  return listenerPids.every(pid => groups.get(pid) === rootPid) ? "owned" : "foreign";
95
142
  }
143
+ async inspectAllUnix(rootPid) {
144
+ const lsof = await this.commands.run("lsof", [
145
+ "-nP",
146
+ "-a",
147
+ "-iTCP@127.0.0.1",
148
+ "-sTCP:LISTEN",
149
+ "-Fpn",
150
+ ]);
151
+ let listeners;
152
+ if (lsof.exitCode === 0 || lsof.exitCode === 1) {
153
+ listeners = parseLsofLoopbackListeners(lsof.stdout);
154
+ }
155
+ else if (lsof.errorCode === "ENOENT") {
156
+ const ss = await this.commands.run("ss", ["-H", "-ltnp"]);
157
+ if (ss.exitCode !== 0)
158
+ throw commandError("ss", ss);
159
+ listeners = parseSsLoopbackListeners(ss.stdout);
160
+ }
161
+ else {
162
+ throw commandError("lsof", lsof);
163
+ }
164
+ if (listeners.length === 0)
165
+ return [];
166
+ const pids = [...new Set(listeners.map(listener => listener.pid))];
167
+ const processGroups = await this.commands.run("ps", ["-o", "pid=,pgid=", "-p", pids.join(",")]);
168
+ if (processGroups.exitCode !== 0)
169
+ throw commandError("ps", processGroups);
170
+ const groups = parseProcessGroups(processGroups.stdout);
171
+ return uniqueSorted(listeners
172
+ .filter(listener => groups.get(listener.pid) === rootPid)
173
+ .map(listener => listener.port));
174
+ }
175
+ }
176
+ export function parseWindowsLoopbackListeners(output) {
177
+ return uniqueListeners(output.split(/\r?\n/u).flatMap(line => {
178
+ const fields = line.trim().split(/\s+/u);
179
+ if (fields.length < 5 || fields[0]?.toUpperCase() !== "TCP" || fields[3]?.toUpperCase() !== "LISTENING")
180
+ return [];
181
+ const endpoint = parseIpv4LoopbackEndpoint(fields[1]);
182
+ const pids = validPid(fields[4]);
183
+ return endpoint && pids.length === 1 ? [{ pid: pids[0], port: endpoint }] : [];
184
+ }));
185
+ }
186
+ export function parseLsofLoopbackListeners(output) {
187
+ const listeners = [];
188
+ let pid = null;
189
+ for (const line of output.split(/\r?\n/u)) {
190
+ if (line.startsWith("p"))
191
+ pid = validPid(line.slice(1))[0] ?? null;
192
+ if (!line.startsWith("n") || pid === null)
193
+ continue;
194
+ const port = parseIpv4LoopbackEndpoint(line.slice(1));
195
+ if (port !== null)
196
+ listeners.push({ pid, port });
197
+ }
198
+ return uniqueListeners(listeners);
199
+ }
200
+ export function parseSsLoopbackListeners(output) {
201
+ return uniqueListeners(output.split(/\r?\n/u).flatMap(line => {
202
+ const endpoint = line.match(/127\.0\.0\.1:(\d+)/u)?.[1];
203
+ const port = endpoint ? Number.parseInt(endpoint, 10) : 0;
204
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535)
205
+ return [];
206
+ return [...line.matchAll(/pid=(\d+)/gu)].flatMap(match => {
207
+ const pid = validPid(match[1])[0];
208
+ return pid ? [{ pid, port }] : [];
209
+ });
210
+ }));
96
211
  }
97
212
  export class ExecFileInspectionCommandRunner {
98
213
  run(command, args) {
99
214
  return new Promise(resolve => {
100
- execFile(command, args, {
215
+ const executable = process.platform === "win32" && command.toLowerCase() === "netstat.exe"
216
+ ? windowsSystemExecutable("netstat.exe")
217
+ : command;
218
+ execFile(executable, args, {
101
219
  windowsHide: true,
102
- timeout: 5_000,
220
+ timeout: 15_000,
103
221
  maxBuffer: 4 * 1024 * 1024,
104
222
  }, (error, stdout, stderr) => {
105
223
  const processError = error;
@@ -175,11 +293,32 @@ function validPid(value) {
175
293
  function uniqueSorted(values) {
176
294
  return [...new Set(values)].sort((left, right) => left - right);
177
295
  }
296
+ function parseIpv4LoopbackEndpoint(value) {
297
+ const match = value?.match(/^127\.0\.0\.1:(\d+)$/u);
298
+ if (!match)
299
+ return null;
300
+ const port = Number.parseInt(match[1], 10);
301
+ return Number.isSafeInteger(port) && port >= 1 && port <= 65535 ? port : null;
302
+ }
303
+ function uniqueListeners(values) {
304
+ const seen = new Set();
305
+ return values.filter(value => {
306
+ const key = `${value.pid}:${value.port}`;
307
+ if (seen.has(key))
308
+ return false;
309
+ seen.add(key);
310
+ return true;
311
+ }).sort((left, right) => left.port - right.port || left.pid - right.pid);
312
+ }
178
313
  function commandError(label, result) {
179
314
  const detail = result.stderr.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(0, 512);
180
- return new Error(`host_listener_inspection_failed: ${label} exited ${result.exitCode}${detail ? `: ${detail}` : ""}.`);
315
+ const code = result.errorCode ? ` (${result.errorCode})` : "";
316
+ return new Error(`host_listener_inspection_failed: ${label} exited ${result.exitCode}${code}${detail ? `: ${detail}` : ""}.`);
181
317
  }
182
318
  function windowsPowerShellPath() {
319
+ return path.win32.join(path.dirname(windowsSystemExecutable("netstat.exe")), "WindowsPowerShell", "v1.0", "powershell.exe");
320
+ }
321
+ function windowsSystemExecutable(name) {
183
322
  const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows";
184
- return path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
323
+ return path.win32.join(systemRoot, "System32", name);
185
324
  }
@@ -5,6 +5,7 @@ export interface HostRuntimeLaunchConfig {
5
5
  readonly command: string;
6
6
  readonly args: readonly string[];
7
7
  readonly workingDirectory: string;
8
+ readonly environment?: Readonly<Record<string, string>>;
8
9
  }
9
10
  export interface HostRuntimePortConfig {
10
11
  readonly name: string;
@@ -31,7 +32,14 @@ export interface HostRuntimeContract {
31
32
  }
32
33
  export interface LoadedHostRuntimeContract {
33
34
  readonly contract: HostRuntimeContract;
34
- readonly source: "default" | "project";
35
+ readonly source: "default" | "project" | "derived";
36
+ readonly discovery?: HostRuntimeDiscovery | null;
37
+ }
38
+ export interface HostRuntimeDiscovery {
39
+ readonly status: "derived" | "ambiguous" | "unsupported";
40
+ readonly reasonCode: string;
41
+ readonly summary: string;
42
+ readonly candidateFingerprint?: string | null;
35
43
  }
36
44
  export declare function loadHostRuntimeContract(worktreePath: string): Promise<LoadedHostRuntimeContract>;
37
45
  export declare function defaultHostRuntimeContract(): HostRuntimeContract;