Track terminal listening ports in activity events and thread state#43

closed16
main
codething/50e6e9da
openedFeb 14, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 4d ago
31 unresolved threads Β· Jump to file
Selected thread: apps/server/src/process/Layers/TerminalProcessInspector.test.ts:38 β€” 🟒 **Low** `terminalProcessInspector/index.test.ts:20` If the effect returned by `stopProcess` is interrupted before the process exits, the `setTimeout` at line 28 keeps running and the `exit` listener at line 34 remains attached β€” the cleanup function at line 86 isn't returned, so interruption leaks resources. Consider returning `Effect.sync(cleanup)` so interruption cancels the timeout and removes the listener. No longer relevant as of bc30ac51f5dd298fd122031d1a70989b6b6a242f
0 / 25 viewed25 files changedLine totals unavailable

apps/server/src/terminalManager.ts Β· Earlier revision 7926636a Β· right 193–197

Original diff
@@ -129,11 +139,62 @@ function isRetryableShellSpawnError(error: unknown): boolean {
   );
 }
 
-async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> {
+function normalizeRunningPorts(ports: number[]): number[] {
+  if (ports.length === 0) return [];
+  return [...new Set(ports)]
+    .filter((port) => Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER)
+    .toSorted((left, right) => left - right);
+}
+
+function parsePidList(stdout: string): number[] {
+  const pids: number[] = [];
+  for (const line of stdout.split(/\r?\n/g)) {
+    const pid = Number(line.trim());
+    if (!Number.isInteger(pid) || pid <= 0) {
+      continue;
+    }
+    pids.push(pid);
+  }
+  return [...new Set(pids)];
+}
+
+function parsePortList(stdout: string): number[] {
+  const ports: number[] = [];
+  for (const line of stdout.split(/\r?\n/g)) {
+    const port = Number(line.trim());
+    if (!Number.isInteger(port)) {
+      continue;
+    }
+    ports.push(port);
+  }
+  return normalizeRunningPorts(ports);
+}
+
+function portFromAddress(address: string): number | null {
+  const match = address.match(/:(\d+)$/);
+  if (!match?.[1]) return null;
+  const port = Number(match[1]);
+  if (!Number.isInteger(port) || port <= 0 || port > MAX_PORT_NUMBER) {
+    return null;
+  }
+  return port;
+}
+
+function arePortListsEqual(left: number[], right: number[]): boolean {
+  if (left.length !== right.length) return false;
+  for (let index = 0; index < left.length; index += 1) {
+    if (left[index] !== right[index]) {
+      return false;
+    }
+  }
+  return true;
+}
+
+async function collectWindowsChildPids(terminalPid: number): Promise<number[]> {
   const command = [
     `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,
-    "if ($children) { exit 0 }",
-    "exit 1",
+    "if (-not $children) { exit 0 }",
+    "$children | Select-Object -ExpandProperty ProcessId",

apps/server/src/terminalManager.ts Β· right 193–197

G
greptile-appsBotFeb 14, 2026

Windows only collects direct children, not the full process tree

collectWindowsChildPids queries Win32_Process with ParentProcessId = ${terminalPid}, which only returns direct child processes. In contrast, collectPosixProcessFamilyPids recursively walks the entire process tree (BFS traversal building processFamily). This means on Windows, if a terminal runs npm start β†’ node server.js β†’ listens on port 3000, the port won't be detected because node is a grandchild.

Consider recursively collecting descendant PIDs on Windows as well β€” for example by iterating the initial children and querying their children in a loop, similar to the POSIX BFS approach.

apps/server/src/terminalManager.ts Β· Earlier revision 7926636a Β· right 313–323

Original diff
@@ -178,32 +256,134 @@ async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolea
       outputMode: "truncate",
     });
     if (psResult.code !== 0) {
-      return false;
+      return [];
     }
 
+    const childrenByParentPid = new Map<number, number[]>();
     for (const line of psResult.stdout.split(/\r?\n/g)) {
       const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
       const pid = Number(pidRaw);
       const ppid = Number(ppidRaw);
       if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
-      if (ppid === terminalPid) {
-        return true;
+      const children = childrenByParentPid.get(ppid);
+      if (children) {
+        children.push(pid);
+      } else {
+        childrenByParentPid.set(ppid, [pid]);
       }
     }
-    return false;
+
+    const processFamily = new Set<number>([terminalPid]);
+    const pendingParents = [terminalPid];
+    while (pendingParents.length > 0) {
+      const parentPid = pendingParents.shift();
+      if (!parentPid) continue;
+      const childPids = childrenByParentPid.get(parentPid);
+      if (!childPids || childPids.length === 0) continue;
+      for (const childPid of childPids) {
+        if (processFamily.has(childPid)) continue;
+        processFamily.add(childPid);
+        pendingParents.push(childPid);
+      }
+    }
+
+    return [...processFamily];
+  } catch {
+    return [];
+  }
+}
+
+async function checkPosixListeningPorts(processIds: number[]): Promise<number[]> {
+  if (processIds.length === 0) return [];
+
+  const ports = new Set<number>();
+  const pidFilter = new Set(processIds);
+
+  try {
+    const result = await runProcess(
+      "lsof",
+      ["-nP", "-iTCP", "-sTCP:LISTEN", "-p", processIds.join(",")],
+      {
+        timeoutMs: 1_500,
+        allowNonZeroExit: true,
+        maxBufferBytes: 262_144,
+        outputMode: "truncate",
+      },
+    );
+    if (result.code === 0) {
+      for (const line of result.stdout.split(/\r?\n/g)) {
+        const match = line.match(/:(\d+)\s+\(LISTEN\)$/);
+        if (!match?.[1]) continue;
+        const port = Number(match[1]);
+        if (Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER) {
+          ports.add(port);
+        }
+      }
+      return [...ports].toSorted((left, right) => left - right);
+    }

apps/server/src/terminalManager.ts Β· right 313–323

G
greptile-appsBotFeb 14, 2026

lsof exit code 1 unnecessarily falls through to ss

lsof returns exit code 1 when no matching files/connections are found (i.e., none of the specified PIDs have listening sockets). This is a normal "no results" outcome, not a failure. With the current logic, result.code === 0 will be false, causing an unnecessary fallback to ss which will also find nothing. The ss fallback should only be needed when lsof is unavailable (the catch path).

Consider also returning early when result.code === 1:

suggestion
1 if (result.code === 0 || result.code === 1) {
2 for (const line of result.stdout.split(/\r?\n/g)) {
3 const match = line.match(/:(\d+)\s+\(LISTEN\)$/);
4 if (!match?.[1]) continue;
5 const port = Number(match[1]);
6 if (Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER) {
7 ports.add(port);
8 }
9 }
10 return [...ports].toSorted((left, right) => left - right);
11 }

apps/server/src/terminal/Layers/Manager.ts Β· Earlier revision da81a167 Β· right 622

Original diff
@@ -695,6 +599,50 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
         }
       });
 
+    const inspectWebPortCached = (port: number) =>
+      Effect.gen(function* () {
+        const now = Date.now();
+        for (const [cachedPort, entry] of webPortProbeCache) {
+          if (now - entry.checkedAt > webPortProbeCacheTtlMs) {
+            webPortProbeCache.delete(cachedPort);
+          }
+        }
+
+        const cached = webPortProbeCache.get(port);
+        if (cached) {
+          return cached.isWeb;
+        }
+
+        const isWeb = yield* webPortInspector(port).pipe(Effect.catch(() => Effect.succeed(false)));
+        webPortProbeCache.set(port, {
+          isWeb,
+          checkedAt: Date.now(),
+        });
+        return isWeb;
+      });
C
cursorBotApr 5, 2026

Negative web port probe results are cached for TTL

Low Severity

inspectWebPortCached catches all webPortInspector failures and caches the result as isWeb: false for the full TTL (default 10 seconds). If a web server is detected as listening but its first HTTP probe fails transiently (e.g., brief startup race, temporary overload), the port won't be classified as a web port until the cache entry expires β€” even though subsequent probes would succeed.

Reviewed by Cursor Bugbot for commit da81a167eac8b111b129cc7ed989b0b7c8f62dde. Configure here.

packages/contracts/src/terminal.ts Β· Earlier revision c11027be Β· right 92–92

Original diff
@@ -89,6 +89,7 @@ export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({
 export const terminalActivityEventSchema = terminalEventBaseSchema.extend({
   type: z.literal("activity"),
   hasRunningSubprocess: z.boolean(),
+  runningPorts: z.array(z.number().int().min(1).max(65_535)).default([]),

packages/contracts/src/terminal.ts Β· right 92–92

M
macroscopeappBotFeb 16, 2026

src/terminal.ts:92

.default([]) shares the same array instance across all parsed objects. Consider using .default(() => []) to return a fresh array each time.

No longer relevant as of 05a7018f0302121cfc15ce9bf2db72498f5e09d4

apps/server/src/terminalManager.ts Β· Earlier revision c11027be Β· right 839

Original diff
@@ -834,18 +836,29 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
           if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) {
             return;
           }
-          if (liveSession.hasRunningSubprocess === hasRunningSubprocess) {
+          const hasRunningSubprocess = activity.hasRunningSubprocess === true;
M
macroscopeappBotFeb 16, 2026

src/terminalManager.ts:839 The detectWebPorts call on line 842 isn't wrapped in a try/catch like the subprocessInspector call above. If it throws, the unhandled rejection could crash the process. Consider wrapping it similarly.

βœ… Addressed in c11027be4d9183c9028f91bf68c0abb5934b103b

apps/server/src/process/win32.ts Β· Earlier revision 0236cc84 Β· right 46

Original diff
@@ -0,0 +1,78 @@
+import { Effect } from "effect";
+
+import type { TerminalProcessInspectionError } from "./Services/TerminalProcessInspector";
+import { parsePidList, parsePortList } from "./utils";
+
+interface InspectorCommandResult {
+  readonly stdout: string;
+  readonly stderr: string;
+  readonly exitCode: number;
+}
+
+interface WindowsRunCommand {
+  (
+    input: Readonly<{
+      operation: string;
+      terminalPid: number;
+      command: string;
+      args: ReadonlyArray<string>;
+      timeoutMs: number;
+      maxOutputBytes: number;
+    }>,
+  ): Effect.Effect<InspectorCommandResult, TerminalProcessInspectionError>;
+}
+
+export const collectWindowsChildPids = Effect.fn("process.collectWindowsChildPids")(function* (
+  terminalPid: number,
+  runCommand: WindowsRunCommand,
+): Effect.fn.Return<number[], TerminalProcessInspectionError> {
+  const command = [
+    `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,
+    "if (-not $children) { exit 0 }",
+    "$children | Select-Object -ExpandProperty ProcessId",
+  ].join("; ");
+  const result = yield* runCommand({
+    operation: "TerminalProcessInspector.collectWindowsChildPids",
+    terminalPid,
+    command: "powershell.exe",
+    args: ["-NoProfile", "-NonInteractive", "-Command", command],
+    timeoutMs: 1_500,
+    maxOutputBytes: 32_768,
+  });
+  if (result.exitCode !== 0) {
+    return [];
+  }
+  return parsePidList(result.stdout);
+});
C
cursorBotApr 4, 2026

