iriai-build 0.5.2 → 0.6.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.
package/bridge-v3.js CHANGED
@@ -47,7 +47,7 @@ async function start() {
47
47
  console.log(`Slack connected. Bot user: ${adapter.botUserId}`);
48
48
 
49
49
  // 3. Create orchestrator with review session support
50
- const reviewSessions = new ReviewSessionManager();
50
+ const reviewSessions = new ReviewSessionManager({ portalPort: PORTAL_PORT });
51
51
  const orchestrator = new Orchestrator({ adapter, reviewSessions });
52
52
  _orchestrator = orchestrator;
53
53
  adapter.setOrchestrator(orchestrator);
package/cli/bootstrap.js CHANGED
@@ -24,7 +24,7 @@ export function bootstrap({ budgetOverride } = {}) {
24
24
 
25
25
  const budget = resolveBudget(budgetOverride);
26
26
  const adapter = new TerminalAdapter();
27
- const reviewSessions = new ReviewSessionManager();
27
+ const reviewSessions = new ReviewSessionManager({ portalPort: PORTAL_PORT });
28
28
  const orchestrator = new Orchestrator({ adapter, reviewSessions, budget });
29
29
  const input = new TerminalInput({ orchestrator });
30
30
  adapter.setInputHandler(input);
@@ -41,7 +41,7 @@ export async function slackCommand() {
41
41
  await adapter.connect();
42
42
  console.log(`Slack connected. Bot user: ${adapter.botUserId}`);
43
43
 
44
- const reviewSessions = new ReviewSessionManager();
44
+ const reviewSessions = new ReviewSessionManager({ portalPort: PORTAL_PORT });
45
45
  const orchestrator = new Orchestrator({ adapter, reviewSessions });
46
46
  adapter.setOrchestrator(orchestrator);
47
47
 
@@ -8,7 +8,7 @@ import { Orchestrator } from "../../v3/orchestrator.js";
8
8
  import { Recovery } from "../../v3/recovery.js";
9
9
  import { ReviewSessionManager } from "../../v3/review-sessions.js";
10
10
  import * as queries from "../../v3/queries.js";
11
- import { DB_PATH } from "../../v3/constants.js";
11
+ import { DB_PATH, PORTAL_PORT } from "../../v3/constants.js";
12
12
  import * as config from "../config.js";
13
13
  import { banner, systemMsg, successMsg, errorMsg } from "../display.js";
14
14
 
@@ -50,7 +50,7 @@ export async function transferToSlackCommand() {
50
50
  successMsg(`Slack connected. Bot user: ${adapter.botUserId}`);
51
51
 
52
52
  // Create orchestrator
53
- const reviewSessions = new ReviewSessionManager();
53
+ const reviewSessions = new ReviewSessionManager({ portalPort: PORTAL_PORT });
54
54
  const orchestrator = new Orchestrator({ adapter, reviewSessions });
55
55
  adapter.setOrchestrator(orchestrator);
56
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iriai-build",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Iriai Build tool — AI agent orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -147,7 +147,8 @@ export class SlackAdapter extends InterfaceAdapter {
147
147
  async uploadArtifact(featureId, filePath, title) {
148
148
  const channel = this._resolveChannel(featureId);
149
149
  const feature = queries.getFeatureById(featureId);
150
- const threadTs = feature?.thread_ts || null;
150
+ // Only thread uploads in the planning channel — feature channels don't have this thread
151
+ const threadTs = (channel === this.planningChannel) ? (feature?.thread_ts || null) : null;
151
152
  await slackUploadArtifact(this.web, channel, threadTs, filePath, title);
152
153
  }
153
154
 
@@ -216,10 +217,11 @@ export class SlackAdapter extends InterfaceAdapter {
216
217
  const designPath = findArtifact("design-decisions", planDir);
217
218
  const planPath = findArtifact("implementation-plan", planDir);
218
219
 
219
- // Upload artifacts FIRST
220
- if (prdPath) await slackUploadArtifact(this.web, channel, feature.thread_ts, prdPath, "PRD");
221
- if (designPath) await slackUploadArtifact(this.web, channel, feature.thread_ts, designPath, "Design Decisions");
222
- if (planPath) await slackUploadArtifact(this.web, channel, feature.thread_ts, planPath, "Implementation Plan");
220
+ // Upload artifacts FIRST — no thread_ts in feature channels (thread belongs to planning channel)
221
+ const artThreadTs = (channel === this.planningChannel) ? feature.thread_ts : null;
222
+ if (prdPath) await slackUploadArtifact(this.web, channel, artThreadTs, prdPath, "PRD");
223
+ if (designPath) await slackUploadArtifact(this.web, channel, artThreadTs, designPath, "Design Decisions");
224
+ if (planPath) await slackUploadArtifact(this.web, channel, artThreadTs, planPath, "Implementation Plan");
223
225
 
224
226
  // Post approval with Block Kit buttons AFTER uploads
225
227
  const blocks = buildDecisionBlocks(
@@ -2,7 +2,7 @@
2
2
  // Serves on a single port (default 8900) with feature index, tabbed detail, and individual artifact views.
3
3
  // Reuses plan-compiler.js CSS/rendering patterns (Charter fonts, frosted glass, marked.js + mermaid.js).
4
4
 
5
- import { createServer } from "node:http";
5
+ import { createServer, request as httpRequest } from "node:http";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { IMPL_BASE } from "./constants.js";
@@ -61,6 +61,12 @@ export class ArtifactPortal {
61
61
  return this._serveArtifact(res, artifactMatch[1], artifactMatch[2]);
62
62
  }
63
63
 
64
+ // Review session proxy: /review/<decisionId>/... → internal iriai-feedback port
65
+ const reviewMatch = pathname.match(/^\/review\/([^/]+)(\/.*)?$/);
66
+ if (reviewMatch) {
67
+ return this._proxyReviewSession(req, res, decodeURIComponent(reviewMatch[1]), reviewMatch[2] || "/");
68
+ }
69
+
64
70
  res.writeHead(404, { "Content-Type": "text/html" });
65
71
  res.end(this._render404());
66
72
  } catch (err) {
@@ -158,6 +164,51 @@ export class ArtifactPortal {
158
164
  res.end(content);
159
165
  }
160
166
 
167
+ // ─── Review Session Proxy ─────────────────────────────────────────
168
+
169
+ /**
170
+ * Reverse proxy requests from /review/<decisionId>/... to the internal
171
+ * iriai-feedback port for that session. Supports multiple concurrent
172
+ * review sessions (e.g., parallel gate reviews for different teams).
173
+ */
174
+ _proxyReviewSession(clientReq, clientRes, decisionId, subpath) {
175
+ if (!this._reviewSessions) {
176
+ clientRes.writeHead(502, { "Content-Type": "text/plain" });
177
+ clientRes.end("Review sessions not available");
178
+ return;
179
+ }
180
+
181
+ const port = this._reviewSessions.getSessionPort(decisionId);
182
+ if (!port) {
183
+ clientRes.writeHead(404, { "Content-Type": "text/plain" });
184
+ clientRes.end(`No active review session for: ${decisionId}`);
185
+ return;
186
+ }
187
+
188
+ const proxyOpts = {
189
+ hostname: "127.0.0.1",
190
+ port,
191
+ path: subpath,
192
+ method: clientReq.method,
193
+ headers: { ...clientReq.headers, host: `localhost:${port}` },
194
+ };
195
+
196
+ const proxyReq = httpRequest(proxyOpts, (proxyRes) => {
197
+ clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
198
+ proxyRes.pipe(clientRes, { end: true });
199
+ });
200
+
201
+ proxyReq.on("error", (err) => {
202
+ console.error(`[artifact-portal] Review proxy error (${decisionId}):`, err.message);
203
+ if (!clientRes.headersSent) {
204
+ clientRes.writeHead(502, { "Content-Type": "text/plain" });
205
+ clientRes.end("Review session unavailable");
206
+ }
207
+ });
208
+
209
+ clientReq.pipe(proxyReq, { end: true });
210
+ }
211
+
161
212
  // ─── Data Helpers ──────────────────────────────────────────────────
162
213
 
163
214
  _getAllFeaturesWithArtifacts() {
@@ -1653,6 +1653,97 @@ export class Orchestrator {
1653
1653
  queries.insertEvent(featureId, "gate-rejected", "bridge", `Gate rejected (teams ${reviewTeams.join(", ")}): ${reason}`);
1654
1654
  }
1655
1655
 
1656
+ /**
1657
+ * Handle a gate [DECISION] block from the Feature Lead's .agent-response.
1658
+ * Mirrors the planning deferred-decision pattern: start doc-review, create
1659
+ * the decision as deferred, relay through operator as "decision-needed",
1660
+ * and ack the FL to stop re-posting.
1661
+ */
1662
+ async _handleGateDecisionFromFL(feature, gateDecision, plainText) {
1663
+ const tree = this._signalTrees[feature.slug];
1664
+
1665
+ const options = gateDecision.options.map(o => ({
1666
+ id: o.id,
1667
+ label: o.label,
1668
+ style: o.style || "default",
1669
+ description: o.description || "",
1670
+ }));
1671
+
1672
+ // Start doc-review session for gate evidence HTML (same as inline gate code in impl:operatorResponse)
1673
+ let reviewUrl = null;
1674
+ let qaUrl = null;
1675
+ const reviewSessionKey = `${gateDecision.id}@${feature.slug}`;
1676
+
1677
+ if (this.reviewSessions) {
1678
+ const evidencePath = path.join(feature.signal_dir, "feature-lead", ".gate-evidence.html");
1679
+ if (fs.existsSync(evidencePath)) {
1680
+ reviewUrl = await this.reviewSessions.startDocReview(
1681
+ reviewSessionKey, evidencePath, `Gate Evidence — ${gateDecision.title}`, { featureId: feature.id }
1682
+ );
1683
+ }
1684
+
1685
+ // Start QA session if verification config specifies local-server
1686
+ const verification = this._readVerificationConfig(feature);
1687
+ if (verification && verification.type === "local-server" && verification.url) {
1688
+ let devServerOk = !verification.command;
1689
+ if (verification.command) {
1690
+ try {
1691
+ const featureReposDir = path.join(PROJECT_ROOT, ".features", feature.slug, "repos");
1692
+ await new Promise((resolve) => {
1693
+ const proc = cpSpawn("sh", ["-c", verification.command], {
1694
+ cwd: featureReposDir,
1695
+ stdio: "ignore",
1696
+ detached: true,
1697
+ });
1698
+ proc.on("error", (err) => {
1699
+ console.error(`[orchestrator] Dev server spawn error: ${err.message}`);
1700
+ resolve(false);
1701
+ });
1702
+ proc.unref();
1703
+ setTimeout(() => resolve(true), 3000);
1704
+ }).then(ok => { devServerOk = ok; });
1705
+ } catch (err) {
1706
+ console.error(`[orchestrator] Failed to start dev server for gate review:`, err.message);
1707
+ }
1708
+ }
1709
+ if (devServerOk) {
1710
+ qaUrl = await this.reviewSessions.startQaSession(
1711
+ reviewSessionKey, verification.url, { featureId: feature.id }
1712
+ );
1713
+ }
1714
+ }
1715
+ }
1716
+
1717
+ const decision = {
1718
+ id: gateDecision.id,
1719
+ title: gateDecision.title,
1720
+ context: gateDecision.context,
1721
+ type: gateDecision.type || "approval",
1722
+ options,
1723
+ reviewUrl,
1724
+ qaUrl,
1725
+ };
1726
+
1727
+ // Defer the decision post until after the Operator relay completes
1728
+ this._deferredDecisions[feature.id] = { decision, featureId: feature.id };
1729
+
1730
+ // Notify the Operator via relay (eventHint: "decision-needed" ensures
1731
+ // fallback skips raw text and just posts the deferred decision)
1732
+ this._notifyOperatorOfDecision(feature, decision, plainText);
1733
+
1734
+ // Ack the FL so it stops polling / re-posting gate evidence
1735
+ if (tree?.featureLead) {
1736
+ writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE),
1737
+ `Gate evidence received and posted for user review. Waiting for user decision — do NOT re-post.`);
1738
+ }
1739
+
1740
+ // Pre-set gate_evidence_ts with a placeholder so FL refresh prompts know
1741
+ // evidence was already posted (actual Slack ts is set after deferred post)
1742
+ queries.updateFeatureGateEvidenceTs(feature.id, "pending");
1743
+
1744
+ console.log(`[orchestrator] Gate decision ${gateDecision.id} intercepted from FL, deferred for operator relay`);
1745
+ }
1746
+
1656
1747
  // ─── Gate Team Dependency Resolution ─────────────────────────────────
1657
1748
 
1658
1749
  /**
@@ -2272,6 +2363,16 @@ export class Orchestrator {
2272
2363
  if (!content) return;
2273
2364
  const feature = queries.getFeatureBySlug(slug);
2274
2365
  if (!feature) return;
2366
+
2367
+ // Intercept gate decisions: use deferred decision pattern (same as planning)
2368
+ // so the gate review gets doc-review + properly formatted Block Kit buttons
2369
+ const parsed = parseOperatorResponse(content);
2370
+ const gateDecision = parsed.decisions.find(d => d.id && d.id.startsWith("gate-"));
2371
+ if (gateDecision) {
2372
+ await this._handleGateDecisionFromFL(feature, gateDecision, parsed.plainText);
2373
+ return;
2374
+ }
2375
+
2275
2376
  await this._enqueueForOperatorRelay(feature, "Feature Lead", "fl-response", content);
2276
2377
  } catch (err) {
2277
2378
  console.error("[orchestrator] Impl response error:", err.message);
@@ -3405,16 +3506,40 @@ modify a SPECIFIC section — never re-read them in full.\n`;
3405
3506
  }
3406
3507
 
3407
3508
  /**
3408
- * Post a deferred decision (stored by _requestPhaseReview) after the Operator
3409
- * relay completes, so the decision buttons appear after the Operator's message.
3509
+ * Post a deferred decision (stored by _requestPhaseReview or _handleGateDecisionFromFL)
3510
+ * after the Operator relay completes, so the decision buttons appear after the Operator's message.
3410
3511
  */
3411
3512
  async _postDeferredDecision(featureId) {
3412
3513
  const deferred = this._deferredDecisions[featureId];
3413
3514
  if (!deferred) return;
3414
3515
  delete this._deferredDecisions[featureId];
3415
3516
 
3517
+ const { decision } = deferred;
3518
+
3416
3519
  try {
3417
- await this.adapter.postDecision(deferred.featureId, deferred.decision);
3520
+ const decResult = await this.adapter.postDecision(deferred.featureId, decision);
3521
+
3522
+ // For gate decisions: store in DB and update gate_evidence_ts with actual ref
3523
+ if (decision.type === "approval" && decision.id.startsWith("gate-")) {
3524
+ queries.createDecision({
3525
+ featureId: deferred.featureId,
3526
+ decisionId: decision.id,
3527
+ decisionType: decision.type || "approval",
3528
+ title: decision.title,
3529
+ contextText: decision.context || null,
3530
+ options: decision.options,
3531
+ });
3532
+
3533
+ const feature = queries.getFeatureById(deferred.featureId);
3534
+ const decChannel = feature?.feature_channel || this.adapter.planningChannel;
3535
+ queries.updateDecisionSlack(deferred.featureId, decision.id, {
3536
+ slackTs: decResult?.ref || null,
3537
+ slackChannel: decChannel || null,
3538
+ permalink: null,
3539
+ });
3540
+
3541
+ queries.updateFeatureGateEvidenceTs(deferred.featureId, decResult?.ref || null);
3542
+ }
3418
3543
  } catch (err) {
3419
3544
  console.error("[orchestrator] Failed to post deferred decision:", err.message);
3420
3545
  }
@@ -1,11 +1,16 @@
1
1
  // review-sessions.js — Manages interactive review tool sessions for decisions.
2
2
  // Loosely coupled: uses iriai-feedback CLI (child_process.spawn) + HTTP API.
3
3
  // No direct imports from iriai-feedback.
4
+ //
5
+ // Doc-review and QA sessions are proxied through the artifact portal so users
6
+ // see stable URLs (http://localhost:<portalPort>/review/<decisionId>/...) instead
7
+ // of ephemeral per-session ports.
4
8
 
5
9
  import { spawn } from "node:child_process";
6
10
  import { createServer } from "node:http";
7
11
  import { get as httpGet } from "node:http";
8
12
  import { readFile } from "node:fs/promises";
13
+ import { createConnection } from "node:net";
9
14
  import * as queries from "./queries.js";
10
15
  import { get as configGet } from "../cli/config.js";
11
16
 
@@ -14,18 +19,55 @@ const PORT_MIN = 9001;
14
19
  const PORT_MAX = 9020;
15
20
 
16
21
  export class ReviewSessionManager {
17
- constructor() {
22
+ constructor({ portalPort } = {}) {
18
23
  this._sessions = new Map(); // decisionId -> { sessionId, url, port, process, type, qa? }
19
24
  this._nextPort = PORT_MIN;
25
+ this._portalPort = portalPort || 8900;
20
26
  }
21
27
 
22
- _allocPort() {
28
+ /**
29
+ * Probe whether a port is actually free by attempting a TCP connection.
30
+ * Returns true if nothing is listening (connection refused), false if occupied.
31
+ */
32
+ _isPortFree(port) {
33
+ return new Promise((resolve) => {
34
+ const conn = createConnection({ port, host: "127.0.0.1" });
35
+ conn.once("connect", () => {
36
+ conn.destroy();
37
+ resolve(false); // something is listening → port is occupied
38
+ });
39
+ conn.once("error", () => {
40
+ resolve(true); // connection refused → port is free
41
+ });
42
+ conn.setTimeout(500, () => {
43
+ conn.destroy();
44
+ resolve(true); // timeout → assume free
45
+ });
46
+ });
47
+ }
48
+
49
+ async _allocPort() {
23
50
  const usedPorts = new Set(
24
51
  [...this._sessions.values()].flatMap(s =>
25
52
  [s.port, s.qa?.port].filter(Boolean)
26
53
  )
27
54
  );
28
55
  let port = this._nextPort;
56
+ let attempts = 0;
57
+ const range = PORT_MAX - PORT_MIN + 1;
58
+
59
+ while (attempts < range) {
60
+ if (!usedPorts.has(port) && await this._isPortFree(port)) {
61
+ this._nextPort = port >= PORT_MAX ? PORT_MIN : port + 1;
62
+ return port;
63
+ }
64
+ port = port >= PORT_MAX ? PORT_MIN : port + 1;
65
+ attempts++;
66
+ }
67
+
68
+ // Fallback: return next non-map port (best effort)
69
+ console.warn("[review-sessions] No verified-free ports; falling back to map-only check");
70
+ port = this._nextPort;
29
71
  while (usedPorts.has(port)) {
30
72
  port = port >= PORT_MAX ? PORT_MIN : port + 1;
31
73
  }
@@ -33,19 +75,27 @@ export class ReviewSessionManager {
33
75
  return port;
34
76
  }
35
77
 
78
+ /**
79
+ * Build the user-facing URL for a review session.
80
+ * Routes through the artifact portal's reverse proxy.
81
+ */
82
+ _buildReviewUrl(decisionId, subpath = "/doc-review") {
83
+ return `http://localhost:${this._portalPort}/review/${encodeURIComponent(decisionId)}${subpath}`;
84
+ }
85
+
36
86
  /**
37
87
  * Start a doc review session for a decision.
38
88
  * Spawns `iriai-feedback review <docPath> --port <port>`.
39
- * Returns the review URL.
89
+ * Returns the portal-proxied review URL.
40
90
  */
41
91
  async startDocReview(decisionId, docPath, title, { featureId, port: preferredPort } = {}) {
42
- const port = preferredPort || this._allocPort();
92
+ const port = preferredPort || await this._allocPort();
43
93
  const args = ["review", docPath, "--port", String(port)];
44
94
  if (title) args.push("--title", title);
45
95
 
46
96
  try {
47
97
  const { proc, sessionId } = await this._spawnAndParse(args);
48
- const url = `http://localhost:${port}/doc-review`;
98
+ const url = this._buildReviewUrl(decisionId);
49
99
  this._sessions.set(decisionId, { sessionId, url, port, process: proc, type: "doc", docPath });
50
100
 
51
101
  // Persist to SQLite for restart recovery
@@ -69,7 +119,7 @@ export class ReviewSessionManager {
69
119
  */
70
120
  async startMockupReview(decisionId, htmlPath, { featureId } = {}) {
71
121
  // 1. Spin up a tiny static file server for the HTML mockup
72
- const staticPort = this._allocPort();
122
+ const staticPort = await this._allocPort();
73
123
  const staticServer = await this._startStaticServer(htmlPath, staticPort);
74
124
 
75
125
  // 2. Wrap it with QA proxy
@@ -128,12 +178,12 @@ export class ReviewSessionManager {
128
178
  * Attaches to an existing decision entry if one exists.
129
179
  */
130
180
  async startQaSession(decisionId, targetUrl, { featureId, port: preferredPort } = {}) {
131
- const port = preferredPort || this._allocPort();
181
+ const port = preferredPort || await this._allocPort();
132
182
  const args = ["start", targetUrl, "--port", String(port)];
133
183
 
134
184
  try {
135
185
  const { proc, sessionId } = await this._spawnAndParse(args);
136
- const qaUrl = `http://localhost:${port}`;
186
+ const qaUrl = this._buildReviewUrl(decisionId, "");
137
187
  const existing = this._sessions.get(decisionId);
138
188
 
139
189
  if (existing) {
@@ -156,6 +206,22 @@ export class ReviewSessionManager {
156
206
  }
157
207
  }
158
208
 
209
+ /**
210
+ * Get the internal port for a session (used by the artifact portal proxy).
211
+ */
212
+ getSessionPort(decisionId) {
213
+ const session = this._sessions.get(decisionId);
214
+ return session?.port || null;
215
+ }
216
+
217
+ /**
218
+ * Get the QA session port for a session (used by the artifact portal proxy).
219
+ */
220
+ getQaPort(decisionId) {
221
+ const session = this._sessions.get(decisionId);
222
+ return session?.qa?.port || null;
223
+ }
224
+
159
225
  /**
160
226
  * Collect annotations from all active review servers for a decision.
161
227
  * Returns array of annotations or null if none.
@@ -219,17 +285,15 @@ export class ReviewSessionManager {
219
285
  });
220
286
  }
221
287
 
222
- // Re-spawn the doc review server with the same port and doc path
288
+ // Re-spawn the doc review server (don't reuse stored port it may be stale)
223
289
  const url = await this.startDocReview(decisionId, row.doc_path, null, {
224
290
  featureId: row.feature_id,
225
- port: row.port,
226
291
  });
227
292
 
228
293
  // Re-spawn QA session if one was active
229
- if (row.qa_target_url && row.qa_port) {
294
+ if (row.qa_target_url) {
230
295
  await this.startQaSession(decisionId, row.qa_target_url, {
231
296
  featureId: row.feature_id,
232
- port: row.qa_port,
233
297
  });
234
298
  }
235
299
 
@@ -243,6 +307,19 @@ export class ReviewSessionManager {
243
307
  return this._sessions.has(decisionId);
244
308
  }
245
309
 
310
+ /**
311
+ * Get all active sessions (for portal listing).
312
+ */
313
+ getAllSessions() {
314
+ return [...this._sessions.entries()].map(([id, s]) => ({
315
+ decisionId: id,
316
+ type: s.type,
317
+ port: s.port,
318
+ url: s.url,
319
+ docPath: s.docPath,
320
+ }));
321
+ }
322
+
246
323
  // ─── Internal helpers ────────────────────────────────────────────────
247
324
 
248
325
  /**
@@ -258,13 +335,20 @@ export class ReviewSessionManager {
258
335
  });
259
336
 
260
337
  let stdout = "";
338
+ let stderr = "";
261
339
  let resolved = false;
262
340
 
263
341
  const timer = setTimeout(() => {
264
342
  if (!resolved) {
265
343
  resolved = true;
266
- // Even if we didn't parse a session ID, the server may be running
267
- resolve({ proc, sessionId: `session-${Date.now()}` });
344
+ // Check stderr for bind errors before accepting
345
+ if (stderr.includes("EADDRINUSE") || stderr.includes("address already in use")) {
346
+ reject(new Error(`Port already in use: ${stderr.trim()}`));
347
+ try { proc.kill("SIGKILL"); } catch { /* ok */ }
348
+ } else {
349
+ // Even if we didn't parse a session ID, the server may be running
350
+ resolve({ proc, sessionId: `session-${Date.now()}` });
351
+ }
268
352
  }
269
353
  }, 5000);
270
354
 
@@ -282,6 +366,17 @@ export class ReviewSessionManager {
282
366
  }
283
367
  });
284
368
 
369
+ proc.stderr.on("data", (chunk) => {
370
+ stderr += chunk.toString();
371
+ // Detect bind errors immediately
372
+ if (!resolved && (stderr.includes("EADDRINUSE") || stderr.includes("address already in use"))) {
373
+ resolved = true;
374
+ clearTimeout(timer);
375
+ reject(new Error(`Port already in use: ${stderr.trim()}`));
376
+ try { proc.kill("SIGKILL"); } catch { /* ok */ }
377
+ }
378
+ });
379
+
285
380
  proc.on("error", (err) => {
286
381
  if (!resolved) {
287
382
  resolved = true;
@@ -324,7 +419,12 @@ export class ReviewSessionManager {
324
419
  _killProc(proc) {
325
420
  if (!proc) return;
326
421
  try {
327
- proc.kill("SIGINT");
422
+ proc.kill("SIGTERM");
423
+ // Follow up with SIGKILL after grace period to ensure cleanup
424
+ const killTimer = setTimeout(() => {
425
+ try { proc.kill("SIGKILL"); } catch { /* already dead */ }
426
+ }, 2000);
427
+ killTimer.unref();
328
428
  } catch {
329
429
  // Process may already be dead
330
430
  }