@kata-sh/pi-symphony-extension 0.1.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.
@@ -0,0 +1,80 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const mocks = vi.hoisted(() => {
5
+ type Handler = (...args: unknown[]) => unknown;
6
+ const instances: Array<{
7
+ state: {
8
+ stopOwnedOnShutdown: boolean;
9
+ ownedProcess?: { baseUrl?: string };
10
+ };
11
+ processManager: { shutdown: ReturnType<typeof vi.fn> };
12
+ restore: ReturnType<typeof vi.fn>;
13
+ clearAttachmentIfBaseUrl: ReturnType<typeof vi.fn>;
14
+ persist: ReturnType<typeof vi.fn>;
15
+ }> = [];
16
+
17
+ const SymphonyRuntime = vi.fn(function SymphonyRuntimeMock() {
18
+ const runtime = {
19
+ state: { stopOwnedOnShutdown: true },
20
+ processManager: { shutdown: vi.fn(async () => undefined) },
21
+ restore: vi.fn(),
22
+ clearAttachmentIfBaseUrl: vi.fn(),
23
+ persist: vi.fn(),
24
+ };
25
+ instances.push(runtime);
26
+ return runtime;
27
+ });
28
+
29
+ return {
30
+ instances,
31
+ SymphonyRuntime,
32
+ registerSymphonyCommands: vi.fn(),
33
+ registerSymphonyTools: vi.fn(),
34
+ setSymphonyStatus: vi.fn(),
35
+ handlers: new Map<string, Handler>(),
36
+ };
37
+ });
38
+
39
+ vi.mock("./runtime.ts", () => ({ SymphonyRuntime: mocks.SymphonyRuntime }));
40
+ vi.mock("./commands.ts", () => ({
41
+ registerSymphonyCommands: mocks.registerSymphonyCommands,
42
+ setSymphonyStatus: mocks.setSymphonyStatus,
43
+ }));
44
+ vi.mock("./tools.ts", () => ({ registerSymphonyTools: mocks.registerSymphonyTools }));
45
+
46
+ import symphonyExtension from "./index.ts";
47
+
48
+ function extensionApi(): ExtensionAPI {
49
+ return {
50
+ on: vi.fn((event: string, handler: (...args: unknown[]) => unknown) => {
51
+ mocks.handlers.set(event, handler);
52
+ }),
53
+ appendEntry: vi.fn(),
54
+ } as unknown as ExtensionAPI;
55
+ }
56
+
57
+ beforeEach(() => {
58
+ mocks.instances.length = 0;
59
+ mocks.handlers.clear();
60
+ mocks.SymphonyRuntime.mockClear();
61
+ mocks.registerSymphonyCommands.mockClear();
62
+ mocks.registerSymphonyTools.mockClear();
63
+ mocks.setSymphonyStatus.mockClear();
64
+ });
65
+
66
+ describe("symphony extension lifecycle", () => {
67
+ it("cleans and persists owned attachment state when shutdown fails", async () => {
68
+ const pi = extensionApi();
69
+ symphonyExtension(pi);
70
+ const runtime = mocks.instances[0];
71
+ const shutdownError = new Error("shutdown failed");
72
+ runtime.state.ownedProcess = { baseUrl: "http://127.0.0.1:8080" };
73
+ runtime.processManager.shutdown.mockRejectedValueOnce(shutdownError);
74
+
75
+ await expect(mocks.handlers.get("session_shutdown")!()).rejects.toThrow(shutdownError);
76
+
77
+ expect(runtime.clearAttachmentIfBaseUrl).toHaveBeenCalledWith("http://127.0.0.1:8080");
78
+ expect(runtime.persist).toHaveBeenCalledWith(pi);
79
+ });
80
+ });
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerSymphonyCommands, setSymphonyStatus } from "./commands.ts";
3
+ import { SymphonyRuntime } from "./runtime.ts";
4
+ import { registerSymphonyTools } from "./tools.ts";
5
+
6
+ export default function symphonyExtension(pi: ExtensionAPI): void {
7
+ const runtime = new SymphonyRuntime();
8
+
9
+ pi.on("session_start", async (_event, ctx) => {
10
+ runtime.restore(ctx);
11
+ setSymphonyStatus(ctx, runtime);
12
+ });
13
+
14
+ pi.on("session_shutdown", async () => {
15
+ const hadOwnedProcess = runtime.state.stopOwnedOnShutdown && Boolean(runtime.state.ownedProcess);
16
+ const ownedBaseUrl = runtime.state.stopOwnedOnShutdown ? runtime.state.ownedProcess?.baseUrl : undefined;
17
+ try {
18
+ await runtime.processManager.shutdown();
19
+ } finally {
20
+ runtime.clearAttachmentIfBaseUrl(ownedBaseUrl);
21
+ if (hadOwnedProcess) runtime.persist(pi);
22
+ }
23
+ });
24
+
25
+ registerSymphonyCommands(pi, runtime);
26
+ registerSymphonyTools(pi, runtime);
27
+ }
@@ -0,0 +1,24 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const packagePath = resolve(here, "../package.json");
8
+
9
+ describe("pi package manifest", () => {
10
+ it("declares the Pi extension entrypoint and package identity", async () => {
11
+ const manifest = JSON.parse(await readFile(packagePath, "utf8")) as {
12
+ name?: string;
13
+ keywords?: string[];
14
+ pi?: { extensions?: string[] };
15
+ peerDependencies?: Record<string, string>;
16
+ };
17
+
18
+ expect(manifest.name).toBe("@kata-sh/pi-symphony-extension");
19
+ expect(manifest.keywords).toContain("pi-package");
20
+ expect(manifest.pi?.extensions).toEqual(["./src/index.ts"]);
21
+ expect(manifest.peerDependencies?.["@earendil-works/pi-coding-agent"]).toBe("*");
22
+ expect(manifest.peerDependencies?.["@earendil-works/pi-tui"]).toBe("*");
23
+ });
24
+ });
@@ -0,0 +1,232 @@
1
+ import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { createServer, type Server } from "node:http";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { SymphonyProcessManager } from "./process-manager.ts";
7
+ import { createDefaultState } from "./state.ts";
8
+
9
+ let server: Server | undefined;
10
+
11
+ async function stateServer(): Promise<string> {
12
+ server = createServer((_req, res) => {
13
+ res.setHeader("content-type", "application/json");
14
+ res.end(JSON.stringify({
15
+ running: {},
16
+ retry_queue: [],
17
+ blocked: [],
18
+ completed: [],
19
+ polling: { checking: false, next_poll_in_ms: 0, poll_interval_ms: 30000 },
20
+ shared_context: { total_entries: 0, entries_by_scope: {}, oldest_entry_at: null, newest_entry_at: null },
21
+ supervisor: { active: true, steers_issued: 0, conflicts_detected: 0, patterns_detected: 0, escalations_created: 0 },
22
+ codex_totals: { input_tokens: 0, output_tokens: 0, total_tokens: 0, event_count: 0, seconds_running: 0 },
23
+ codex_rate_limits: null,
24
+ }));
25
+ });
26
+ await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
27
+ const address = server.address();
28
+ if (!address || typeof address === "string") throw new Error("expected TCP address");
29
+ return `http://127.0.0.1:${address.port}`;
30
+ }
31
+
32
+ async function writeNodeScript(dir: string, name: string, source: string): Promise<string> {
33
+ const script = join(dir, name);
34
+ await writeFile(script, source, "utf8");
35
+ return script;
36
+ }
37
+
38
+ function isPidRunning(pid: number): boolean {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ } catch (error) {
43
+ const code = (error as NodeJS.ErrnoException).code;
44
+ if (code === "ESRCH") return false;
45
+ if (code === "EPERM") return true;
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ async function waitForProcessExit(pid: number): Promise<void> {
51
+ await expect.poll(() => isPidRunning(pid), { interval: 50, timeout: 3000 }).toBe(false);
52
+ await new Promise((resolve) => setTimeout(resolve, 0));
53
+ }
54
+
55
+ afterEach(async () => {
56
+ if (!server) return;
57
+ await new Promise<void>((resolve, reject) => server!.close((error) => (error ? reject(error) : resolve())));
58
+ server = undefined;
59
+ });
60
+
61
+ describe("SymphonyProcessManager", () => {
62
+ it("starts Symphony with --no-tui and records owned process metadata", async () => {
63
+ const baseUrl = await stateServer();
64
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-process-"));
65
+ const script = join(dir, "fake-symphony.sh");
66
+ await writeFile(script, `#!/bin/sh\necho "dashboard listening at ${baseUrl}"\nsleep 30\n`, "utf8");
67
+ await chmod(script, 0o755);
68
+
69
+ const state = createDefaultState();
70
+ const manager = new SymphonyProcessManager(state);
71
+ const started = await manager.start({ binary: script, cwd: dir, workflow: ".symphony/WORKFLOW.md", timeoutMs: 2000 });
72
+
73
+ expect(started.baseUrl).toBe(baseUrl);
74
+ expect(started.owned).toBe(true);
75
+ expect(state.ownedProcess?.command).toContain("--no-tui");
76
+ expect(state.ownedProcess?.command).toContain(".symphony/WORKFLOW.md");
77
+
78
+ await manager.stopOwned();
79
+ });
80
+
81
+ it("does not mark the runtime attached before the caller attaches", async () => {
82
+ const baseUrl = await stateServer();
83
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-process-"));
84
+ const script = join(dir, "fake-symphony.sh");
85
+ await writeFile(script, `#!/bin/sh\necho "dashboard listening at ${baseUrl}"\nsleep 30\n`, "utf8");
86
+ await chmod(script, 0o755);
87
+
88
+ const state = createDefaultState();
89
+ const manager = new SymphonyProcessManager(state);
90
+ const started = await manager.start({ binary: script, cwd: dir, timeoutMs: 2000 });
91
+
92
+ expect(started.baseUrl).toBe(baseUrl);
93
+ expect(state.attachedBaseUrl).toBeUndefined();
94
+
95
+ await manager.stopOwned();
96
+ });
97
+
98
+ it("reads the workflow server port before probing health", async () => {
99
+ const baseUrl = await stateServer();
100
+ const port = new URL(baseUrl).port;
101
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-process-"));
102
+ const workflowDir = join(dir, ".symphony");
103
+ await mkdir(workflowDir);
104
+ await writeFile(join(workflowDir, "WORKFLOW.md"), `---\nserver:\n port: ${port}\n---\nRun Symphony\n`, "utf8");
105
+ const script = join(dir, "fake-symphony.sh");
106
+ await writeFile(script, "#!/bin/sh\nsleep 30\n", "utf8");
107
+ await chmod(script, 0o755);
108
+
109
+ const state = createDefaultState();
110
+ const manager = new SymphonyProcessManager(state);
111
+ const started = await manager.start({ binary: script, cwd: dir, workflow: ".symphony/WORKFLOW.md", timeoutMs: 2000 });
112
+
113
+ expect(started.baseUrl).toBe(baseUrl);
114
+
115
+ await manager.stopOwned();
116
+ });
117
+
118
+ it("hard-kills a SIGTERM-ignoring owned child and clears state", async () => {
119
+ const baseUrl = await stateServer();
120
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-process-"));
121
+ const script = await writeNodeScript(
122
+ dir,
123
+ "ignore-term.js",
124
+ `process.on("SIGTERM", () => {});\nconsole.log("dashboard listening at ${baseUrl}");\nsetInterval(() => {}, 1000);\n`,
125
+ );
126
+
127
+ const state = createDefaultState();
128
+ const manager = new SymphonyProcessManager(state);
129
+ const started = await manager.start({ binary: process.execPath, cwd: dir, workflow: script, timeoutMs: 2000 });
130
+
131
+ await manager.stopOwned();
132
+
133
+ expect(state.ownedProcess).toBeUndefined();
134
+ await waitForProcessExit(started.pid);
135
+ }, 10_000);
136
+
137
+ it("cleans up a spawned child when startup is aborted", async () => {
138
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-process-"));
139
+ const pidFile = join(dir, "child.pid");
140
+ const script = await writeNodeScript(
141
+ dir,
142
+ "hang.js",
143
+ `const { writeFileSync } = require("node:fs");\nwriteFileSync(${JSON.stringify(pidFile)}, String(process.pid));\nsetInterval(() => {}, 1000);\n`,
144
+ );
145
+
146
+ const state = createDefaultState();
147
+ const manager = new SymphonyProcessManager(state);
148
+ const controller = new AbortController();
149
+ const start = manager.start({ binary: process.execPath, cwd: dir, workflow: script, timeoutMs: 5000, signal: controller.signal });
150
+
151
+ await expect.poll(async () => readFile(pidFile, "utf8").catch(() => ""), { interval: 50, timeout: 1000 }).not.toBe("");
152
+ const pid = Number(await readFile(pidFile, "utf8"));
153
+ controller.abort(new Error("startup cancelled"));
154
+
155
+ await expect(start).rejects.toThrow("startup cancelled");
156
+ expect(state.ownedProcess).toBeUndefined();
157
+ await waitForProcessExit(pid);
158
+ }, 10_000);
159
+
160
+ it("cleans up a spawned child when startup times out", async () => {
161
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-process-"));
162
+ const pidFile = join(dir, "child.pid");
163
+ const script = await writeNodeScript(
164
+ dir,
165
+ "hang-no-api.js",
166
+ `const { writeFileSync } = require("node:fs");\nwriteFileSync(${JSON.stringify(pidFile)}, String(process.pid));\nsetInterval(() => {}, 1000);\n`,
167
+ );
168
+
169
+ const state = createDefaultState();
170
+ const manager = new SymphonyProcessManager(state);
171
+ const start = manager.start({ binary: process.execPath, cwd: dir, workflow: script, timeoutMs: 250 });
172
+
173
+ await expect.poll(async () => readFile(pidFile, "utf8").catch(() => ""), { interval: 50, timeout: 1000 }).not.toBe("");
174
+ const pid = Number(await readFile(pidFile, "utf8"));
175
+
176
+ await expect(start).rejects.toMatchObject({ kind: "start_timeout" });
177
+ expect(state.ownedProcess).toBeUndefined();
178
+ await waitForProcessExit(pid);
179
+ }, 10_000);
180
+
181
+ it("allows start after a previous owned child exits", async () => {
182
+ const baseUrl = await stateServer();
183
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-process-"));
184
+ const quickExit = await writeNodeScript(dir, "quick-exit.js", `console.log("dashboard listening at ${baseUrl}");\nsetTimeout(() => process.exit(0), 100);\n`);
185
+ const longRunning = await writeNodeScript(dir, "long-running.js", `console.log("dashboard listening at ${baseUrl}");\nsetInterval(() => {}, 1000);\n`);
186
+
187
+ const state = createDefaultState();
188
+ const manager = new SymphonyProcessManager(state);
189
+ const first = await manager.start({ binary: process.execPath, cwd: dir, workflow: quickExit, timeoutMs: 2000 });
190
+ await waitForProcessExit(first.pid);
191
+
192
+ const second = await manager.start({ binary: process.execPath, cwd: dir, workflow: longRunning, timeoutMs: 2000 });
193
+
194
+ expect(second.baseUrl).toBe(baseUrl);
195
+ expect(state.ownedProcess?.pid).toBe(second.pid);
196
+
197
+ await manager.stopOwned();
198
+ });
199
+
200
+ it("clears stale owned state when the owned child exits unexpectedly", async () => {
201
+ const baseUrl = await stateServer();
202
+ const dir = await mkdtemp(join(tmpdir(), "pi-symphony-process-"));
203
+ const script = await writeNodeScript(dir, "quick-exit.js", `console.log("dashboard listening at ${baseUrl}");\nsetTimeout(() => process.exit(0), 300);\n`);
204
+
205
+ const state = createDefaultState();
206
+ const manager = new SymphonyProcessManager(state);
207
+ const started = await manager.start({ binary: process.execPath, cwd: dir, workflow: script, timeoutMs: 2000 });
208
+ state.attachedBaseUrl = baseUrl;
209
+ state.lastKnownState = {
210
+ baseUrl,
211
+ runningCount: 0,
212
+ retryCount: 0,
213
+ blockedCount: 0,
214
+ completedCount: 0,
215
+ pollingChecking: false,
216
+ nextPollInMs: 0,
217
+ updatedAt: "2026-05-14T00:00:00.000Z",
218
+ };
219
+ await waitForProcessExit(started.pid);
220
+
221
+ await expect.poll(() => state.ownedProcess, { interval: 50, timeout: 1000 }).toBeUndefined();
222
+ expect(state.attachedBaseUrl).toBeUndefined();
223
+ expect(state.lastKnownState).toBeUndefined();
224
+ await expect(manager.stopOwned()).rejects.toMatchObject({ kind: "not_owned" });
225
+ });
226
+
227
+ it("does not stop when no owned child exists", async () => {
228
+ const state = createDefaultState();
229
+ const manager = new SymphonyProcessManager(state);
230
+ await expect(manager.stopOwned()).rejects.toThrow("No Symphony process owned by this extension");
231
+ });
232
+ });
@@ -0,0 +1,290 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { SymphonyExtensionError } from "./errors.ts";
5
+ import { SymphonyHttpClient } from "./http-client.ts";
6
+ import type { ExtensionState } from "./state.ts";
7
+
8
+ const TERMINATE_GRACE_MS = 2000;
9
+ const KILL_GRACE_MS = 2000;
10
+ const MAX_OUTPUT_CHARS = 64_000;
11
+
12
+ export interface StartOptions {
13
+ binary: string;
14
+ cwd: string;
15
+ workflow?: string;
16
+ timeoutMs?: number;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ export interface StartResult {
21
+ baseUrl: string;
22
+ owned: true;
23
+ pid: number;
24
+ }
25
+
26
+ export class SymphonyProcessManager {
27
+ private child?: ChildProcessWithoutNullStreams;
28
+ private output = "";
29
+
30
+ constructor(private readonly state: ExtensionState) {}
31
+
32
+ async start(options: StartOptions): Promise<StartResult> {
33
+ throwIfAborted(options.signal);
34
+
35
+ if (this.child) {
36
+ if (isChildRunning(this.child)) {
37
+ throw new SymphonyExtensionError("command_failed", "Symphony is already running as an owned child process", {
38
+ pid: this.child.pid,
39
+ });
40
+ }
41
+ this.clearOwnedState();
42
+ }
43
+
44
+ const args = options.workflow ? [options.workflow, "--no-tui"] : ["--no-tui"];
45
+ this.output = "";
46
+ const child = spawn(options.binary, args, { cwd: options.cwd, stdio: "pipe" });
47
+ this.child = child;
48
+ child.once("exit", () => {
49
+ if (this.child === child) this.clearOwnedState();
50
+ });
51
+
52
+ const pid = child.pid;
53
+ if (!pid) {
54
+ this.clearOwnedState();
55
+ throw new SymphonyExtensionError("command_failed", "Failed to spawn Symphony process");
56
+ }
57
+
58
+ child.stdout.on("data", (chunk) => this.appendOutput(chunk));
59
+ child.stderr.on("data", (chunk) => this.appendOutput(chunk));
60
+
61
+ try {
62
+ const baseUrl = await this.waitForReady(options.cwd, options.workflow, options.timeoutMs ?? 10_000, options.signal);
63
+ throwIfAborted(options.signal);
64
+ this.state.ownedProcess = {
65
+ pid,
66
+ command: [options.binary, ...args].join(" "),
67
+ cwd: options.cwd,
68
+ baseUrl,
69
+ startedAt: new Date().toISOString(),
70
+ };
71
+ if (!isChildRunning(child)) this.clearOwnedState();
72
+
73
+ return { baseUrl, owned: true, pid };
74
+ } catch (error) {
75
+ try {
76
+ await this.stopOwnedInternal(false);
77
+ } catch {
78
+ this.clearOwnedState();
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ async stopOwned(): Promise<void> {
85
+ await this.stopOwnedInternal(true);
86
+ }
87
+
88
+ async shutdown(): Promise<void> {
89
+ if (!this.state.stopOwnedOnShutdown) return;
90
+ await this.stopOwnedInternal(false);
91
+ }
92
+
93
+ private async stopOwnedInternal(throwIfNotOwned: boolean): Promise<void> {
94
+ if (!this.child || !isChildRunning(this.child)) {
95
+ this.clearOwnedState();
96
+ if (throwIfNotOwned) {
97
+ throw new SymphonyExtensionError("not_owned", "No Symphony process owned by this extension is running");
98
+ }
99
+ return;
100
+ }
101
+
102
+ const child = this.child;
103
+ child.kill("SIGTERM");
104
+ await waitForExitOrTimeout(child, TERMINATE_GRACE_MS);
105
+
106
+ if (isChildRunning(child)) {
107
+ child.kill("SIGKILL");
108
+ await waitForExitOrTimeout(child, KILL_GRACE_MS);
109
+ }
110
+
111
+ this.clearOwnedState();
112
+ }
113
+
114
+ private clearOwnedState(): void {
115
+ const ownedBaseUrl = this.state.ownedProcess?.baseUrl;
116
+ this.child = undefined;
117
+ this.state.ownedProcess = undefined;
118
+ if (ownedBaseUrl && this.state.attachedBaseUrl === ownedBaseUrl) {
119
+ this.state.attachedBaseUrl = undefined;
120
+ this.state.lastKnownState = undefined;
121
+ }
122
+ }
123
+
124
+ private appendOutput(chunk: unknown): void {
125
+ this.output = (this.output + String(chunk)).slice(-MAX_OUTPUT_CHARS);
126
+ }
127
+
128
+ private async waitForReady(cwd: string, workflow: string | undefined, timeoutMs: number, signal?: AbortSignal): Promise<string> {
129
+ const started = Date.now();
130
+ let lastError: unknown;
131
+
132
+ while (Date.now() - started < timeoutMs) {
133
+ throwIfAborted(signal);
134
+ const baseUrl = this.detectOutputBaseUrl() ?? (Date.now() - started >= Math.min(500, timeoutMs) ? this.detectBaseUrl(cwd, workflow) : undefined);
135
+ if (baseUrl) {
136
+ try {
137
+ const client = new SymphonyHttpClient(baseUrl);
138
+ await client.verify(signal);
139
+ return baseUrl;
140
+ } catch (error) {
141
+ if (signal?.aborted) throw error;
142
+ lastError = error;
143
+ }
144
+ }
145
+
146
+ if (this.child && !isChildRunning(this.child)) break;
147
+ await delay(150, signal);
148
+ }
149
+
150
+ throw new SymphonyExtensionError("start_timeout", "Timed out waiting for Symphony HTTP API", {
151
+ expectedBaseUrl: this.detectBaseUrl(cwd, workflow),
152
+ output: this.output.slice(-4000),
153
+ childExitCode: this.child?.exitCode,
154
+ cause: lastError instanceof Error ? lastError.message : String(lastError),
155
+ });
156
+ }
157
+
158
+ private detectBaseUrl(cwd: string, workflow: string | undefined): string {
159
+ const outputBaseUrl = this.detectOutputBaseUrl();
160
+ if (outputBaseUrl) return outputBaseUrl;
161
+
162
+ const workflowPath = workflow ? resolve(cwd, workflow) : join(cwd, ".symphony", "WORKFLOW.md");
163
+ const configured = readWorkflowServerConfigSyncBestEffort(workflowPath);
164
+ return `http://${configured.host}:${configured.port}`;
165
+ }
166
+
167
+ private detectOutputBaseUrl(): string | undefined {
168
+ return this.output.match(/https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+/)?.[0];
169
+ }
170
+ }
171
+
172
+ function isChildRunning(child: ChildProcessWithoutNullStreams): boolean {
173
+ return child.exitCode === null && child.signalCode === null;
174
+ }
175
+
176
+ function throwIfAborted(signal?: AbortSignal): void {
177
+ if (!signal?.aborted) return;
178
+ throw signal.reason instanceof Error ? signal.reason : new DOMException("This operation was aborted", "AbortError");
179
+ }
180
+
181
+ async function delay(timeoutMs: number, signal?: AbortSignal): Promise<void> {
182
+ throwIfAborted(signal);
183
+ await new Promise<void>((resolve, reject) => {
184
+ let settled = false;
185
+ const done = () => {
186
+ if (settled) return;
187
+ settled = true;
188
+ clearTimeout(timeout);
189
+ signal?.removeEventListener("abort", abort);
190
+ resolve();
191
+ };
192
+ const abort = () => {
193
+ if (settled) return;
194
+ settled = true;
195
+ clearTimeout(timeout);
196
+ signal?.removeEventListener("abort", abort);
197
+ reject(signal?.reason instanceof Error ? signal.reason : new DOMException("This operation was aborted", "AbortError"));
198
+ };
199
+ const timeout = setTimeout(done, timeoutMs);
200
+ signal?.addEventListener("abort", abort, { once: true });
201
+ if (signal?.aborted) abort();
202
+ });
203
+ }
204
+
205
+ async function waitForExitOrTimeout(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> {
206
+ if (!isChildRunning(child)) return;
207
+
208
+ await new Promise<void>((resolve) => {
209
+ let settled = false;
210
+ const done = () => {
211
+ if (settled) return;
212
+ settled = true;
213
+ clearTimeout(timeout);
214
+ child.off("exit", done);
215
+ resolve();
216
+ };
217
+ const timeout = setTimeout(done, timeoutMs);
218
+ child.once("exit", done);
219
+ if (!isChildRunning(child)) done();
220
+ });
221
+ }
222
+
223
+ function readWorkflowServerConfigSyncBestEffort(workflowPath: string): { host: string; port: number } {
224
+ try {
225
+ return parseWorkflowServerConfig(readFileSync(workflowPath, "utf8"));
226
+ } catch {
227
+ return { host: "127.0.0.1", port: 8080 };
228
+ }
229
+ }
230
+
231
+ function parseWorkflowServerConfig(content: string): { host: string; port: number } {
232
+ const yaml = extractYamlFrontmatter(content) ?? content;
233
+ let inServerSection = false;
234
+ let serverIndent = -1;
235
+ let host = "127.0.0.1";
236
+ let port = 8080;
237
+
238
+ for (const rawLine of yaml.split(/\r?\n/)) {
239
+ const line = stripYamlComment(rawLine).trimEnd();
240
+ if (!line.trim()) continue;
241
+ const indent = leadingWhitespaceLength(line);
242
+ const trimmed = line.trim();
243
+
244
+ if (trimmed === "server:") {
245
+ inServerSection = true;
246
+ serverIndent = indent;
247
+ continue;
248
+ }
249
+
250
+ if (inServerSection && indent <= serverIndent) {
251
+ inServerSection = false;
252
+ }
253
+
254
+ if (!inServerSection) continue;
255
+
256
+ const match = trimmed.match(/^(host|port):\s*(.+)$/);
257
+ if (!match) continue;
258
+ const [, key, rawValue] = match;
259
+ const value = parseYamlScalar(rawValue);
260
+ if (key === "host" && value) host = value;
261
+ if (key === "port") {
262
+ const parsedPort = Number(value);
263
+ if (Number.isInteger(parsedPort) && parsedPort >= 0 && parsedPort <= 65535) port = parsedPort;
264
+ }
265
+ }
266
+
267
+ return { host, port };
268
+ }
269
+
270
+ function extractYamlFrontmatter(content: string): string | undefined {
271
+ const lines = content.split(/\r?\n/);
272
+ if (lines[0]?.trim() !== "---") return undefined;
273
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
274
+ if (end === -1) return undefined;
275
+ return lines.slice(1, end).join("\n");
276
+ }
277
+
278
+ function stripYamlComment(line: string): string {
279
+ return line.replace(/\s+#.*$/, "");
280
+ }
281
+
282
+ function leadingWhitespaceLength(value: string): number {
283
+ return value.length - value.trimStart().length;
284
+ }
285
+
286
+ function parseYamlScalar(value: string): string {
287
+ const trimmed = value.trim();
288
+ const quoted = trimmed.match(/^(["'])(.*)\1$/);
289
+ return quoted ? quoted[2] : trimmed;
290
+ }