/* * ⟨q-8a3f1c05⟩ — A HALF-RESTARTED SEAT IS A STATEMENT, NOT AN INFERENCE. * * Each seat runs TWO long-lived processes from the installed tree: the MCP server * (answers `capabilities`) and the tmux pusher (`hooks/tmux-pusher.mjs --agent `, * pastes into the pane). A restart cycles the server and leaves the pusher, so a seat * comes back running new server code behind an old pusher — fully responsive, `online: * true`, and invisible to every instrument that reads the server. Measured 2026-09-14 * during the staged restart: stage 1's pusher was three days older than its server and * was found only because that seat happened to look; a stale pusher still delivers. * * THE INSTRUMENT ALREADY EXISTED AND NOBODY CALLED IT: pushers self-identify on the * command line (`--agent `), so `ps` + `--agent` + the installed HOOK's mtime is a * per-seat pusher-staleness probe. This module states it, per seat, beside the server * half — in words, not a pair of timestamps a reader has to subtract. * * THE HOOK'S OWN MTIME, NEVER A SIBLING'S: `hooks/tmux-pusher.mjs` is the file the * pusher loads. `package.json`, `dist/…` and the hook all carry the same instant under * a whole-tree install, but an install that rewrites some files and not others breaks * that, and nothing checks it. So the pusher half reads the hook file directly. * * THE LIMIT THIS CARRIES RATHER THAN SOLVES: servers carry no `--agent`, so the server * side cannot be keyed to a seat from `ps`. Only the ANSWERING process knows its own * start time (`answeredBy.pid`). For every other seat the server half is reported as * UNOBSERVABLE from here — said, not inferred from the marker's attach-time stamp. */ import { execFileSync } from "node:child_process"; import { statSync } from "node:fs"; import path from "node:path"; import type { TransportMarker } from "../transports/types.js"; export type ProcessFacts = { pid: number; startedAt: number | null; command: string | null; alive: boolean }; export type PsReader = (pid: number) => ProcessFacts; export type StatReader = (p: string) => { mtimeMs: number }; export type Installed = { module: string; hookPath: string; hookMtime: number | null; buildMtime: number | null }; export type ServerFacts = { pid: number; startedAt: number; buildMtime: number | null } | null; export type SeatBuild = { agentId: string; verdict: "current" | "half-restarted" | "stale" | "unknown"; statement: string; server: { observable: boolean; pid: number | null; startedAt: string | null; buildMtime: string | null; current: boolean | null; note: string }; pusher: { pid: number | null; alive: boolean | null; keyedByAgentArg: boolean | null; startedAt: string | null; /** The hook file THIS pusher loads, read off its own command line — never a sibling, never the server's tree. */ hookPath: string | null; hookMtime: string | null; /** false when the pusher runs from a different tree than the answering server (a dev checkout vs the global install, or two installs). */ sameTreeAsServer: boolean | null; current: boolean | null; note: string; }; }; const iso = (ms: number | null | undefined) => (typeof ms === "number" && Number.isFinite(ms) ? new Date(ms).toISOString() : null); /** `ps` for one pid: start time and command line. `lstart` is portable across macOS and Linux. */ export const psReader: PsReader = (pid) => { try { const out = execFileSync("ps", ["-o", "lstart=,command=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); if (!out) return { pid, startedAt: null, command: null, alive: false }; // lstart is 24 chars ("Mon Sep 14 14:38:24 2026"), then the command. const m = /^(\w{3}\s+\w{3}\s+\d+\s+[\d:]+\s+\d{4})\s+(.*)$/.exec(out); const startedAt = m ? Date.parse(m[1]) : NaN; return { pid, startedAt: Number.isFinite(startedAt) ? startedAt : null, command: m ? m[2] : out, alive: true }; } catch { return { pid, startedAt: null, command: null, alive: false }; } }; /** The installed tree's hook and build stamps, read from the files themselves. */ export function installedFrom(moduleRoot: string, stat: (p: string) => { mtimeMs: number } = statSync): Installed { const hookPath = path.join(moduleRoot, "hooks", "tmux-pusher.mjs"); const read = (p: string) => { try { return stat(p).mtimeMs; } catch { return null; } }; return { module: moduleRoot, hookPath, hookMtime: read(hookPath), buildMtime: read(path.join(moduleRoot, "package.json")) }; } const day = (ms: number) => new Date(ms).toISOString().slice(0, 10); /** * One seat's build state: the pusher half from its marker's pid via `ps`, keyed by the * `--agent` argument on that process's own command line; the server half from the * answering process when the seat IS the answering process, else unobservable. */ /** The hook file a pusher loads, read off its own command line: `node /hooks/tmux-pusher.mjs --agent `. */ export function hookPathOf(command: string): string | null { const m = /(\S*\/hooks\/tmux-pusher\.mjs)(?=\s|$)/.exec(command); return m ? m[1] : null; } export function seatBuildOf(input: { agentId: string; marker: TransportMarker | undefined; installed: Installed; ps: PsReader; server: ServerFacts; stat?: StatReader }): SeatBuild { const { agentId, marker, installed, ps, server } = input; const stat = input.stat ?? statSync; // ---- pusher half let pusher: SeatBuild["pusher"]; const empty = { pid: null, alive: null, keyedByAgentArg: null, startedAt: null, hookPath: null, hookMtime: null, sameTreeAsServer: null, current: null }; if (marker && marker.transport === "herdr") { // Phase 5.4 Task 4 — a herdr seat has NO pusher: delivery is in-process by the server, // so the seat's build is the server half alone, and this says so instead of reading pid 0. pusher = { ...empty, note: `herdr socket transport (pane ${marker.target ?? marker.tmuxTarget ?? "?"}): no pusher process — delivery is in-process by the answering server, so the server half is the whole seat` }; } else if (!marker || typeof marker.pid !== "number") { pusher = { ...empty, note: "no transport marker for this seat — no pusher pid to read" }; } else { const facts = ps(marker.pid); if (!facts.alive) { pusher = { ...empty, pid: marker.pid, alive: false, note: `the marker names pid ${marker.pid} and no such process is running — the pusher is dead, not stale` }; } else { const cmd = facts.command ?? ""; const keyed = /tmux-pusher\.mjs/.test(cmd) && new RegExp(`--agent\\s+${agentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`).test(cmd); if (!keyed) { pusher = { ...empty, pid: marker.pid, alive: true, keyedByAgentArg: false, startedAt: iso(facts.startedAt), note: `pid ${marker.pid} is alive but its command line does not say \`tmux-pusher.mjs --agent ${agentId}\` — the marker's pid is not this seat's pusher, so nothing is claimed about it` }; } else { // THE VERY FILE THIS PUSHER LOADS. Its path is on the command line; the mtime is // read from that path, not from the answering server's tree — a dev checkout // answering for a fleet of global-install pushers would otherwise call every // pusher stale against a file none of them ever loaded (measured while building // this: 6 of 6 read stale against a worktree's hook, all 6 current against their own). const hookPath = hookPathOf(cmd) ?? installed.hookPath; const fromOwnCmd = hookPathOf(cmd) !== null; let hookMtime: number | null = null; try { hookMtime = stat(hookPath).mtimeMs; } catch { hookMtime = null; } const sameTree = path.resolve(path.dirname(path.dirname(hookPath))) === path.resolve(installed.module); if (facts.startedAt === null || hookMtime === null) { pusher = { ...empty, pid: marker.pid, alive: true, keyedByAgentArg: true, startedAt: iso(facts.startedAt), hookPath, sameTreeAsServer: sameTree, note: facts.startedAt === null ? "ps gave no start time for the pusher" : `the hook ${hookPath} could not be read` }; } else { const current = facts.startedAt >= hookMtime; pusher = { pid: marker.pid, alive: true, keyedByAgentArg: true, startedAt: iso(facts.startedAt), hookPath, hookMtime: iso(hookMtime), sameTreeAsServer: sameTree, current, note: (current ? `pusher started ${iso(facts.startedAt)}, after its hook ${hookPath} (${iso(hookMtime)}) — it loaded that installed code` : `pusher started ${iso(facts.startedAt)}, BEFORE its hook ${hookPath} (${iso(hookMtime)}) — it runs the code it loaded then, and still delivers`) + (fromOwnCmd ? "" : " (hook path not on the command line; the answering server's tree was used)") + (sameTree ? "" : `; NOTE the pusher's tree differs from the answering server's (${installed.module})`), }; } } } } // ---- server half let srv: SeatBuild["server"]; if (!server) { srv = { observable: false, pid: null, startedAt: null, buildMtime: iso(installed.buildMtime), current: null, note: "the server side cannot be keyed to a seat from ps (servers carry no --agent); only the answering process knows its own start time (answeredBy.pid). Ask this seat's own `capabilities`." }; } else if (server.buildMtime === null) { srv = { observable: true, pid: server.pid, startedAt: iso(server.startedAt), buildMtime: null, current: null, note: "the installed package.json could not be read, so the server's build cannot be placed" }; } else { const current = server.startedAt >= server.buildMtime; srv = { observable: true, pid: server.pid, startedAt: iso(server.startedAt), buildMtime: iso(server.buildMtime), current, note: current ? `server started ${iso(server.startedAt)}, on the ${day(server.buildMtime)} install` : `server started ${iso(server.startedAt)}, BEFORE the ${day(server.buildMtime)} install` }; } // ---- the statement let verdict: SeatBuild["verdict"]; let statement: string; const p = pusher.current; const s = srv.current; if (s === true && p === true) { verdict = "current"; statement = `${agentId}: current — server and pusher both started after the ${day(server!.buildMtime!)} install.`; } else if (s === false && p === false) { verdict = "stale"; statement = `${agentId}: stale — server and pusher both predate the install; nothing on this seat runs the installed code.`; } else if (s === true && p === false) { verdict = "half-restarted"; statement = `${agentId}: HALF-RESTARTED — server on the ${day(server!.buildMtime!)} install, pusher started before it (${pusher.startedAt}). The seat sends through new code and receives through old; it looks healthy. Cycle the pusher: detach_agent, then attach_agent, from this seat.`; } else if (s === false && p === true) { verdict = "half-restarted"; statement = `${agentId}: HALF-RESTARTED the other way — pusher on the installed hook, server started before the ${day(server!.buildMtime!)} install. Reload the server (relaunch the MCP client for this seat).`; } else if (marker?.transport === "herdr" && s !== null) { verdict = s ? "current" : "stale"; statement = `${agentId}: ${s ? "current" : "stale"} — herdr socket transport, no pusher; the server ${s ? "started after" : "predates"} the ${day(server!.buildMtime!)} install and is the whole seat.`; } else if (s === null && p !== null) { verdict = "unknown"; statement = `${agentId}: pusher ${p ? "current" : "STALE — started before the installed hook"}; server UNOBSERVABLE from here (${srv.note}).`; } else { verdict = "unknown"; statement = `${agentId}: unknown — ${pusher.note}${srv.observable ? "" : `; ${srv.note}`}`; } return { agentId, verdict, statement, server: srv, pusher }; }