import { detachAgentTool } from "./transport.js"; import { isLocallyProbeable, isRemoteTmuxKind, ownPaneTarget, targetOf, HERDR, HerdrTransport, activeTransport } from "../transports/index.js"; import { readAway, secondCoordinatorRefusal } from "./away.js"; import { randomUUID } from "node:crypto"; import { existsSync, openSync, statSync, watch } from "node:fs"; import { promises as fsp } from "node:fs"; import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { detectSpread, installedBuild, pidRunning } from "../server-spread.js"; import { z } from "zod"; import path from "node:path"; import { AGENTS_FILE, CURSOR_DIR, DEFAULT_ROOM, INBOX_DIR, ROOT, ROOM_FILE, ROOMS_DIR, ROOMS_FILE, STATUS_FILE, addMember, appendJsonl, cursorFile, deleteFile, ensureRoom, fileSize, getRooms, inboxFile, listCursorFiles, listInboxFiles, listTransportFiles, logFile, memberRooms, normalizeRoom, pidFile, readJson, readJsonl, receiptFile, listReceiptFiles, removeMember, rewriteJsonl, roomFile, rotateAgentToken, setRoomMeta, stashHistory, retrieveHistory, pruneHistory, transportFile, TRANSPORT_DIR, updateJson, listSessionFiles, readJsonStrict, type RoomRegistry, type SessionBinding, } from "../store.js"; import { recordAuthorityFor, resolveRole, CANONICAL_ROLE_IDS, roleInputSchema, isHuman, type RoleArg } from "../roles.js"; import { readHumans, recordHuman } from "../store.js"; import { type AgentEntry, type AgentRegistry, type Message, type StatusEntry, type Cursor, type Source, type TransportMarker, roomFeedOf, sourceFile, getOffset, setOffset, sysMsg, moveFile, STALE_MS, EVICT_MS, MAX_WAIT_MS, } from "./shared.js"; // ---------- register ---------- export const registerSchema = { agentId: z.string().min(1), project: z.string().optional(), role: roleInputSchema.optional(), // First-claim guard overrides (server.ts guardFirstClaim): claiming an id // that is PROVABLY LIVE on the bus refuses unless the call presents that // agent's token (tokens.json / coord-token) — `force` alone no longer // overrides a provably live incumbent (q-314e0187). `force` still bypasses // the guard when liveness cannot be verified, or the id is verified absent. // Both ignored once bound. token: z.string().optional(), force: z.boolean().optional(), // PROSE-ONLY EXEMPTION (Phase 5.1 Task 12.8). `true` grants it, `false` // revokes it, omitted leaves it exactly as it was — so an agent re-joining // after a /clear does not silently drop an exemption it was granted, and a // card that never heard of the flag cannot revoke one either. proseOnly: z.union([z.boolean(), z.object({ reason: z.string().min(1) })]).optional(), }; // Work out what `role`/`roleId` should become, or why the update is refused. // // Rules (Phase 8 Task 4): // - A DECLARED roleId is immutable. Re-declaring the same id is a no-op; // declaring a different one is rejected. // - displayName is always free to change — that is the whole point. // - A plain string never freezes an id (it only sets the display name), so // v1 agents that re-register under changing free text keep working exactly // as before, and their id stays derived. export function resolveRoleUpdate( agentId: string, existing: AgentEntry | undefined, input: RoleArg | undefined, ): { ok: true; role?: string; roleId?: string } | { ok: false; error: string } { if (input === undefined) return { ok: true, role: existing?.role, roleId: existing?.roleId }; const resolved = resolveRole(typeof input === "string" ? input : { ...input }); if (!resolved) return { ok: true, role: existing?.role, roleId: existing?.roleId }; const declared = typeof input === "object" && !!input.roleId; if (existing?.roleId && declared && resolved.roleId !== existing.roleId) { return { ok: false, error: `roleId '${existing.roleId}' is frozen for agent '${agentId}'; rejected attempt to change it to '${resolved.roleId}'. ` + `The display name is free to change — pass role.displayName instead.`, }; } // A bare string against an agent with a frozen id updates the name only. const nextRole = typeof input === "string" ? input : input.displayName ?? existing?.role ?? resolved.displayName; return { ok: true, role: nextRole, roleId: declared ? resolved.roleId : existing?.roleId }; } export async function registerTool(args: { agentId: string; project?: string; role?: RoleArg; proseOnly?: boolean | { reason: string }; }) { const before = await readJson(AGENTS_FILE, {}); const roleUpdate = resolveRoleUpdate(args.agentId, before[args.agentId], args.role); if (!roleUpdate.ok) return { ok: false as const, error: roleUpdate.error }; // 4.2 — a SECOND coordinator may not register while the first is away. // Checked on the RESOLVED role, not the string the caller passed: the refusal // has to see what the registry will actually record, or a spelling slips past // the guard and lands as `coordinator` anyway. const second = secondCoordinatorRefusal(readAway(), args.agentId, roleUpdate.roleId); if (second) return { ok: false as const, error: second }; const reg = await updateJson(AGENTS_FILE, {}, (current) => { const now = Date.now(); const existing = current[args.agentId]; current[args.agentId] = { agentId: args.agentId, project: args.project ?? existing?.project, role: roleUpdate.role, // Only ever written when the caller declared one — absent stays absent, // so an existing agents.json is never rewritten into a new shape. ...(roleUpdate.roleId ? { roleId: roleUpdate.roleId } : {}), registeredAt: existing?.registeredAt ?? now, lastHeartbeat: now, capabilities: existing?.capabilities, // ⟨q-18a719c5⟩ The entry is rebuilt here, so the stamp is rebuilt with it — from the // server answering THIS register, which is the one serving the seat now. ...answeringServerIdentity(), // Omitted → carried forward untouched. Only an explicit `false` revokes. ...(args.proseOnly === undefined ? existing?.proseOnly ? { proseOnly: existing.proseOnly } : {} : args.proseOnly === false ? {} : { proseOnly: { since: existing?.proseOnly?.since ?? now, ...(typeof args.proseOnly === "object" && args.proseOnly.reason ? { reason: args.proseOnly.reason } : existing?.proseOnly?.reason ? { reason: existing.proseOnly.reason } : {}), }, }), }; return current; }); const entry = reg[args.agentId]; // ⟨q-178878aa⟩ — a HUMAN registration is recorded durably: the registry entry itself is // evicted after EVICT_MS (no heartbeat, no transport), and the send path's exemption // must still see the human after that. if (isHuman(entry)) await recordHuman(args.agentId, { displayName: entry.role, by: `register:${args.agentId}` }); // Echo the record types this role may and may not emit. Record authority is // otherwise invisible until the first typed send is refused mid-work — this // is how an agent whose role owns `go`/`scope`/`verdict` finds out at // onboarding that it has to declare that role. const authority = recordAuthorityFor(entry); // A declared, non-canonical roleId still works (word-split carries authority), // but the next card should use the exact spelling — say so at onboarding, the // one moment the agent is reading its registration. const canonWarning = entry.roleId && !CANONICAL_ROLE_IDS.has(entry.roleId) ? `roleId '${entry.roleId}' is not canonical (canonical: ${[...CANONICAL_ROLE_IDS].join(", ")}). ` + `It keeps any authority its words carry (e.g. 'coord-qa' → 'qa'), but prefer the canonical id — ` + `role:{roleId:"", displayName:"${entry.role ?? entry.roleId}"}.` : undefined; // The exemption's own docs state the asymmetry, at the one moment the agent // granting itself one is reading the response. An exemption whose cost is // invisible to the agent holding it is how the rule decays. const proseOnlyEcho = entry.proseOnly ? { proseOnly: { ...entry.proseOnly, asymmetry: "GRANTED TO THE SENDER, PAID BY EVERY READER. An untyped message cannot be slimmed by the " + "transport (hooks/tier.mjs slims only when record.type is present), so every multi-line message " + "you send arrives in full in every reader's context — which is precisely the cost the typed-record " + "rule exists to remove. This is opt-in and reviewed, not self-service: it is visible in list_agents " + "and counted there, so if the exempt share climbs the drift is measurable. Drop it with " + "proseOnly:false as soon as the model behind this agent can pick a record.type.", }, } : {}; return { ok: true as const, ...(canonWarning ? { warning: canonWarning } : {}), agent: entry, ...proseOnlyEcho, resolvedRole: resolveRole(entry), recordAuthority: { ...authority, ...(authority.mayNotEmit.length ? { note: `this role may not emit ${authority.mayNotEmit.map((t) => `'${t}'`).join("/")} as a typed record — register with the owning role (e.g. role:{roleId:"coordinator"}) if it should. Text prefixes are unrestricted.` } : {}), }, }; } // ---------- unregister ---------- export const unregisterSchema = { agentId: z.string().min(1) }; export async function unregisterTool(args: { agentId: string }) { // If a transport is attached, take it down first so the pusher doesn't keep // re-publishing the marker after we drop the registry entry. const detach = await detachAgentTool(args); let existed = false; await updateJson(AGENTS_FILE, {}, (current) => { if (current[args.agentId]) { existed = true; delete current[args.agentId]; } return current; }); // Drop the agent from every channel's membership so it doesn't linger as a // ghost in list_rooms / joinedRooms() after it's gone from the registry. const leftRooms: string[] = []; await updateJson(ROOMS_FILE, {}, (current) => { for (const [chan, e] of Object.entries(current)) { if (e.members?.includes(args.agentId)) { e.members = e.members.filter((m) => m !== args.agentId); leftRooms.push(chan); } } return current; }); return { ok: true, removed: existed, detach, leftRooms }; } // ---------- quit ---------- // Clean shutdown: unregister (detach transport + leave rooms + remove registry // entry) then exit the MCP process. Only the bound identity can call this — // prevents one agent from killing another agent's session. export const quitSchema = { agentId: z.string().min(1) }; export async function quitTool(args: { agentId: string }): Promise { await unregisterTool(args); // Give stdio a moment to flush the JSON response before exiting setTimeout(() => process.exit(0), 150); // Return a result so the MCP framework sends the response before the // setTimeout fires — the caller will see this before the process dies. return { ok: true, message: `'${args.agentId}' unregistered — MCP process exiting` } as never; } // ---------- heartbeat ---------- export const heartbeatSchema = { agentId: z.string().min(1) }; /** * ⟨q-18a719c5⟩ THE ANSWERING SERVER'S IDENTITY — what `serverSpread` places a seat by. It exists * only inside the process that serves the seat, so every path on which a session binds an * identity stamps it: `register` (which `join` calls), the first-claim binding in server.ts * (a server that restarts and re-binds through any gated tool, `attach_agent` included), * `rename_agent` (the renaming session serves the new id), and `heartbeat`. Before this, * only `heartbeat` stamped, no code path called it, and `register` rebuilt the entry and * dropped whatever it had written. */ export function answeringServerIdentity(): { serverPid: number; serverStartedAt: number; serverModule?: string } { return { serverPid: process.pid, // Derived from uptime rather than read from a file: an mtime tracks writes (a reinstall of // identical bytes moves it) while uptime is a fact about THIS process. serverStartedAt: Date.now() - Math.round(process.uptime() * 1000), // WHAT this process is executing, not what it is labelled. Resolved from this module's own // URL, so a server running a dev `dist/` says so instead of inheriting the installed path. serverModule: installedBuild(import.meta.url, statSync)?.module, }; } /** Stamp an EXISTING registry entry with the answering server's identity. False when absent. */ export async function stampServerIdentity(agentId: string): Promise { let stamped = false; await updateJson(AGENTS_FILE, {}, (current) => { if (!current[agentId]) return current; Object.assign(current[agentId], answeringServerIdentity()); stamped = true; return current; }); return stamped; } export async function heartbeatTool(args: { agentId: string }) { let missing = false; await updateJson(AGENTS_FILE, {}, (current) => { if (!current[args.agentId]) { missing = true; return current; } current[args.agentId].lastHeartbeat = Date.now(); // ⛔ STAMP THE ANSWERING PROCESS — `⟨q-cec42e20⟩`; see `answeringServerIdentity`. Object.assign(current[args.agentId], answeringServerIdentity()); return current; }); if (missing) return { ok: false, error: `agent '${args.agentId}' not registered` }; return { ok: true }; } // ---------- list_agents ---------- export const listAgentsSchema = {} as const; export async function listAgentsTool() { const now = Date.now(); const evicted: string[] = []; // Load live transport markers first so we can refresh heartbeats for agents // whose pusher (or other transport daemon) is alive — the live process IS // the heartbeat, no separate ping needed. const liveTransports = await loadLiveTransports(); const reg = await updateJson(AGENTS_FILE, {}, (current) => { for (const [id, entry] of Object.entries(current)) { // A LIVE TRANSPORT PROTECTS AN AGENT FROM EVICTION. IT DOES NOT STAMP IT. // // This loop used to write `entry.lastHeartbeat = now` here, and // `agents.json` is ONE store shared by every fleet on the machine while // `list_agents` takes no project argument — so a call from one fleet // rewrote every other fleet's timestamps. Another fleet did not ask for // that, cannot see it, and it is not ours to write. // // kit#105 removed the fabricated value from the RESPONSE; the WRITE // stayed, so every consumer reading the file directly still saw // freshness this call had invented. // // AND IT MASKED STALL DETECTION. `stall_check` reads `lastHeartbeat` to // find agents that have gone quiet. Because any `list_agents` call // refreshed every live-transport agent, that clock could never age past // the threshold, so no-heartbeat could not fire for exactly the agents // most likely to be stuck — alive, attached, and doing nothing. // // Nothing is lost: the pusher calls `heartbeat` every 60s // (scripts/coord-pusher.mjs:181), so an attached agent has a REAL // heartbeat. This stamp only ever overwrote a true value with a // simultaneous one. if (liveTransports.has(id)) continue; // A human has no heartbeat to go stale; evicting one is how the exemption went blind. if (isHuman(entry)) continue; if (now - entry.lastHeartbeat > EVICT_MS) { evicted.push(id); delete current[id]; } } return current; }); // A LIVE-TRANSPORT AGENT EMITS NO HEARTBEAT FIELDS, because the value would be // the one this call just wrote. // // The loop above stamps `lastHeartbeat = now` on every live-transport agent and // the response then reports it: THE ACT OF OBSERVING SETS THE THING OBSERVED, so // `secondsSinceHeartbeat: 0` is the caller's own timestamp handed back. Measured // live: 12 agents, 3 distinct `lastHeartbeat` values, TEN sharing one — every // live-transport agent across two fleets. // // THE DESIGN IS RIGHT AND THE FIELD NAME IS THE DEFECT. "The live process IS the // heartbeat, no separate ping needed" is a defensible answer to *is this agent // alive*, and `online` already carries it, derived from `transport` // independently. What is dishonest is emitting it as `lastHeartbeat` / // `secondsSinceHeartbeat`, which read as *when did THIS agent last check in* — // the value is honest about what it means and dishonest about what it is called. // // AN ABSENT FIELD IS HONEST; A WRONG ONE IS NOT. Nothing loses information: // liveness for these agents is `online`/`transport`, and per-agent freshness that // is genuinely read from server state is what `ping` reports. For agents WITHOUT // live transport the fields are accurate and are kept — which is exactly the // population where they are load-bearing. const agents = Object.values(reg).map((a) => { const transport = liveTransports.get(a.agentId); const merged = [...(a.capabilities ?? [])]; if (transport && !merged.includes(transport.transport)) merged.push(transport.transport); const { lastHeartbeat, ...rest } = a; const heartbeatFields = transport ? { // NOT "refreshed BY this call" — that write was removed in #137 // precisely because it stamped every OTHER fleet's agents too // (agents.json is shared, list_agents takes no project argument). // Measured after the removal (Task 13.5): calling list_agents and // re-reading agents.json directly leaves lastHeartbeat UNCHANGED. // The field is omitted because the value is STALE-BY-DESIGN for a // live-transport agent, not because this call would overwrite it — // an explanation that outlived the code it explained is the same // carrier-gap shape as a stated cause nobody re-checked. heartbeatSource: "omitted — a live-transport agent's raw lastHeartbeat measures time since it last JOINED, not activity (nothing else writes it for a local transport; Task 13.3/13.4). Liveness is `online`/`transport`; for per-agent freshness read from server state, use `ping`.", } : { lastHeartbeat, secondsSinceHeartbeat: Math.floor((now - lastHeartbeat) / 1000) }; return { ...rest, online: transport ? true : now - a.lastHeartbeat < STALE_MS, ...heartbeatFields, capabilities: merged.length > 0 ? merged : undefined, transport: transport ? { kind: transport.transport, tmuxTarget: transport.tmuxTarget, pid: transport.pid, // `attached` alone said nothing about WHAT is attached. A pusher // started `--no-room` delivers DMs only, and its marker used to be // indistinguishable from a full one — so an agent could sit with // its room feed off while status read healthy (worker-2). // // Absent is UNKNOWN, never "on": an older pusher's marker cannot // answer, and answering for it is how the original defect worked. rooms: roomFeedOf(transport), } : undefined, }; }); // COUNTED, so drift is measurable (Task 12.8). "No exemptions" and "I did not // look" are different claims, so the block is always present and always // carries its denominator — a bare list of ids would read as zero on a bus // where the field had simply never been written. const exempt = agents.filter((a) => a.proseOnly); const proseOnly = { exempt: exempt.map((a) => ({ agentId: a.agentId, since: a.proseOnly!.since, reason: a.proseOnly!.reason })), count: exempt.length, total: agents.length, share: agents.length ? Number((exempt.length / agents.length).toFixed(3)) : 0, note: "prose-only agents are exempt from the typed-record rule on agent→agent sends. The exemption is " + "granted to the SENDER and paid by every READER: their multi-line messages cannot be slimmed. " + "A rising share means the rule is decaying.", }; /* * ⛔⛆ AND WHETHER THOSE SEATS AGREE ABOUT WHAT THEY ARE RUNNING — `⟨q-cec42e20⟩`. * * Reported HERE because this is the verb that already answers "who is live", and the * spread is a property of that same population: a reader asking who is on the bus is * exactly the reader who needs to know their servers are not the same build. * * ⚠ IT NEVER REPORTS AGREEMENT IT DID NOT ESTABLISH. A seat that has not stamped its * identity is UNCOMPARABLE and poisons the verdict to CANNOT_COMPARE rather than being * dropped from the population — because "every seat I could read agrees" and "the * fleet agrees" are different claims, and only the second is what a reader will act on. */ const spread = detectSpread( Object.values(reg) .filter((a) => liveTransports.has(a.agentId) || now - a.lastHeartbeat < STALE_MS) .map((a) => ({ agentId: a.agentId, serverPid: a.serverPid, serverStartedAt: a.serverStartedAt, serverModule: a.serverModule, })), installedBuild(import.meta.url, statSync), // ⟨q-18a719c5⟩ a stamp whose server is gone reads unknown: ask the kernel, here, in production. { isRunning: pidRunning }, ); // ⟨q-178878aa⟩ — the humans the bus knows: visible here, read at send time, never evicted. const humans = Object.entries(await readHumans()).map(([id, h]) => ({ id, ...h })); return { agents, evicted, proseOnly, humans, serverSpread: spread }; } /** * EVERY marker on disk, READ-ONLY. * * `loadLiveTransports` below deletes any marker it judges not-live, which is * right for the delivery paths that own that garbage collection and WRONG for a * diagnostic: `capabilities` must be able to describe the fleet without changing * it. Using the reaping loader from a read-only verb made a mixed-fleet check * delete the very marker it was reporting (caught by its own test — the second * disagreeing agent vanished between write and read). * * Liveness is reported per marker rather than filtered on, because a STALE marker * naming a different transport is more alarming than a live one, not less: it is * a seat that may come back on the wrong transport. Filtering it out would make * the remote kind — whose liveness is heartbeat-based and therefore absent * without a registry entry — systematically invisible to this check. */ export async function readAllTransportMarkers(): Promise< { marker: TransportMarker; live: boolean }[] > { const out: { marker: TransportMarker; live: boolean }[] = []; const reg = await readJson(AGENTS_FILE, {}); const now = Date.now(); for (const fname of await listTransportFiles()) { const marker = await readJson(path.join(TRANSPORT_DIR, fname), null); if (!marker) continue; // unparseable: nothing to attribute, and not ours to delete out.push({ marker, live: isMarkerLive(marker, reg, now) }); } return out; } export async function loadLiveTransports(): Promise> { const out = new Map(); const reg = await readJson(AGENTS_FILE, {}); const now = Date.now(); for (const fname of await listTransportFiles()) { const file = path.join(TRANSPORT_DIR, fname); const marker = await readJson(file, null); if (!marker || !isMarkerLive(marker, reg, now)) { await deleteFile(file); continue; } out.set(marker.agentId, marker); } return out; } // Liveness for a transport marker. Local markers carry a real pid we can probe; // remote markers (tmux-push-remote, pid 0 on a foreign host) can't be — so we // trust the registry heartbeat the remote pusher refreshes (within STALE_MS). export function isMarkerLive(marker: TransportMarker, reg: AgentRegistry, now: number): boolean { if (isRemoteTmuxKind(marker.transport)) { const entry = reg[marker.agentId]; return !!entry && now - entry.lastHeartbeat < STALE_MS; } // Phase 5.4 Task 4 — a herdr marker has no pid (0): liveness is the PANE, asked of // herdr itself. "Could not ask" keeps the marker (unknown is not dead); only herdr // saying pane_not_found lets the registry drop it. if (marker.transport === HERDR) { const t = targetOf(marker); if (!t) return false; // Ask through the WIRED herdr transport (its runner is what tests inject and what the // fleet configured). A server with no herdr transport wired cannot ask, and "cannot // ask" keeps the marker: the herdr-configured server reaps its own dead panes. const active = activeTransport(); const exists = active?.kind === HERDR && active instanceof HerdrTransport ? active.paneExists(t) : null; return exists !== false; } return isPidAlive(marker.pid); } /** * ⟨q-abd88dd4⟩ Does this marker hold a LIVE LOCAL PROCESS — a pusher someone could signal, or * wait on? A herdr marker never does: it has no pusher, and its `pid` is addressed to pre-herdr * readers (pid 1, see `herdrMarkerPid`), so reading it as a process would make pid 1 look like a * running pusher. Every pid-as-process decision about a marker goes through here; liveness of a * herdr seat is `isMarkerLive`, which asks herdr for the pane. */ export function markerHoldsLiveProcess(marker: TransportMarker | null | undefined): boolean { if (!marker || marker.transport === HERDR) return false; return isPidAlive(marker.pid); } export function isPidAlive(pid: number): boolean { if (!pid || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (err) { const code = (err as NodeJS.ErrnoException).code; return code === "EPERM"; // EPERM = exists but not ours; ESRCH = gone } } // ---------- first-claim liveness evidence ---------- // What the TOFU binding guard (server.ts guardFirstClaim) consults before a // fresh session may claim an id. Three independent signals say "this id is // currently active": a fresh registry heartbeat, a live transport marker, and // a live session-binding marker from another pid. The verdict distinguishes // VERIFIED ABSENT (state readable, id not live → free to bind; refusing here // would break all onboarding) from CANNOT VERIFY (a state file exists but is // unreadable → the guard must refuse rather than treat corruption as absence). // `samePane`: a live local pusher for the claimed id types into THIS process's // own tmux pane — two sessions cannot share a pane, so this is the same seat // restarting in place, not a second session claiming a live id. export type ClaimEvidence = { live: boolean; verifiable: boolean; samePane: boolean; boundElsewhere: number; reasons: string[]; /** * SESSION processes still holding this identity, structured rather than * buried in `reasons` prose. * * A live SESSION binding is not the same fact as a live TRANSPORT marker, and * conflating them is what let a refusal read as routine. The marker's pid is * the pusher daemon — expected, and doing its job. A session binding for this * id, alive, that is not this process, is a LEFTOVER PROCESS holding an * identity: yesterday two sessions held worker-3's, and the refusal that * reported it sent the reader to re-join. * * Named so the reader can look at the pid instead of routing around it. */ leakedSessions: { pid: number; via: string }[]; }; export async function liveClaimEvidence(agentId: string, now: number): Promise { const reasons: string[] = []; const leakedSessions: { pid: number; via: string }[] = []; let verifiable = true; let samePane = false; let heartbeatFresh = false; let markerLive = false; let boundElsewhere = 0; let reg: AgentRegistry = {}; try { reg = await readJsonStrict(AGENTS_FILE, {}); } catch { verifiable = false; reasons.push("agents.json exists but cannot be parsed — heartbeat liveness is unverifiable"); } const entry = reg[agentId]; if (entry && now - entry.lastHeartbeat < STALE_MS) { heartbeatFresh = true; reasons.push(`fresh registry heartbeat ${Math.floor((now - entry.lastHeartbeat) / 1000)}s ago`); } let marker: TransportMarker | null = null; try { marker = await readJsonStrict(transportFile(agentId), null); } catch { verifiable = false; reasons.push("transport marker exists but cannot be parsed — transport liveness is unverifiable"); } if (marker && isMarkerLive(marker, reg, now)) { markerLive = true; reasons.push( `live ${marker.transport} transport (pid ${marker.pid}${targetOf(marker) ? `, pane ${targetOf(marker)}` : ""})`, ); // ⟨q-f995c3c7⟩ THE SAME SEAT RESTARTING INTO ITS OWN PANE, for herdr as well as tmux. This read // `process.env.TMUX_PANE` alone, which a herdr seat never has, so the exception could not fire // for the fleet that runs on herdr: every seat was refused by its own leftover marker (6 of 6 on // 2026-09-23). The pane comes from THIS process's environment, never from the caller's argument. const ownPane = ownPaneTarget(marker.transport); if (targetOf(marker) && ownPane && targetOf(marker) === ownPane) { samePane = true; reasons.push(`the marker names THIS process's own pane (${ownPane})`); } } for (const file of await listSessionFiles()) { let s: SessionBinding | null = null; try { s = await readJsonStrict(file, null); } catch { verifiable = false; reasons.push(`session binding ${path.basename(file)} cannot be parsed — unverifiable (doctor fix cleans it)`); continue; } if (!s || s.agentId !== agentId || s.pid === process.pid) continue; if (isPidAlive(s.pid)) { boundElsewhere++; leakedSessions.push({ pid: s.pid, via: String(s.via ?? "unknown") }); reasons.push(`another live session (pid ${s.pid}, via ${s.via}) is already bound to this id`); } } return { live: heartbeatFresh || markerLive || boundElsewhere > 0, verifiable, samePane, boundElsewhere, reasons, leakedSessions, }; } // ---------- rename_agent (NICK) ---------- export const renameAgentSchema = { agentId: z.string().min(1), newAgentId: z.string().min(1), }; export async function renameAgentTool(args: { agentId: string; newAgentId: string }) { const oldId = args.agentId; const newId = args.newAgentId; if (oldId === newId) return { ok: false, error: "new id is identical to the current id" }; const reg = await readJson(AGENTS_FILE, {}); if (!reg[oldId]) return { ok: false, error: `agent '${oldId}' not registered` }; if (reg[newId]) return { ok: false, error: `agent '${newId}' already exists` }; const joined = await memberRooms(oldId); // A running pusher has the OLD agentId (and its file paths) baked into its // env, so after we migrate the inbox/cursor below it would keep tailing the // now-empty old inbox while new DMs land in the new one — silently breaking // delivery and orphaning the moved marker. Take it down first; the caller // must re-attach under the new id (join/attach_agent) to restore push. const liveTransport = await readJson(transportFile(oldId), null); let detachedTransport = false; if (markerHoldsLiveProcess(liveTransport)) { await detachAgentTool({ agentId: oldId }); detachedTransport = true; } // Registry: move the entry under the new key. await updateJson(AGENTS_FILE, {}, (current) => { if (current[oldId]) { // ⟨q-18a719c5⟩ The renaming session serves the new id, so the stamp is re-taken from it. current[newId] = { ...current[oldId], agentId: newId, ...answeringServerIdentity() }; delete current[oldId]; } return current; }); // Channel memberships: rename in place. await updateJson(ROOMS_FILE, {}, (current) => { for (const e of Object.values(current)) { if (e.members?.includes(oldId)) e.members = e.members.map((m) => (m === oldId ? newId : m)); } return current; }); // Per-agent files: inbox, cursor. (The transport marker was already removed // above if a pusher was live; nothing to move otherwise.) await moveFile(inboxFile(oldId), inboxFile(newId)); await moveFile(cursorFile(oldId), cursorFile(newId)); await moveFile(transportFile(oldId), transportFile(newId)); // Identity-binding token rotation: if tokens.json exists and had the old // id, move its token to the new id atomically. Lets the same bearer keep // authenticating after rename — no-op if binding isn't configured. await rotateAgentToken(oldId, newId); // Broadcast a NICK notice to every channel the agent was in. for (const chan of joined) { await appendJsonl(roomFile(chan), sysMsg(newId, chan, `is now known as ${newId} (was ${oldId})`)); } return { ok: true, from: oldId, to: newId, rooms: joined, detachedTransport, ...(detachedTransport ? { warning: `the live tmux-push transport was detached during rename — re-attach as '${newId}' (e.g. join/attach_agent) to restore real-time delivery` } : {}), }; } // ---------- force_unregister ---------- export const forceUnregisterSchema = { targetAgentId: z.string().min(1), }; // Admin eviction — same logic as unregister but bypasses the identity gate // so the caller does not need to be the target agent. export async function forceUnregisterTool(args: { targetAgentId: string }) { return unregisterTool({ agentId: args.targetAgentId }); }