Windows subprocess inspection misses grandchild processes unlike POSIX

Medium Severity

collectWindowsChildPids only queries direct children via ParentProcessId = ${terminalPid}, while collectPosixProcessFamilyPids performs a full BFS traversal of the process tree. On Windows, grandchild processes (e.g., shell β†’ npm β†’ node listening on a port) won't be detected for subprocess activity or port scanning, causing inconsistent behavior across platforms.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 0236cc845e0621a281ad3cac36c5bd341594236f. Configure here.

apps/server/src/terminalManager.ts Β· Earlier revision b3e967e2 Β· right 796–800

Original diff
@@ -800,6 +757,48 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
     this.subprocessPollTimer = null;
   }
 
+  private async inspectWebPortCached(port: number): Promise<boolean> {
+    const now = Date.now();
+    const cached = this.webPortProbeCache.get(port);
+    if (cached && now - cached.checkedAt <= this.webPortProbeCacheTtlMs) {
+      return cached.isWeb;
+    }
+
+    const inFlight = this.webPortProbeInFlight.get(port);
+    if (inFlight) {
+      return inFlight;
+    }
+
+    const probe = this.webPortInspector(port)
+      .then((isWeb) => {
+        this.webPortProbeCache.set(port, {
+          isWeb,
+          checkedAt: Date.now(),
+        });
+        return isWeb;
+      })
+      .catch(() => false)
+      .finally(() => {
+        this.webPortProbeInFlight.delete(port);
+      });
+    this.webPortProbeInFlight.set(port, probe);
+    return probe;
+  }
+
+  private async detectWebPorts(runningPorts: number[]): Promise<number[]> {
+    if (runningPorts.length === 0) return [];
+    const checks = await Promise.all(
+      runningPorts.map(async (port) => ({
+        port,
+        isWeb: await this.inspectWebPortCached(port),
+      })),
+    );
+    return checks
+      .filter((entry) => entry.isWeb)
+      .map((entry) => entry.port)
+      .toSorted((left, right) => left - right);
+  }

apps/server/src/terminalManager.ts Β· right 796–800

C
coderabbitaiBotFeb 16, 2026

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

shell
1#!/bin/bash
2# Check Node.js version requirements in package.json files
3echo "=== Checking engines field in package.json files ==="
4fd -t f 'package.json' --max-depth 3 | xargs -I {} sh -c 'echo "=== {} ==="; cat {} | jq ".engines // empty" 2>/dev/null'
5
6echo ""
7echo "=== Checking .nvmrc or .node-version ==="
8cat .nvmrc 2>/dev/null || cat .node-version 2>/dev/null || echo "No version file found"

Repository: pingdotgg/codething-mvp

Length of output: 364


🏁 Script executed:

shell
1# View the specific lines in terminalManager.ts around 796-800
2echo "=== terminalManager.ts lines 790-805 ==="
3sed -n '790,805p' apps/server/src/terminalManager.ts
4
5echo ""
6echo "=== terminalManager.ts lines 835-850 (double normalization check) ==="
7sed -n '835,850p' apps/server/src/terminalManager.ts
8
9echo ""
10echo "=== Search for detectWebPorts method definition ==="
11rg -n "detectWebPorts" apps/server/src/terminalManager.ts -A 15

Repository: pingdotgg/codething-mvp

Length of output: 2739


🏁 Script executed:

shell
1# Check utils.ts for .toSorted() usage
2echo "=== Search for toSorted usage in utils.ts ==="
3rg -n "toSorted" apps/server/src/
4
5echo ""
6echo "=== Check if Array.prototype.toSorted is polyfilled or available ==="
7rg -n "toSorted|Array.*sort" packages/ -t ts -t js --max-count 20

Repository: pingdotgg/codething-mvp

Length of output: 739


🏁 Script executed:

shell
1# Check what normalizeRunningPorts does
2echo "=== normalizeRunningPorts definition ==="
3rg -n "normalizeRunningPorts" apps/server/src/terminalManager.ts -B 2 -A 8

Repository: pingdotgg/codething-mvp

Length of output: 1073


🏁 Script executed:

shell
1# Check if there's any documentation about project runtime requirements
2echo "=== Check README for runtime requirements ==="
3head -50 README.md 2>/dev/null || echo "No README at root"
4
5echo ""
6echo "=== Check apps/server for runtime info ==="
7head -30 apps/server/README.md 2>/dev/null || echo "No README in apps/server"
8
9echo ""
10echo "=== Check tsconfig to see ES2023 target ==="
11cat apps/server/tsconfig.json 2>/dev/null | grep -A 5 -B 5 "target" || echo "No tsconfig found"

Repository: pingdotgg/codething-mvp

Length of output: 2080


🏁 Script executed:

shell
1# Find and examine normalizeRunningPorts implementation
2echo "=== Search for normalizeRunningPorts implementation ==="
3rg -n "export.*normalizeRunningPorts|function normalizeRunningPorts|const normalizeRunningPorts" apps/server/src/ -A 10
4
5echo ""
6echo "=== Check terminalProcessInspector imports ==="
7head -40 apps/server/src/terminalProcessInspector/index.ts 2>/dev/null || echo "Checking for the file..."
8
9echo ""
10echo "=== Find terminalProcessInspector directory structure ==="
11find apps/server/src/terminalProcessInspector -type f -name "*.ts" 2>/dev/null

Repository: pingdotgg/codething-mvp

Length of output: 2822


🏁 Script executed:

shell
1# Check if there's a .node-version or engines requirement we missed
2echo "=== Check all package.json files for engines field ==="
3find . -name "package.json" -type f -exec grep -l "engines" {} \;
4
5echo ""
6echo "=== Check apps/server/package.json completely ==="
7cat apps/server/package.json | head -40

Repository: pingdotgg/codething-mvp

Length of output: 933


Remove redundant normalizeRunningPorts() call at line 841.

The double normalization is unnecessary. The inner call at line 842 normalizes activity.runningPorts, then detectWebPorts() returns an already-sorted array via .toSorted(), making the outer normalizeRunningPorts() at line 841 redundant. Simplify to: await this.detectWebPorts(normalizeRunningPorts(activity.runningPorts)).

πŸ€– Prompt for AI Agents
1In `@apps/server/src/terminalManager.ts` around lines 796 - 800, Replace the
2double-normalization around detectWebPorts: remove the outer
3normalizeRunningPorts(...) call and instead call detectWebPorts with the
4normalized activity.runningPorts directly by invoking
5this.detectWebPorts(normalizeRunningPorts(activity.runningPorts)); update the
6call site where normalizeRunningPorts(await this.detectWebPorts(...)) is used so
7only normalizeRunningPorts(activity.runningPorts) is passed into detectWebPorts
8(referencing the functions normalizeRunningPorts and detectWebPorts and the
9variable activity.runningPorts).

apps/web/src/terminalStateStore.ts Β· Earlier revision da81a167 Β· right 70

Original diff
@@ -61,6 +62,30 @@ function normalizeRunningTerminalIds(
     .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
 }
 
+export function normalizeRunningPorts(ports: readonly number[] | undefined): number[] {
+  if (!ports || ports.length === 0) return [];
+  return [...new Set(ports)]
+    .filter((port) => Number.isInteger(port) && port > 0 && port <= 65_535)
+    .toSorted((left, right) => left - right);
+}
C
cursorBotApr 5, 2026

Duplicated normalizeRunningPorts across server and web packages

Low Severity

normalizeRunningPorts is implemented twice with nearly identical logic β€” once in apps/server/src/process/utils.ts and again in apps/web/src/terminalStateStore.ts. Both deduplicate, filter invalid ports, and sort. Since the @t3tools/contracts package already defines TerminalPortSchema with the same validation range (1–65535), this shared utility belongs there to avoid divergence over time.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit da81a167eac8b111b129cc7ed989b0b7c8f62dde. Configure here.

apps/web/src/components/ThreadTerminalDrawer.tsx Β· Earlier revision da81a167 Β· right 109

Original diff
@@ -74,6 +79,35 @@ export function selectPendingTerminalEventEntries(
   return entries.filter((entry) => entry.id > lastAppliedTerminalEventId);
 }
 
