Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.15.102",
"version": "0.15.103",
"description": "ClosedLoop Desktop",
"author": "ClosedLoop AI <support@closedloop.ai>",
"private": true,
Expand Down
18 changes: 10 additions & 8 deletions apps/desktop/scripts/build-agent-monitor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -924,14 +924,6 @@ function runStartupBackfills(dbModule, handles) {

function startColdStartIngest(dbModule, signal) {
try {
const { ingestAllHarnesses } = require("./agent-monitor-shared/ingest-orchestrator");
const { importAllSessions, backfillCompactions } = require("../scripts/import-history");
const harnesses = [
{ key: "codex", importAll: require("./lib/codex-import").importAllCodexSessions },
{ key: "cursor", importAll: require("./lib/cursor-import").importAllCursorSessions },
{ key: "copilot", importAll: require("./lib/copilot-import").importAllCopilotSessions },
{ key: "opencode", importAll: require("./lib/opencode-import").importAllOpenCodeSessions },
];
let existingCount = 0;
try {
existingCount = dbModule.db.prepare("SELECT COUNT(*) AS c FROM sessions").get().c;
Expand All @@ -944,6 +936,16 @@ function startColdStartIngest(dbModule, signal) {
} catch {
/* ignore */
}
}
const { ingestAllHarnesses } = require("./agent-monitor-shared/ingest-orchestrator");
const { importAllSessions, backfillCompactions } = require("../scripts/import-history");
const harnesses = [
{ key: "codex", importAll: require("./lib/codex-import").importAllCodexSessions },
{ key: "cursor", importAll: require("./lib/cursor-import").importAllCursorSessions },
{ key: "copilot", importAll: require("./lib/copilot-import").importAllCopilotSessions },
{ key: "opencode", importAll: require("./lib/opencode-import").importAllOpenCodeSessions },
];
if (existingCount === 0) {
harnesses.unshift({
key: "claude",
importAll: async (db, opts) => {
Expand Down
329 changes: 329 additions & 0 deletions apps/desktop/src/main/agent-monitor-port-reconcile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
import { spawn } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";

import { gatewayLog } from "./gateway-logger.js";
import { isProcessAlive, signalProcess } from "./agent-monitor-process-utils.js";

const TAG = "agent-monitor";

// Substrings that identify ClosedLoop-owned holders of the fixed dashboard port.
//
// The legacy sidecar ran `agent-monitor/server/index.js` as a child process. PRD-407
// moved the runtime in-process, so the holder can now be the whole ClosedLoop
// app or Electron dev process. Treat full app processes as live instances that
// require consent; only the legacy sidecar marker can be silently reclaimed as
// an orphan when its parent is gone.
const LEGACY_SIDECAR_COMMAND_MARKER = "agent-monitor/server/index.js";
const CLOSEDLOOP_APP_COMMAND_MARKERS = [
LEGACY_SIDECAR_COMMAND_MARKER,
"ClosedLoop.app/Contents/MacOS/ClosedLoop",
"/Electron.app/Contents/MacOS/Electron",
];

const LSOF_TIMEOUT_MS = 1_500;
const PS_TIMEOUT_MS = 1_000;
const KILL_GRACE_MS = 2_000;
const KILL_TIMEOUT_MS = 2_000;
const EXIT_POLL_INTERVAL_MS = 100;

export interface PortHolder {
pid: number;
uid: number;
ppid: number;
command: string;
}

// foreign -> not ours; never touch it.
// orphan -> legacy sidecar, parent died (PPID 1).
// live -> a live ClosedLoop/Electron process; only kill with explicit consent.
export type HolderClass = "foreign" | "orphan" | "live";

export type ReconcileOutcome =
| "no-holder"
| "foreign"
| "killed-orphan"
| "killed-live"
| "blocked-live"
| "kill-failed";

export interface ReconcileOptions {
port: number;
pidFilePath: string;
selfUid: number;
// Invoked only for the `live` case. Returns true if the user consents to
// force-kill the other live instance. Injected so the Electron dialog stays
// out of this module and the decision path is unit-testable.
confirmKillLive: (holder: PortHolder) => Promise<boolean>;
}

// Pure classifier — the heart of the three-guard decision. Kept side-effect
// free so the full truth table can be asserted in unit tests.
export function classifyHolder(holder: PortHolder, selfUid: number): HolderClass {
const ownedByUs =
holder.uid === selfUid && isClosedLoopPortHolder(holder.command);
if (!ownedByUs) {
return "foreign";
}
// Orphan requires positive proof that a legacy child sidecar's parent is gone:
// a detached sidecar whose parent died is reparented to init (PPID 1). A full
// ClosedLoop.app/Electron process can also have a launchd-like parent shape, so
// app-process holders are always `live` and go through explicit consent.
if (holder.command.includes(LEGACY_SIDECAR_COMMAND_MARKER) && holder.ppid === 1) {
return "orphan";
}
return "live";
}

// Parse the first numeric pid from `lsof -t` output (one pid per line).
export function parseFirstPid(lsofOutput: string): number | null {
for (const line of lsofOutput.split("\n")) {
const pid = Number.parseInt(line.trim(), 10);
if (Number.isInteger(pid) && pid > 0) {
return pid;
}
}
return null;
}

// Parse a single `ps -o uid=,ppid=,command=` line into a PortHolder. Only the
// first non-empty line is considered, so a trailing/wrapped newline from an
// exotic `ps` implementation cannot defeat the single-line regex.
export function parsePsLine(pid: number, psOutput: string): PortHolder | null {
const firstLine = psOutput.trim().split("\n", 1)[0] ?? "";
const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(firstLine);
if (!match) {
return null;
}
return {
pid,
uid: Number.parseInt(match[1], 10),
ppid: Number.parseInt(match[2], 10),
command: match[3].trim(),
};
}

export function readRecordedPid(pidFilePath: string): number | null {
try {
const pid = Number.parseInt(readFileSync(pidFilePath, "utf-8").trim(), 10);
return Number.isInteger(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}

export function writeSidecarPidFile(pidFilePath: string, pid: number): void {
try {
mkdirSync(path.dirname(pidFilePath), { recursive: true });
writeFileSync(pidFilePath, String(pid), "utf-8");
} catch (error) {
gatewayLog.warn(TAG, `failed to write sidecar pid file: ${describe(error)}`);
}
}

export function removeSidecarPidFile(pidFilePath: string): void {
try {
rmSync(pidFilePath, { force: true });
} catch {
// Best-effort: a missing/locked pid file must never affect shutdown.
}
}

// Best-effort lookup of the process LISTENing on a loopback port. macOS/Linux
// only (relies on lsof + ps); returns null on win32 or any tooling failure, in
// which case the caller simply proceeds and the existing degrade path applies.
export async function findPortHolder(port: number): Promise<PortHolder | null> {
if (process.platform === "win32") {
return null;
}
// Match ANY address bound to the port, not just 127.0.0.1 — a foreign holder
// bound to 0.0.0.0/* (a stray dev server) would be missed by an @127.0.0.1
// filter, fall through to spawn, and reproduce the silent EADDRINUSE degrade
// (PR #257 review, P2). classifyHolder decides ours (loopback) vs foreign.
const lsofOut = await runCapture(
"lsof",
["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
LSOF_TIMEOUT_MS,
);
if (lsofOut === null) {
return null;
}
const pid = parseFirstPid(lsofOut);
if (pid === null) {
return null;
}
const psOut = await runCapture(
"ps",
["-o", "uid=,ppid=,command=", "-p", String(pid)],
PS_TIMEOUT_MS,
);
if (psOut === null) {
return null;
}
return parsePsLine(pid, psOut);
}

// Detect-and-reconcile preflight. Runs BEFORE the sidecar bind attempt and
// never throws — any failure degrades to "proceed and let the bind decide".
export async function reconcileAgentMonitorPort(
options: ReconcileOptions,
): Promise<ReconcileOutcome> {
const { port, pidFilePath, selfUid, confirmKillLive } = options;
const holder = await findPortHolder(port);
if (!holder) {
return "no-holder";
}

const klass = classifyHolder(holder, selfUid);

if (klass === "foreign") {
gatewayLog.warn(
TAG,
`port ${port} held by a non-ClosedLoop process pid=${holder.pid} (${holder.command}); the agent dashboard will be unavailable until it is freed`,
);
return "foreign";
}

if (klass === "orphan") {
// The PID file does not gate the kill (PPID 1 already proves orphan); it
// only annotates whether this was our own prior process vs another
// worktree's orphan, both of which we reclaim the same way.
const provenance =
readRecordedPid(pidFilePath) === holder.pid ? " (our prior instance)" : "";
if (await reclaimFromHolder(holder.pid, port, selfUid)) {
gatewayLog.info(
TAG,
`reclaimed port ${port} from orphaned sidecar pid=${holder.pid}${provenance}`,
);
return "killed-orphan";
}
gatewayLog.warn(
TAG,
`failed to reclaim port ${port} from orphaned sidecar pid=${holder.pid}`,
);
return "kill-failed";
}

// live: never kill without explicit consent.
const consent = await confirmKillLive(holder);
if (!consent) {
gatewayLog.warn(
TAG,
`port ${port} in use by another live ClosedLoop instance pid=${holder.pid}; left running at the user's request`,
);
return "blocked-live";
}
if (await reclaimFromHolder(holder.pid, port, selfUid)) {
gatewayLog.info(
TAG,
`reclaimed port ${port} from live ClosedLoop instance pid=${holder.pid} (user requested)`,
);
return "killed-live";
}
return "kill-failed";
}

// SIGTERM the holder's process group, wait for it to exit, then escalate to
// SIGKILL (group + bare pid, since a reparented orphan keeps its own pgid but
// the bare-pid fallback covers any edge case). Returns true once the process
// is gone — at which point the kernel has released its listening socket.
//
// TOCTOU guard: the holder was classified from an earlier lsof/ps snapshot. Re-
// confirm, immediately before signaling, that the same pid still holds the port
// AND is still ours (uid + command marker). This closes the window where the
// original holder exited and the OS recycled its pid for an unrelated process.
async function reclaimFromHolder(
pid: number,
port: number,
selfUid: number,
): Promise<boolean> {
const current = await findPortHolder(port);
if (!current) {
return true; // port already free — nothing to reclaim.
}
if (current.pid !== pid || current.uid !== selfUid || !isClosedLoopPortHolder(current.command)) {
gatewayLog.warn(
TAG,
`aborting reclaim: port ${port} holder changed since classification (now pid=${current.pid})`,
);
return false;
}

signalProcess(pid, "SIGTERM", { group: true });
if (await waitForProcessExit(pid, KILL_GRACE_MS)) {
return true;
}
signalProcess(pid, "SIGKILL", { group: true });
signalProcess(pid, "SIGKILL");
return waitForProcessExit(pid, KILL_TIMEOUT_MS);
}

async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) {
return true;
}
await delay(EXIT_POLL_INTERVAL_MS);
}
return !isProcessAlive(pid);
}

// Runs a read-only diagnostic command with a hard timeout. Resolves the raw
// stdout (possibly empty), or null on spawn error / timeout. Args are fixed and
// the only interpolated value is an integer pid / port — no shell, no untrusted
// string reaches argv.
function runCapture(
command: string,
args: string[],
timeoutMs: number,
): Promise<string | null> {
return new Promise((resolve) => {
let settled = false;
const finish = (value: string | null): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve(value);
};

let child;
try {
child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
} catch {
resolve(null);
return;
}

const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// ignore
}
finish(null);
}, timeoutMs);

let out = "";
child.stdout?.setEncoding("utf-8");
child.stdout?.on("data", (chunk: string) => {
out += chunk;
});
child.on("error", () => finish(null));
child.on("close", () => finish(out));
});
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function isClosedLoopPortHolder(command: string): boolean {
return CLOSEDLOOP_APP_COMMAND_MARKERS.some((marker) => command.includes(marker));
}

function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
Loading
Loading