+interface TerminalRuntimeStatus {
+  label: string;
+  primaryWebPort: number | null;
+}
+
+function terminalRuntimeStatus(
+  terminalId: string,
+  runningTerminalIds: Set<string>,
+  runningTerminalPorts: Record<string, number[]>,
+): TerminalRuntimeStatus | null {
+  if (!runningTerminalIds.has(terminalId)) {
+    return null;
+  }
+
+  const runningPorts = normalizeRunningPorts(runningTerminalPorts[terminalId]);
+  const primaryWebPort = runningPorts[0] ?? null;
+  const label =
+    runningPorts.length === 0
+      ? "Terminal process running"
+      : runningPorts.length === 1
+        ? `Open web server: http://localhost:${primaryWebPort}`
+        : `Open web server: http://localhost:${primaryWebPort} (detected web ports: ${runningPorts.join(", ")})`;
+
+  return {
+    label,
+    primaryWebPort,
+  };
+}
C
cursorBotApr 5, 2026

Duplicated port label construction in sidebar and drawer

Low Severity

The port label format string logic (including the ternary for single vs. multiple ports and the http://localhost:${port} template) is duplicated across terminalRuntimeStatus in ThreadTerminalDrawer.tsx and terminalStatusFromTerminalState in Sidebar.tsx. If the label format needs to change, both locations must be updated in lockstep.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit da81a167eac8b111b129cc7ed989b0b7c8f62dde. Configure here.

apps/server/src/terminalManager.ts Β· Earlier revision 7926636a Β· right 1000

Original diff
@@ -807,9 +1000,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
       await Promise.all(
M
macroscopeappBotFeb 14, 2026

src/terminalManager.ts:1000

Consider throttling or batching the subprocess activity checks. Running subprocessInspector concurrently for all sessions via Promise.all spawns heavy external processes (e.g., powershell.exe, ps) in parallel every polling interval, which could cause resource pressure with many open terminals.

No longer relevant as of 05a7018f0302121cfc15ce9bf2db72498f5e09d4

apps/server/src/process/utils.ts Β· Earlier revision 259251c8 Β· right 3–8

Original diff
@@ -0,0 +1,52 @@
+export const MAX_PORT_NUMBER = 65_535;
+
+export function normalizeRunningPorts(ports: number[]): number[] {
+  if (ports.length === 0) return [];
+  return [...new Set(ports)]
+    .filter((port) => Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER)
+    .toSorted((left, right) => left - right);
+}

apps/server/src/process/utils.ts Β· right 3–8

C
coderabbitaiBotFeb 15, 2026

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

shell
1#!/bin/bash
2
3# Find Node version config files and package.json
4echo "=== Node version config files ==="
5find . -type f \( -name ".nvmrc" -o -name ".node-version" \) 2>/dev/null | head -20
6echo ""
7
8# Check package.json for engines field
9echo "=== Checking engines field in package.json files ==="
10fd -t f 'package.json' | head -5 | xargs -I {} sh -c 'echo "File: {}"; cat {} | jq -r ".engines // empty" 2>/dev/null || echo "No engines field"'
11echo ""
12
13# Check for root package.json specifically
14echo "=== Root package.json engines field ==="
15cat package.json 2>/dev/null | jq '.engines' 2>/dev/null || echo "No engines field found"
16echo ""
17
18# Search for toSorted usage
19echo "=== toSorted usage in TypeScript files ==="
20rg -n "toSorted" --type ts --type tsx 2>/dev/null | head -20
21echo ""
22
23# Check Node version mentioned in CI/build configs
24echo "=== Node version in CI/build files ==="
25find . -maxdepth 3 -type f \( -name "*.yml" -o -name "*.yaml" -o -name ".npmrc" -o -name ".eslintrc*" \) 2>/dev/null | xargs grep -l "node" 2>/dev/null | head -5 | xargs -I {} sh -c 'echo "=== {} ==="; cat {}'

Repository: pingdotgg/codething-mvp

Length of output: 473


🏁 Script executed:

shell
1cat -n apps/server/src/terminalProcessInspector/utils.ts 2>/dev/null || echo "File not found or cannot be read"

Repository: pingdotgg/codething-mvp

Length of output: 1885


🏁 Script executed:

shell
1# Check for Bun specifics
2echo "=== Checking Bun vs Node runtime ==="
3cat package.json | jq '.packageManager // empty' 2>/dev/null
4
5# Check apps/server specifically
6echo ""
7echo "=== apps/server/package.json ==="
8cat apps/server/package.json | jq '.engines // .scripts // empty' 2>/dev/null
9
10# Look for runtime detection in build configs
11echo ""
12echo "=== Checking tsconfig or build configs ==="
13find apps/server -maxdepth 2 -name "tsconfig.json" -o -name "*.config.*" | head -10 | xargs cat 2>/dev/null | head -50
14
15# Check for Node references in server code
16echo ""
17echo "=== Checking for Node.js imports in server ==="
18rg "import.*node:|from.*node:" apps/server/src/ --type ts 2>/dev/null | head -10

Repository: pingdotgg/codething-mvp

Length of output: 1658


Verify Node.js version requirement for toSorted or add runtime compatibility fallback.

The code targets ES2023 with toSorted, which requires Node.js 20+. However, package.json engines specifies only bun: >=1.3.9 with no Node.js constraint. In production, this runs on node dist/index.mjs and will fail at runtime on Node < 20 with "toSorted is not a function", violating predictable behavior requirements.

Either add "node": ">=20" to the engines field or use .slice().sort(...) for compatibility:

Compatibility fallback
diff
1 return [...new Set(ports)]
2 .filter((port) => Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER)
3- .toSorted((left, right) => left - right);
4+ .slice()
5+ .sort((left, right) => left - right);
πŸ€– Prompt for AI Agents
1In `@apps/server/src/terminalProcessInspector/utils.ts` around lines 3 - 8, The
2call to Array.prototype.toSorted in normalizeRunningPorts requires Node.js 20+,
3which may not be guaranteed; either declare "node": ">=20" in package.json
4engines or replace toSorted usage in normalizeRunningPorts with a
5runtime-compatible sort (e.g., copy array with slice() or spread and use
6.sort((a,b)=>a-b)) so the function still returns a deduplicated, ascending array
7and keeps the MAX_PORT_NUMBER check intact.

apps/server/src/terminalManager.ts Β· Earlier revision 7926636a Β· right 371

Original diff
@@ -178,32 +256,134 @@ async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolea
       outputMode: "truncate",
     });
     if (psResult.code !== 0) {
-      return false;
+      return [];
     }
 
+    const childrenByParentPid = new Map<number, number[]>();
     for (const line of psResult.stdout.split(/\r?\n/g)) {
       const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
       const pid = Number(pidRaw);
       const ppid = Number(ppidRaw);
       if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
-      if (ppid === terminalPid) {
-        return true;
+      const children = childrenByParentPid.get(ppid);
+      if (children) {
+        children.push(pid);
+      } else {
+        childrenByParentPid.set(ppid, [pid]);
       }
     }
-    return false;
+
+    const processFamily = new Set<number>([terminalPid]);
+    const pendingParents = [terminalPid];
+    while (pendingParents.length > 0) {
+      const parentPid = pendingParents.shift();
+      if (!parentPid) continue;
+      const childPids = childrenByParentPid.get(parentPid);
+      if (!childPids || childPids.length === 0) continue;
+      for (const childPid of childPids) {
+        if (processFamily.has(childPid)) continue;
+        processFamily.add(childPid);
+        pendingParents.push(childPid);
+      }
+    }
+
+    return [...processFamily];
+  } catch {
+    return [];
+  }
+}
+
+async function checkPosixListeningPorts(processIds: number[]): Promise<number[]> {
+  if (processIds.length === 0) return [];
+
+  const ports = new Set<number>();
+  const pidFilter = new Set(processIds);
+
+  try {
+    const result = await runProcess(
+      "lsof",
+      ["-nP", "-iTCP", "-sTCP:LISTEN", "-p", processIds.join(",")],
+      {
+        timeoutMs: 1_500,
+        allowNonZeroExit: true,
+        maxBufferBytes: 262_144,
+        outputMode: "truncate",
+      },
+    );
+    if (result.code === 0) {
+      for (const line of result.stdout.split(/\r?\n/g)) {
+        const match = line.match(/:(\d+)\s+\(LISTEN\)$/);
+        if (!match?.[1]) continue;
+        const port = Number(match[1]);
+        if (Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER) {
+          ports.add(port);
+        }
+      }
+      return [...ports].toSorted((left, right) => left - right);
+    }
+  } catch {
+    // Fall back to ss if lsof is unavailable.
+  }
+
+  try {
+    const result = await runProcess("ss", ["-ltnp"], {
+      timeoutMs: 1_500,
+      allowNonZeroExit: true,
+      maxBufferBytes: 524_288,
+      outputMode: "truncate",
+    });
+    if (result.code !== 0) {
+      return [];
+    }
+
+    for (const line of result.stdout.split(/\r?\n/g)) {
+      if (!line.includes("pid=")) continue;
+      const localAddress = line.trim().split(/\s+/g)[3];
+      if (!localAddress) continue;
+      const port = portFromAddress(localAddress);
+      if (port === null) continue;
+
+      const pidMatches = [...line.matchAll(/pid=(\d+)/g)];
+      if (pidMatches.length === 0) continue;
+      if (
+        pidMatches.some((match) => {
+          const pid = Number(match[1]);
+          return Number.isInteger(pid) && pidFilter.has(pid);
+        })
+      ) {
+        ports.add(port);
+      }
+    }
+    return [...ports].toSorted((left, right) => left - right);
   } catch {
-    return false;
+    return [];
   }
 }
 
-async function defaultSubprocessChecker(terminalPid: number): Promise<boolean> {
+async function defaultSubprocessInspector(
+  terminalPid: number,
+): Promise<TerminalSubprocessActivity> {
   if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
-    return false;
+    return { hasRunningSubprocess: false, runningPorts: [] };
   }
+
   if (process.platform === "win32") {
-    return checkWindowsSubprocessActivity(terminalPid);
+    const childPids = await collectWindowsChildPids(terminalPid);
M
macroscopeappBotFeb 14, 2026

src/terminalManager.ts:371 On Windows, collectWindowsChildPids only retrieves immediate children, unlike the POSIX path which recursively collects the entire process family. Consider implementing recursive traversal (similar to collectPosixProcessFamilyPids) to detect ports opened by grandchild processes.

Code refactored as of 259251c84327b88fcc0bc31bb6b6be9212f2799b

apps/server/src/terminalProcessInspector/posix.ts Β· Earlier revision 259251c8 Β· right 67–71

Original diff
@@ -0,0 +1,119 @@
+import { runProcess } from "../processRunner";
+import { MAX_PORT_NUMBER, portFromAddress } from "./utils";
+
+export async function collectPosixProcessFamilyPids(terminalPid: number): Promise<number[]> {
+  try {
+    const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], {
+      timeoutMs: 1_000,
+      allowNonZeroExit: true,
+      maxBufferBytes: 262_144,
+      outputMode: "truncate",
+    });
+    if (psResult.code !== 0) {
+      return [];
+    }
+
+    const childrenByParentPid = new Map<number, number[]>();
+    for (const line of psResult.stdout.split(/\r?\n/g)) {
+      const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
+      const pid = Number(pidRaw);
+      const ppid = Number(ppidRaw);
+      if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
+      const children = childrenByParentPid.get(ppid);
+      if (children) {
+        children.push(pid);
+      } else {
+        childrenByParentPid.set(ppid, [pid]);
+      }
+    }
+
+    const processFamily = new Set<number>([terminalPid]);
+    const pendingParents = [terminalPid];
+    while (pendingParents.length > 0) {
+      const parentPid = pendingParents.shift();
+      if (!parentPid) continue;
+      const childPids = childrenByParentPid.get(parentPid);
+      if (!childPids || childPids.length === 0) continue;
+      for (const childPid of childPids) {
+        if (processFamily.has(childPid)) continue;
+        processFamily.add(childPid);
+        pendingParents.push(childPid);
+      }
+    }
+
+    return [...processFamily];
+  } catch {
+    return [];
+  }
+}
+
+export async function checkPosixListeningPorts(processIds: number[]): Promise<number[]> {
+  if (processIds.length === 0) return [];
+
+  const ports = new Set<number>();
+  const pidFilter = new Set(processIds);
+
+  try {
+    const result = await runProcess(
+      "lsof",
+      ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-p", processIds.join(",")],
+      {
+        timeoutMs: 1_500,
+        allowNonZeroExit: true,
+        maxBufferBytes: 262_144,
+        outputMode: "truncate",
+      },
+    );
+    if (result.code !== 0) {
+      // `lsof` returns 1 when there are no matching files/sockets.
+      // This is a valid "no results" outcome; avoid falling back to `ss`.
+      return [];
+    }

apps/server/src/terminalProcessInspector/posix.ts Β· right 67–71

C
coderabbitaiBotFeb 15, 2026

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

shell
1# First, check the file structure and read the relevant lines
2fd "posix.ts" -t f | head -5

Repository: pingdotgg/codething-mvp

Length of output: 116


🏁 Script executed:

shell
1# Look at the full context of the file around lines 67-71
2cat -n apps/server/src/terminalProcessInspector/posix.ts | sed -n '50,90p'

Repository: pingdotgg/codething-mvp

Length of output: 1610


🌐 Web query:

lsof exit codes documentation meaning

πŸ’‘ Result:

lsof’s exit status is primarily a β€œdid my requested search succeed?” indicator (not just β€œdid I print any lines?”):

  • 0: No errors detected, and lsof was able to list information for all specified search arguments (and if you didn’t specify any files to search for, success is also 0). [2] [3]
  • 1: Some error was detected including β€œsearch failure” cases such as failing to locate a requested name/address/PID/UID/file/etc., or not finding all specified files/filesystems. This often happens when your filters match nothing for one of the specified search items. [2] [3]
  • Repeat mode special case (-r with +): when repeat mode ends because β€œno open files are listed”, exit is 0 if any open files were ever listed in earlier cycles, otherwise 1. [1]

Related option:

  • -Q: makes β€œfailed to find a search item” still exit 0, but it does not suppress exit 1 for generic/non-search execution errors. [2] [6]

Fall back to ss on lsof failures other than "no results".

Currently, any non-zero lsof exit returns [] and skips the ss fallback. The comment states that exit code 1 means "no matching files/sockets", but lsof exit code 1 actually indicates broader search failuresβ€”it can mean no matches, failed to locate requested items, or incomplete results. Other exit codes indicate errors like lsof being unavailable. Treat exit code 1 as a "no results" outcome and fall through to ss for other errors.

πŸ› οΈ Suggested fix
diff
1 if (result.code !== 0) {
2 // `lsof` returns 1 when there are no matching files/sockets.
3+ // Other codes indicate errors; fall back to ss.
4- // This is a valid "no results" outcome; avoid falling back to `ss`.
5- return [];
6+ if (result.code === 1) return [];
7+ break;
8 }

(Alternatively, throw an error to surface lsof failures explicitly.)

πŸ“ Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1 if (result.code !== 0) {
2 // `lsof` returns 1 when there are no matching files/sockets.
3 // Other codes indicate errors; fall back to ss.
4 if (result.code === 1) return [];
5 break;
6 }
πŸ€– Prompt for AI Agents
1In `@apps/server/src/terminalProcessInspector/posix.ts` around lines 67 - 71, The
2current check in posix.ts returns [] for any non-zero lsof exit, preventing the
3ss fallback; change the logic so only result.code === 1 is treated as "no
4results" and returns [], while any other non-zero result.code should not return
5and should allow the code to fall through to the ss fallback (or surface an
6error if you prefer). Locate the block referencing result.code and lsof and
7update the conditional to return [] only when result.code === 1, leaving other
8non-zero exit codes to proceed to the ss handling.

apps/server/src/terminal/Layers/Manager.ts Β· Earlier revision bc30ac51 Β· right 622

Original diff
@@ -695,6 +605,44 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
         }
       });
 
+    const inspectWebPortCached = (port: number) =>
+      Effect.gen(function* () {
+        const now = Date.now();
+        const cached = webPortProbeCache.get(port);
+        if (cached && now - cached.checkedAt <= webPortProbeCacheTtlMs) {
+          return cached.isWeb;
+        }
+
+        const isWeb = yield* webPortInspector(port).pipe(Effect.catch(() => Effect.succeed(false)));
+        webPortProbeCache.set(port, {
+          isWeb,
+          checkedAt: Date.now(),
+        });
+        return isWeb;
+      });
C
cursorBotApr 4, 2026

Web port probe cache grows without bound

Low Severity

The webPortProbeCache map is never pruned β€” entries are added on each probe but never removed, even when their TTL expires. Over a long-running server session with many ephemeral dev-server ports, stale entries accumulate indefinitely. Adding eviction of expired entries during cache reads or periodically would keep memory bounded.

Reviewed by Cursor Bugbot for commit bc30ac51f5dd298fd122031d1a70989b6b6a242f. Configure here.

apps/server/src/webPortInspector.ts Β· Earlier revision 259251c8 Β· right 66–75

Original diff
@@ -0,0 +1,114 @@
+import { request as httpRequest } from "node:http";
+
+export const DEFAULT_WEB_PORT_PROBE_TTL_MS = 10_000;
+const DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS = 500;
+const WEB_PORT_PROBE_MAX_BODY_BYTES = 8_192;
+
+interface WebProbeResult {
+  status: number;
+  contentType: string;
+  body: string;
+  location: string;
+}
+
+function normalizeHeaderValue(value: string | string[] | undefined): string {
+  if (typeof value === "string") return value;
+  if (Array.isArray(value)) return value[0] ?? "";
+  return "";
+}
+
+function isLikelyWebProbe(result: WebProbeResult | null): boolean {
+  if (!result) return false;
+  if (result.status === 404) return false;
+  if (result.status >= 300 && result.status < 400 && result.location.length > 0) {
+    return true;
+  }
+  const contentType = result.contentType.toLowerCase();
+  if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) {
+    return true;
+  }
+  const body = result.body.toLowerCase();
+  return (
+    body.includes("<!doctype") ||
+    body.includes("<html") ||
+    body.includes("<head")
+  );
+}
+
+async function probeWebPortOnHost(
+  port: number,
+  host: string,
+): Promise<WebProbeResult | null> {
+  return new Promise((resolve) => {
+    let timer: ReturnType<typeof setTimeout> | null = null;
+    let settled = false;
+    const settle = (result: WebProbeResult | null) => {
+      if (settled) return;
+      settled = true;
+      if (timer) {
+        clearTimeout(timer);
+      }
+      resolve(result);
+    };
+
+    const req = httpRequest(
+      {
+        host,
+        port,
+        method: "GET",
+        path: "/",
+        timeout: DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS,
+      },
+      (res) => {
+        const chunks: string[] = [];
+        let received = 0;
+        res.setEncoding("utf8");
+        res.on("data", (chunk: string) => {
+          if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) return;
+          const remaining = WEB_PORT_PROBE_MAX_BODY_BYTES - received;
+          const fragment = chunk.slice(0, remaining);
+          received += fragment.length;
+          chunks.push(fragment);
+          if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) {
+            res.destroy();
+          }
+        });

apps/server/src/webPortInspector.ts Β· right 66–75

C
coderabbitaiBotFeb 15, 2026

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

shell
1# First, let's examine the webPortInspector.ts file to understand the full context
2find . -name "webPortInspector.ts" -type f

Repository: pingdotgg/codething-mvp

Length of output: 104


🏁 Script executed:

shell
1# Also search for any web port related files
2fd -e ts -e js | grep -i "port" | grep -i "inspect"

Repository: pingdotgg/codething-mvp

Length of output: 102


🏁 Script executed:

shell
1# Read the webPortInspector.ts file to understand the full context
2cat -n ./apps/server/src/webPortInspector.ts

Repository: pingdotgg/codething-mvp

Length of output: 4004


🌐 Web query:

Node.js HTTP IncomingMessage res.destroy() event behavior end event

πŸ’‘ Result:

http.IncomingMessage (the res you get in http.request() / https.request()) is a Readable stream.

What happens when you call res.destroy([err])

  • It immediately tears down the stream, drops any unread response data, and releases resources.
  • It will emit:
    • 'error' only if you pass an err to destroy(err)
    • 'close' (unless the stream was created with emitClose: false)
      This is standard Readable-stream destroy() behavior. [2]

Will 'end' fire?

  • Usually no. 'end' is for normal completion (β€œno more data to be consumed”) and is not emitted unless the response data is fully consumed. Destroying the stream is a premature termination, so you should not rely on 'end' after destroy(). [2]

HTTP-specific events you may see (http.ClientRequest + IncomingMessage)

If you destroy the request (req.destroy()) after the response is received, Node documents this typical sequence:

  • on res: 'aborted' β†’ 'close' β†’ 'error' (often ECONNRESET, message like β€œaborted”) β†’ 'close' [1]

So for β€œdestroyed early” cases, treat 'close' / 'aborted' / 'error' as the relevant signals, not 'end'. [1][2]

Sources: Node.js HTTP docs [1], Node.js Stream (Readable) docs [2].


Settle truncated responses immediately instead of waiting for timeout.

When the body cap is reached, res.destroy() is called without settling. Since Node.js HTTP responses do not emit the 'end' event after destroy(), the settle() function is never called for truncated responses. The promise then waits for the fallback timeout (550ms), causing large HTML responses to be misclassified as failures. Settle with the partial response before destroying.

πŸ› οΈ Suggested fix
diff
1 res.on("data", (chunk: string) => {
2 if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) return;
3 const remaining = WEB_PORT_PROBE_MAX_BODY_BYTES - received;
4 const fragment = chunk.slice(0, remaining);
5 received += fragment.length;
6 chunks.push(fragment);
7 if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) {
8+ settle({
9+ status: res.statusCode ?? 0,
10+ contentType: normalizeHeaderValue(res.headers["content-type"]),
11+ location: normalizeHeaderValue(res.headers.location),
12+ body: chunks.join(""),
13+ });
14 res.destroy();
15 }
16 });
πŸ€– Prompt for AI Agents
1In `@apps/server/src/webPortInspector.ts` around lines 66 - 75, When truncating
2the response in the res.on("data") handler (when received >=
3WEB_PORT_PROBE_MAX_BODY_BYTES), call the existing settle(...) logic with the
4partial body/chunks immediately before calling res.destroy() so the promise is
5resolved with the truncated payload; ensure settle() is idempotent (or guard it
6with a boolean like settled) so it is only invoked once (both from the normal
7'end' handler and this truncation path). In short: inside the data handler for
8res.on("data") use the same settle(...) call that the 'end' handler uses
9(passing the current chunks/received data and status), then destroy the response
10to stop further data.

apps/server/src/terminalProcessInspector/win32.ts Β· Earlier revision 259251c8 Β· right 21–22

Original diff
@@ -0,0 +1,60 @@
+import { runProcess } from "../processRunner";
+import { parsePidList, parsePortList } from "./utils";
+
+export async function collectWindowsChildPids(terminalPid: number): Promise<number[]> {
+  const command = [
+    `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,
+    "if (-not $children) { exit 0 }",
+    "$children | Select-Object -ExpandProperty ProcessId",
+  ].join("; ");
+  try {
+    const result = await runProcess(
+      "powershell.exe",
+      ["-NoProfile", "-NonInteractive", "-Command", command],
+      {
+        timeoutMs: 1_500,
+        allowNonZeroExit: true,
+        maxBufferBytes: 32_768,
+        outputMode: "truncate",
+      },
+    );
+    if (result.code !== 0) {
+      return [];

apps/server/src/terminalProcessInspector/win32.ts Β· right 21–22

M
macroscopeappBotFeb 15, 2026

🟒 Low

terminalProcessInspector/win32.ts:21 When stdoutTruncated is true, the last PID may be partially cut off and parsed as a different valid number. Consider discarding the final line when truncation occurs, or switching to outputMode: "error" to reject truncated output entirely.

diff
1- if (result.code !== 0) {
2+ if (result.code !== 0 || result.stdoutTruncated) {
3 return [];

πŸš€ Want me to fix this? Reply ex: "fix it for me".

πŸ€– Prompt for AI
text
1In file apps/server/src/terminalProcessInspector/win32.ts around lines 21-22:
2
3When `stdoutTruncated` is true, the last PID may be partially cut off and parsed as a different valid number. Consider discarding the final line when truncation occurs, or switching to `outputMode: "error"` to reject truncated output entirely.

apps/server/src/process/Layers/TerminalProcessInspector.test.ts Β· Earlier revision 75ae12f6 Β· right 20–38

Original diff
@@ -0,0 +1,162 @@
+import { spawn, type ChildProcess, type ChildProcessByStdio } from "node:child_process";
+import type { Readable } from "node:stream";
+
+import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import { Effect, Layer } from "effect";
+
+import { TerminalProcessInspectorLive } from "./Layers/TerminalProcessInspector";
+import { TerminalProcessInspector } from "./Services/TerminalProcessInspector";
+
+type ListenerProcess = ChildProcessByStdio<null, Readable, Readable>;
+
+interface StartedProcess {
+  readonly process: ListenerProcess;
+  readonly port: number;
+}
+
+const stopProcess = (child: ChildProcess) =>
+  Effect.callback<void>((resume) => {
+    if (child.exitCode !== null || child.signalCode !== null) {
+      resume(Effect.void);
+      return;
+    }
+
+    child.kill("SIGTERM");
+
+    const timeout = setTimeout(() => {
+      if (child.exitCode === null && child.signalCode === null) {
+        child.kill("SIGKILL");
+      }
+    }, 1_000);
+
+    child.once("exit", () => {
+      clearTimeout(timeout);
+      resume(Effect.void);
+    });
+  });

apps/server/src/process/Layers/TerminalProcessInspector.test.ts Β· right 20–38

M
macroscopeappBotApr 4, 2026

🟒 Low terminalProcessInspector/index.test.ts:20

If the effect returned by stopProcess is interrupted before the process exits, the setTimeout at line 28 keeps running and the exit listener at line 34 remains attached β€” the cleanup function at line 86 isn't returned, so interruption leaks resources. Consider returning Effect.sync(cleanup) so interruption cancels the timeout and removes the listener.

No longer relevant as of bc30ac51f5dd298fd122031d1a70989b6b6a242f

apps/server/src/webPortInspector.test.ts Β· Earlier revision 75ae12f6 Β· right 24–31

Original diff
@@ -0,0 +1,109 @@
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+
+import { assert, it } from "@effect/vitest";
+import { Effect } from "effect";
+
+import { WebPortInspectorLive } from "./webPortInspector";
+import { WebPortInspector } from "./terminalProcessInspector/Services/WebPortInspector";
+
+const closeServer = (server: Server) =>
+  Effect.callback<void>((resume) => {
+    server.close(() => {
+      resume(Effect.void);
+    });
+
+    return Effect.sync(() => {
+      try {
+        server.close();
+      } catch {
+        // Ignore cleanup failures in tests.
+      }
+    });
+  });
+
+const listenServer = (server: Server) =>
+  Effect.callback<number, Error>((resume) => {
+    server.listen(0, "127.0.0.1", (error?: Error) => {
+      if (error) {
+        resume(Effect.fail(error));
+        return;
+      }
+

apps/server/src/webPortInspector.test.ts Β· right 24–31

M
macroscopeappBotApr 4, 2026

🟒 Low src/webPortInspector.test.ts:24

The server.listen callback signature is wrong: Node.js passes no arguments to the success callback, so error?: Error will always be undefined. Listen failures (e.g., port already in use) emit an 'error' event instead, which this code doesn't handle. The Effect will hang forever on listen failures because resume is never invoked.

No longer relevant as of 6aac79caff618d7affa32076be551dea38b49819

apps/server/src/terminalManager.test.ts Β· Earlier revision c0578af7 Β· right 354–362

Original diff
@@ -296,27 +307,106 @@ describe("TerminalManager", () => {
     await waitFor(() => events.some((event) => event.type === "started"));
     expect(events.some((event) => event.type === "activity")).toBe(false);
 
-    hasRunningSubprocess = true;
+    activity = { hasRunningSubprocess: true, runningPorts: [3000] };
     await waitFor(
       () =>
         events.some(
-          (event) => event.type === "activity" && event.hasRunningSubprocess === true,
+          (event) =>
+            event.type === "activity" &&
+            event.hasRunningSubprocess === true &&
+            event.runningPorts.join(",") === "3000",
         ),
       1_200,
     );
 
-    hasRunningSubprocess = false;
+    activity = { hasRunningSubprocess: true, runningPorts: [5173] };
     await waitFor(
       () =>
         events.some(
-          (event) => event.type === "activity" && event.hasRunningSubprocess === false,
+          (event) =>
+            event.type === "activity" &&
+            event.hasRunningSubprocess === true &&
+            event.runningPorts.length === 0,
+        ),
+      1_200,
+    );
+
+    activity = { hasRunningSubprocess: false, runningPorts: [5173] };
+    await waitFor(
+      () =>
+        events.some(
+          (event) =>
+            event.type === "activity" &&
+            event.hasRunningSubprocess === false &&
+            event.runningPorts.length === 0,
         ),
       1_200,
     );
 
     manager.dispose();
   });
 
+  it("ignores ports that return HTTP 404 at root during web-port detection", async () => {
+    const notFoundServer = createServer((_req, res) => {
+      res.statusCode = 404;
+      res.end();
+    });
+    await new Promise<void>((resolve, reject) => {
+      notFoundServer.listen(0, "127.0.0.1", (error?: Error) => {
+        if (error) {
+          reject(error);
+          return;
+        }
+        resolve();
+      });
+    });

apps/server/src/terminalManager.test.ts Β· right 354–362

C
coderabbitaiBotFeb 14, 2026

⚠️ Potential issue | 🟑 Minor

Incorrect callback signature for server.listen().

The listen callback receives no arguments. Errors during binding are emitted via the 'error' event on the server, not passed to the callback. While this doesn't break the test (the parameter is simply unused), the signature is misleading.

πŸ”§ Suggested fix
diff
1 await new Promise<void>((resolve, reject) => {
2- notFoundServer.listen(0, "127.0.0.1", (error?: Error) => {
3- if (error) {
4- reject(error);
5- return;
6- }
7- resolve();
8- });
9+ notFoundServer.once("error", reject);
10+ notFoundServer.listen(0, "127.0.0.1", () => {
11+ notFoundServer.removeListener("error", reject);
12+ resolve();
13+ });
14 });
πŸ“ Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1 await new Promise<void>((resolve, reject) => {
2 notFoundServer.once("error", reject);
3 notFoundServer.listen(0, "127.0.0.1", () => {
4 notFoundServer.removeListener("error", reject);
5 resolve();
6 });
7 });
πŸ€– Prompt for AI Agents
1In `@apps/server/src/terminalManager.test.ts` around lines 354 - 362, The listen
2callback used with notFoundServer.listen in terminalManager.test.ts incorrectly
3declares an error parameter; remove the unused parameter from the callback and
4instead attach an 'error' event listener on notFoundServer to reject the Promise
5on binding errors (or use once('error', ...) before calling listen), keeping the
6Promise resolution in the listen callback (with no args). This updates the
7signature to match Node's server.listen behavior and ensures binding errors are
8handled via the 'error' event rather than a nonexistent callback argument.

apps/web/src/components/Sidebar.tsx Β· Earlier revision 05a7018f Β· right 212

Original diff
@@ -202,16 +204,39 @@ function ThreadStatusLabel({
   );
 }
 
-function terminalStatusFromRunningIds(
+function normalizeRunningPorts(rawPorts: number[] | undefined): number[] {
+  if (!rawPorts) return [];
+  return [...new Set(rawPorts)]
+    .filter((port) => Number.isInteger(port) && port > 0 && port <= 65_535)
+    .toSorted((left, right) => left - right);
+}
C
cursorBotApr 4, 2026

Triplicated normalizeRunningPorts across web app files

Low Severity

normalizeRunningPorts is independently defined with near-identical logic in Sidebar.tsx, ThreadTerminalDrawer.tsx, and terminalStateStore.ts. All three deduplicate, filter, and sort port arrays the same way. If the normalization rules change (e.g., port range), three places need updating, risking inconsistency.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 05a7018f0302121cfc15ce9bf2db72498f5e09d4. Configure here.

apps/server/src/terminalManager.ts Β· Earlier revision c0578af7 Β· right 461–463

Original diff
@@ -178,32 +263,243 @@ async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolea
       outputMode: "truncate",
     });
     if (psResult.code !== 0) {
-      return false;
+      return [];
     }
 
+    const childrenByParentPid = new Map<number, number[]>();
     for (const line of psResult.stdout.split(/\r?\n/g)) {
       const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
       const pid = Number(pidRaw);
       const ppid = Number(ppidRaw);
       if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
-      if (ppid === terminalPid) {
-        return true;
+      const children = childrenByParentPid.get(ppid);
+      if (children) {
+        children.push(pid);
+      } else {
+        childrenByParentPid.set(ppid, [pid]);
+      }
+    }
+
+    const processFamily = new Set<number>([terminalPid]);
+    const pendingParents = [terminalPid];
+    while (pendingParents.length > 0) {
+      const parentPid = pendingParents.shift();
+      if (!parentPid) continue;
+      const childPids = childrenByParentPid.get(parentPid);
+      if (!childPids || childPids.length === 0) continue;
+      for (const childPid of childPids) {
+        if (processFamily.has(childPid)) continue;
+        processFamily.add(childPid);
+        pendingParents.push(childPid);
+      }
+    }
+
+    return [...processFamily];
+  } catch {
+    return [];
+  }
+}
+
+async function checkPosixListeningPorts(processIds: number[]): Promise<number[]> {
+  if (processIds.length === 0) return [];
+
+  const ports = new Set<number>();
+  const pidFilter = new Set(processIds);
+
+  try {
+    const result = await runProcess(
+      "lsof",
+      ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-p", processIds.join(",")],
+      {
+        timeoutMs: 1_500,
+        allowNonZeroExit: true,
+        maxBufferBytes: 262_144,
+        outputMode: "truncate",
+      },
+    );
+    if (result.code === 0) {
+      for (const line of result.stdout.split(/\r?\n/g)) {
+        const match = line.match(/:(\d+)\s+\(LISTEN\)$/);
+        if (!match?.[1]) continue;
+        const port = Number(match[1]);
+        if (Number.isInteger(port) && port > 0 && port <= MAX_PORT_NUMBER) {
+          ports.add(port);
+        }
       }
+      return [...ports].toSorted((left, right) => left - right);
     }
-    return false;
   } catch {
-    return false;
+    // Fall back to ss if lsof is unavailable.
+  }
+
+  try {
+    const result = await runProcess("ss", ["-ltnp"], {
+      timeoutMs: 1_500,
+      allowNonZeroExit: true,
+      maxBufferBytes: 524_288,
+      outputMode: "truncate",
+    });
+    if (result.code !== 0) {
+      return [];
+    }
+
+    for (const line of result.stdout.split(/\r?\n/g)) {
+      if (!line.includes("pid=")) continue;
+      const localAddress = line.trim().split(/\s+/g)[3];
+      if (!localAddress) continue;
+      const port = portFromAddress(localAddress);
+      if (port === null) continue;
+
+      const pidMatches = [...line.matchAll(/pid=(\d+)/g)];
+      if (pidMatches.length === 0) continue;
+      if (
+        pidMatches.some((match) => {
+          const pid = Number(match[1]);
+          return Number.isInteger(pid) && pidFilter.has(pid);
+        })
+      ) {
+        ports.add(port);
+      }
+    }
+    return [...ports].toSorted((left, right) => left - right);
+  } catch {
+    return [];
   }
 }
 
-async function defaultSubprocessChecker(terminalPid: number): Promise<boolean> {
+async function defaultSubprocessInspector(
+  terminalPid: number,
+): Promise<TerminalSubprocessActivity> {
   if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
-    return false;
+    return { hasRunningSubprocess: false, runningPorts: [] };
   }
+
   if (process.platform === "win32") {
-    return checkWindowsSubprocessActivity(terminalPid);
+    const childPids = await collectWindowsChildPids(terminalPid);
+    if (childPids.length === 0) {
+      return { hasRunningSubprocess: false, runningPorts: [] };
+    }
+    const runningPorts = await checkWindowsListeningPorts(childPids);
+    return { hasRunningSubprocess: true, runningPorts };
+  }
+
+  const processFamilyPids = await collectPosixProcessFamilyPids(terminalPid);
+  const subprocessPids = processFamilyPids.filter((pid) => pid !== terminalPid);
+  if (subprocessPids.length === 0) {
+    return { hasRunningSubprocess: false, runningPorts: [] };
+  }
+
+  const runningPorts = await checkPosixListeningPorts(subprocessPids);
+  return { hasRunningSubprocess: true, runningPorts };
+}
+
+interface WebProbeResult {
+  status: number;
+  contentType: string;
+  body: string;
+  location: string;
+}
+
+function normalizeHeaderValue(value: string | string[] | undefined): string {
+  if (typeof value === "string") return value;
+  if (Array.isArray(value)) return value[0] ?? "";
+  return "";
+}
+
+function isLikelyWebProbe(result: WebProbeResult | null): boolean {
+  if (!result) return false;
+  if (result.status === 404) return false;
+  if (result.status >= 300 && result.status < 400 && result.location.length > 0) {
+    return true;
+  }
+  const contentType = result.contentType.toLowerCase();
+  if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) {
+    return true;
+  }
+  const body = result.body.toLowerCase();
+  return (
+    body.includes("<!doctype") ||
+    body.includes("<html") ||
+    body.includes("<head")
+  );
+}
+
+async function probeWebPortOnHost(
+  port: number,
+  host: string,
+): Promise<WebProbeResult | null> {
+  return new Promise((resolve) => {
+    let timer: ReturnType<typeof setTimeout> | null = null;
+    let settled = false;
+    const settle = (result: WebProbeResult | null) => {
+      if (settled) return;
+      settled = true;
+      if (timer) {
+        clearTimeout(timer);
+      }
+      resolve(result);
+    };
+
+    const req = httpRequest(
+      {
+        host,
+        port,
+        method: "GET",
+        path: "/",
+        timeout: DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS,
+      },
+      (res) => {
+        const chunks: string[] = [];
+        let received = 0;
+        res.setEncoding("utf8");
+        res.on("data", (chunk: string) => {
+          if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) return;
+          const remaining = WEB_PORT_PROBE_MAX_BODY_BYTES - received;
+          const fragment = chunk.slice(0, remaining);
+          received += fragment.length;
+          chunks.push(fragment);
+          if (received >= WEB_PORT_PROBE_MAX_BODY_BYTES) {
+            res.destroy();
+          }

apps/server/src/terminalManager.ts Β· right 461–463

M
macroscopeappBotFeb 14, 2026

src/terminalManager.ts:461 Calling res.destroy() prevents the 'end' event from firing, so the promise only resolves when the fallback timer fires (~550ms later). Consider calling settle() immediately after res.destroy().

Code refactored as of 259251c84327b88fcc0bc31bb6b6be9212f2799b

apps/web/src/components/ThreadTerminalDrawer.tsx Β· Earlier revision 6aac79ca Β· right 114

Original diff
@@ -74,6 +75,45 @@ export function selectPendingTerminalEventEntries(
   return entries.filter((entry) => entry.id > lastAppliedTerminalEventId);
 }
 
+interface TerminalRuntimeStatus {
+  label: string;
+  primaryWebPort: number | null;
+  extraWebPortCount: number;
+}
+
+function normalizeRunningPorts(rawPorts: number[] | undefined): number[] {
+  if (!rawPorts) return [];
+  return [...new Set(rawPorts)]
+    .filter((port) => Number.isInteger(port) && port > 0 && port <= 65_535)
+    .toSorted((left, right) => left - right);
+}
+
+function terminalRuntimeStatus(
+  terminalId: string,
+  runningTerminalIds: Set<string>,
+  runningTerminalPorts: Record<string, number[]>,
+): TerminalRuntimeStatus | null {
+  if (!runningTerminalIds.has(terminalId)) {
+    return null;
+  }
+
+  const runningPorts = normalizeRunningPorts(runningTerminalPorts[terminalId]);
+  const primaryWebPort = runningPorts[0] ?? null;
+  const extraWebPortCount = runningPorts.length > 1 ? runningPorts.length - 1 : 0;
+  const label =
+    runningPorts.length === 0
+      ? "Terminal process running"
+      : runningPorts.length === 1
+        ? `Open web server: http://localhost:${primaryWebPort}`
+        : `Open web server: http://localhost:${primaryWebPort} (detected web ports: ${runningPorts.join(", ")})`;
+
+  return {
+    label,
+    primaryWebPort,
+    extraWebPortCount,
+  };
C
cursorBotApr 4, 2026

extraWebPortCount computed but never used in rendering

Low Severity

The TerminalRuntimeStatus interface declares extraWebPortCount (line 81) and terminalRuntimeStatus computes it (line 102), but it's never consumed in the rendering code. The runtimeStatus object is only accessed for primaryWebPort and label in the JSX, making extraWebPortCount dead code.

Reviewed by Cursor Bugbot for commit 6aac79caff618d7affa32076be551dea38b49819. Configure here.

apps/web/src/components/ChatView.tsx Β· File comment

M
macroscopeappBotApr 4, 2026

🟒 Low

https://github.com/pingdotgg/t3code/blob/05a7018f0302121cfc15ce9bf2db72498f5e09d4/apps/web/src/components/ChatView.tsx#L435

selectThreadTerminalState returns raw persisted ThreadTerminalState without normalization, so when old persisted data lacks runningTerminalPorts, terminalState.runningTerminalPorts is undefined. This undefined is then passed directly to ThreadTerminalDrawer at line 562, likely causing runtime errors when the component expects a Record<string, number[]>. The persist middleware has no migration to add the missing field to existing data.

βœ… Resolved in 037a7719fa567006c0ddafe4137a7b162326593e

apps/server/src/terminalProcessInspector/index.ts Β· Earlier revision 05a7018f Β· right 56

Original diff
@@ -0,0 +1,56 @@
+import { collectPosixProcessFamilyPids, checkPosixListeningPorts } from "./posix";
+import {
+  type TerminalSubprocessActivity,
+  type TerminalSubprocessChecker,
+  type TerminalSubprocessInspector,
+  type TerminalWebPortInspector,
+} from "./types";
+import { checkWindowsListeningPorts, collectWindowsChildPids } from "./win32";
+
+export { arePortListsEqual, normalizeRunningPorts } from "./utils";
+
+export type {
+  TerminalSubprocessActivity,
+  TerminalSubprocessChecker,
+  TerminalSubprocessInspector,
+  TerminalWebPortInspector,
+} from "./types";
+
+export async function defaultSubprocessInspector(
+  terminalPid: number,
+): Promise<TerminalSubprocessActivity> {
+  if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
+    return { hasRunningSubprocess: false, runningPorts: [] };
+  }
+
+  if (process.platform === "win32") {
+    const childPids = await collectWindowsChildPids(terminalPid);
+    const processPidsForPortScan = [terminalPid, ...childPids];
+    const runningPorts = await checkWindowsListeningPorts(processPidsForPortScan);
+    return {
+      hasRunningSubprocess: childPids.length > 0 || runningPorts.length > 0,
+      runningPorts,
+    };
+  }
+
+  const processFamilyPids = await collectPosixProcessFamilyPids(terminalPid);
+  if (processFamilyPids.length === 0) {
+    return { hasRunningSubprocess: false, runningPorts: [] };
+  }
+
+  const subprocessPids = processFamilyPids.filter((pid) => pid !== terminalPid);
+  const runningPorts = await checkPosixListeningPorts(processFamilyPids);
+  return {
+    hasRunningSubprocess: subprocessPids.length > 0 || runningPorts.length > 0,
+    runningPorts,
+  };
+}
+
+export function subprocessCheckerToInspector(
+  subprocessChecker: TerminalSubprocessChecker,
+): TerminalSubprocessInspector {
+  return async (terminalPid: number) => ({
+    hasRunningSubprocess: await subprocessChecker(terminalPid),
+    runningPorts: [],
+  });
+}
C
cursorBotApr 4, 2026

Exported subprocessCheckerToInspector is never imported or used

Low Severity

subprocessCheckerToInspector is exported from the terminalProcessInspector module but is never imported anywhere in the codebase. Manager.ts (the sole consumer of this module) implements the same adapter inline when options.subprocessChecker is provided. This is dead code that also pulls in the unused TerminalSubprocessChecker type from types.ts.

Reviewed by Cursor Bugbot for commit 05a7018f0302121cfc15ce9bf2db72498f5e09d4. Configure here.

apps/server/src/process/Layers/WebPortInspector.ts Β· Earlier revision 6aac79ca Β· right 138

Original diff
@@ -0,0 +1,179 @@
+import { Buffer } from "node:buffer";
+
+import { Effect, Layer, Option, Stream } from "effect";
+import { HttpClient, HttpClientRequest } from "effect/unstable/http";
+
+import { WebPortInspectionError } from "../Errors";
+import type { WebPortInspectorShape } from "../Services/WebPortInspector";
+import { WebPortInspector } from "../Services/WebPortInspector";
+
+const DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS = 2_000;
+const WEB_PORT_PROBE_MAX_BODY_BYTES = 8_192;
+
+interface WebProbeResult {
+  readonly status: number;
+  readonly contentType: string;
+  readonly body: string;
+  readonly location: string;
+}
+
+function normalizeHeaderValue(value: string | string[] | undefined): string {
+  if (typeof value === "string") return value;
+  if (Array.isArray(value)) return value[0] ?? "";
+  return "";
+}
+
+function isLikelyWebProbe(result: WebProbeResult | null): boolean {
+  if (!result) return false;
+  if (result.status === 404) return false;
+  if (result.status >= 300 && result.status < 400 && result.location.length > 0) {
+    return true;
+  }
+  const contentType = result.contentType.toLowerCase();
+  if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) {
+    return true;
+  }
+  const body = result.body.toLowerCase();
+  return body.includes("<!doctype") || body.includes("<html") || body.includes("<head");
+}
+
+const collectBodyPreview = <E>(
+  stream: Stream.Stream<Uint8Array, E>,
+): Effect.Effect<string, never> =>
+  stream.pipe(
+    Stream.decodeText(),
+    Stream.runFold(
+      () => ({
+        text: "",
+        bytes: 0,
+      }),
+      (state, chunk) => {
+        if (state.bytes >= WEB_PORT_PROBE_MAX_BODY_BYTES) {
+          return state;
+        }
+
+        const chunkBytes = Buffer.byteLength(chunk);
+        const remainingBytes = WEB_PORT_PROBE_MAX_BODY_BYTES - state.bytes;
+        if (chunkBytes <= remainingBytes) {
+          return {
+            text: `${state.text}${chunk}`,
+            bytes: state.bytes + chunkBytes,
+          };
+        }
+
+        return {
+          text: `${state.text}${Buffer.from(chunk).subarray(0, remainingBytes).toString("utf8")}`,
+          bytes: state.bytes + remainingBytes,
+        };
+      },
+    ),
+    Effect.map((preview) => preview.text),
+    Effect.orElseSucceed(() => ""),
+  );
+
+const makeWebPortInspector = Effect.gen(function* () {
+  const httpClient = yield* HttpClient.HttpClient;
+
+  const probeWebPortOnHost = Effect.fn("process.probeWebPortOnHost")(function* (
+    port: number,
+    host: string,
+  ): Effect.fn.Return<WebProbeResult | null, WebPortInspectionError> {
+    const request = HttpClientRequest.get(`http://${host}:${port}/`).pipe(
+      HttpClientRequest.setHeaders({
+        accept: "text/html,application/xhtml+xml;q=1,*/*;q=0.1",
+      }),
+    );
+
+    return yield* httpClient.execute(request).pipe(
+      Effect.flatMap((response) =>
+        Effect.gen(function* () {
+          const status = response.status;
+          const contentType = normalizeHeaderValue(response.headers["content-type"]);
+          const location = normalizeHeaderValue(response.headers.location);
+
+          if (
+            (status >= 300 && status < 400 && location.length > 0) ||
+            contentType.toLowerCase().includes("text/html") ||
+            contentType.toLowerCase().includes("application/xhtml+xml")
+          ) {
+            return {
+              status,
+              contentType,
+              location,
+              body: "",
+            } satisfies WebProbeResult;
+          }
+
+          const body = yield* collectBodyPreview(response.stream);
+          return {
+            status,
+            contentType,
+            location,
+            body,
+          } satisfies WebProbeResult;
+        }),
+      ),
+      Effect.timeoutOption(DEFAULT_WEB_PORT_PROBE_TIMEOUT_MS),
+      Effect.flatMap(
+        Option.match({
+          onNone: () =>
+            Effect.fail(
+              new WebPortInspectionError({
+                port,
+                host,
+                detail: "HTTP probe timed out.",
+              }),
+            ),
+          onSome: Effect.succeed,
+        }),
+      ),
+      Effect.mapError(
+        (cause) =>
+          new WebPortInspectionError({
+            port,
+            host,
+            detail: "Failed to execute HTTP probe request.",
+            cause,
+          }),
+      ),
C
cursorBotApr 4, 2026

Timeout errors double-wrapped by subsequent mapError

Medium Severity

The Effect.mapError at line 130 catches all errors flowing through the pipeline, including the WebPortInspectionError already created by the timeout handler at line 120–126. When a probe times out, the error with detail "HTTP probe timed out." gets wrapped again into a new WebPortInspectionError with detail "Failed to execute HTTP probe request.", burying the original message in cause. This obscures the real failure reason in logs and error reporting.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 6aac79caff618d7affa32076be551dea38b49819. Configure here.

apps/web/src/store.ts Β· Earlier revision 7926636a Β· right 706–734

Original diff
@@ -674,24 +705,32 @@ export function reducer(state: AppState, action: Action): AppState {
         threads: updateThread(state.threads, action.event.threadId, (thread) => {
           const normalizedThread = normalizeThreadTerminals(thread);
           const runningTerminalIdSet = new Set(normalizedThread.runningTerminalIds);
+          const runningTerminalPorts = { ...normalizedThread.runningTerminalPorts };
           if (
             action.event.type === "started" ||
             action.event.type === "restarted"
           ) {
             runningTerminalIdSet.delete(action.event.terminalId);
+            delete runningTerminalPorts[action.event.terminalId];
           } else if (action.event.type === "activity") {
             if (action.event.hasRunningSubprocess) {
               runningTerminalIdSet.add(action.event.terminalId);
+              runningTerminalPorts[action.event.terminalId] = normalizeRunningPorts(
+                action.event.runningPorts,
+              );
             } else {
               runningTerminalIdSet.delete(action.event.terminalId);
+              delete runningTerminalPorts[action.event.terminalId];
             }
           } else if (action.event.type === "exited" || action.event.type === "error") {
             runningTerminalIdSet.delete(action.event.terminalId);
+            delete runningTerminalPorts[action.event.terminalId];
           }
 
           return normalizeThreadTerminals({
             ...normalizedThread,
             runningTerminalIds: [...runningTerminalIdSet],
+            runningTerminalPorts,
           });

apps/web/src/store.ts Β· right 706–734

C
coderabbitaiBotFeb 14, 2026

⚠️ Potential issue | πŸ”΄ Critical

🧩 Analysis chain

🏁 Script executed:

shell
1# Search for terminal-related schema files in the contracts package
2fd -e ts -e tsx "terminal" packages/contracts/src/

Repository: pingdotgg/codething-mvp

Length of output: 141


🏁 Script executed:

shell
1# Search for the activity event schema and runningPorts definition
2rg "activity|runningPorts" packages/contracts/src/ -A 5 -B 2

Repository: pingdotgg/codething-mvp

Length of output: 1679


🏁 Script executed:

shell
1# Search for where terminal events are handled/validated in the store
2rg "terminalEventSchema|action\.event" apps/web/src/store.ts -B 5 -A 3

Repository: pingdotgg/codething-mvp

Length of output: 1667


🏁 Script executed:

shell
1# Check the full context of the event handling to understand the validation flow
2head -n 750 apps/web/src/store.ts | tail -n 100

Repository: pingdotgg/codething-mvp

Length of output: 3850


🏁 Script executed:

shell
1# Search for where APPLY_TERMINAL_EVENT is dispatched and how events are validated
2rg "APPLY_TERMINAL_EVENT|dispatch.*terminal" apps/web/src/ -B 3 -A 3

Repository: pingdotgg/codething-mvp

Length of output: 3673


🏁 Script executed:

shell
1# Look for event validation or parsing logic
2rg "terminalEventSchema\.parse|safeParse.*terminal" apps/web/src/ -B 2 -A 2

Repository: pingdotgg/codething-mvp

Length of output: 49


🏁 Script executed:

shell
1# Check the TerminalEvent type definition and where it comes from
2rg "type TerminalEvent|TerminalEvent =" apps/web/src/store.ts -B 2 -A 5

Repository: pingdotgg/codething-mvp

Length of output: 49


🏁 Script executed:

shell
1# Look for imports and type definition source
2head -n 100 apps/web/src/store.ts | grep -E "import|from"

Repository: pingdotgg/codething-mvp

Length of output: 427


🏁 Script executed:

shell
1# Check api.terminal.onEvent to see if it validates events
2rg "api\.terminal|onEvent" apps/web/src/ -B 3 -A 5

Repository: pingdotgg/codething-mvp

Length of output: 8040


🏁 Script executed:

shell
1# Check the WebSocket transport implementation for event validation
2rg "transport\.subscribe|WS_CHANNELS" apps/web/src/wsNativeApi.ts -B 5 -A 10

Repository: pingdotgg/codething-mvp

Length of output: 2640


🏁 Script executed:

shell
1# Look at the entire wsNativeApi.ts to understand the event flow
2wc -l apps/web/src/wsNativeApi.ts

Repository: pingdotgg/codething-mvp

Length of output: 98


🏁 Script executed:

shell
1# Check if there's validation in the transport layer
2rg "parse|validate|schema" apps/web/src/wsNativeApi.ts -B 2 -A 2

Repository: pingdotgg/codething-mvp

Length of output: 49


🏁 Script executed:

shell
1# Find and check the wsTransport implementation
2fd -e ts "wsTransport"

Repository: pingdotgg/codething-mvp

Length of output: 94


🏁 Script executed:

shell
1# Check wsTransport.ts for validation logic
2cat apps/web/src/wsTransport.ts

Repository: pingdotgg/codething-mvp

Length of output: 5454


🏁 Script executed:

shell
1# Check if there's any validation on the event before it reaches the reducer
2rg "terminalEventSchema|parse|validate" apps/web/src/ -B 2 -A 2

Repository: pingdotgg/codething-mvp

Length of output: 8717


🏁 Script executed:

shell
1# Look at the full event handling in App.tsx to see if validation happens anywhere
2grep -n "api.terminal.onEvent\|dispatch" apps/web/src/App.tsx -A 10 | head -40

Repository: pingdotgg/codething-mvp

Length of output: 1109


Ensure terminal events are validated against the Zod schema before being processed.

The code accesses action.event.runningPorts without defensive handling, but more critically, events flowing from the WebSocket are not validated against terminalEventSchema before reaching the reducer. This violates the requirement to use Zod schemas from packages/contracts for type contracts. Even though the schema defines runningPorts with a .default([]), that validation never executes in the current implementation. Add validation in the event handler (App.tsx or wsNativeApi.ts) to parse events through the appropriate Zod schema before dispatch.

πŸ€– Prompt for AI Agents
1In `@apps/web/src/store.ts` around lines 706 - 734, The reducer mutates and reads
2action.event.runningPorts without ensuring events were validated by the
3terminalEventSchema; validate/parses incoming socket events through the Zod
4terminalEventSchema before dispatch so defaults (like runningPorts defaulting to
5[]) and types are enforced. Update the WebSocket/event handler (e.g., in App.tsx
6or wsNativeApi.ts) to call terminalEventSchema.parse or safeParse on the raw
7message and only dispatch the parsed value to the reducer (which includes
8normalizeThreadTerminals/runningTerminalPorts logic), and handle parse failures
9(log/ignore) so the reducer never receives unvalidated events.

apps/server/src/terminalProcessInspector/win32.ts Β· Earlier revision 05a7018f Β· right 28

Original diff
@@ -0,0 +1,58 @@
+import { runProcess } from "../processRunner";
+import { parsePidList, parsePortList } from "./utils";
+
+export async function collectWindowsChildPids(terminalPid: number): Promise<number[]> {
+  const command = [
+    `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,
+    "if (-not $children) { exit 0 }",
+    "$children | Select-Object -ExpandProperty ProcessId",
+  ].join("; ");
+  try {
+    const result = await runProcess(
+      "powershell.exe",
+      ["-NoProfile", "-NonInteractive", "-Command", command],
+      {
+        timeoutMs: 1_500,
+        allowNonZeroExit: true,
+        maxBufferBytes: 32_768,
+        outputMode: "truncate",
+      },
+    );
+    if (result.code !== 0) {
+      return [];
+    }
+    return parsePidList(result.stdout);
+  } catch {
+    return [];
+  }
+}
C
cursorBotApr 4, 2026

Windows misses grandchild ports unlike POSIX full-tree traversal

Medium Severity

collectWindowsChildPids only queries direct children via ParentProcessId = ${terminalPid}, while collectPosixProcessFamilyPids performs a full BFS traversal to collect the entire descendant tree. In a typical dev workflow (e.g., terminal β†’ npm β†’ node listening on port 3000), the Windows path misses the grandchild node process, so checkWindowsListeningPorts won't detect port 3000. The PR description states it "detects process families" on both platforms, but only POSIX actually walks the tree.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 05a7018f0302121cfc15ce9bf2db72498f5e09d4. Configure here.

apps/server/src/terminalManager.ts Β· Earlier revision b3e967e2 Β· right 844–844

Original diff
@@ -834,18 +836,29 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
           if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) {
             return;
           }
-          if (liveSession.hasRunningSubprocess === hasRunningSubprocess) {
+          const hasRunningSubprocess = activity.hasRunningSubprocess === true;
+          const runningPorts = hasRunningSubprocess
+            ? normalizeRunningPorts(
+                await this.detectWebPorts(normalizeRunningPorts(activity.runningPorts)),
+              )
+            : [];

apps/server/src/terminalManager.ts Β· right 844–844

M
macroscopeappBotFeb 16, 2026

src/terminalManager.ts:844

Stale update risk: after await this.detectWebPorts(...), the session may have restarted. Suggest re-checking that liveSession is still current (e.g., status === 'running' && pid === terminalPid) before updating subprocess state or emitting the activity event.

No longer relevant as of 05a7018f0302121cfc15ce9bf2db72498f5e09d4