import { statSync, promises as fs, existsSync, mkdirSync, readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { homedir } from "node:os"; import path from "node:path"; import lockfile from "proper-lockfile"; export const ROOT = process.env.AGENT_COORD_DIR ?? process.env.CLAUDE_COORD_DIR ?? path.join(homedir(), "agent-coord"); export const AGENTS_FILE = path.join(ROOT, "agents.json"); /** * ⟨q-178878aa⟩ — THE HUMANS THE BUS KNOWS, durably. A human is not an agent: no pusher, no * heartbeat, so a registry entry for one is evicted after EVICT_MS and the typed-record * exemption ("messages TO a human are exempt") could not see the one human it exists for — * the first David-facing send after the cutover was refused. This file is written when a * seat registers with a human role and never evicted; `AGENT_COORD_HUMANS` (comma list) * names humans for a store nobody has registered them in yet. Read at SEND time. */ export const HUMANS_FILE = path.join(ROOT, "humans.json"); export type HumanEntry = { since: number; displayName?: string; by?: string }; export type HumanRegistry = Record; export function envHumans(): string[] { return String(process.env.AGENT_COORD_HUMANS ?? "").split(",").map((s) => s.trim()).filter(Boolean); } /** Every known human: the durable file, unioned with the environment list (`by: "env"`). */ export async function readHumans(): Promise { const file = await readJson(HUMANS_FILE, {}); const out: HumanRegistry = { ...file }; for (const id of envHumans()) if (!out[id]) out[id] = { since: 0, by: "env" }; return out; } export async function isKnownHuman(id: string): Promise { return Boolean((await readHumans())[id]); } /** Record a human durably; an existing entry keeps its `since`. */ export async function recordHuman(id: string, meta: { displayName?: string; by?: string } = {}): Promise { const reg = await updateJson(HUMANS_FILE, {}, (cur) => { cur[id] = { since: cur[id]?.since ?? Date.now(), ...(meta.displayName ? { displayName: meta.displayName } : {}), ...(meta.by ? { by: meta.by } : {}) }; return cur; }); return reg[id]; } export const ROOM_FILE = path.join(ROOT, "room.jsonl"); export const STATUS_FILE = path.join(ROOT, "status.jsonl"); export const INBOX_DIR = path.join(ROOT, "inbox"); export const CURSOR_DIR = path.join(ROOT, "cursors"); export const TRANSPORT_DIR = path.join(ROOT, "transports"); export const PID_DIR = path.join(ROOT, "pids"); export const LOG_DIR = path.join(ROOT, "logs"); // Out-of-band delivery receipts (v0.9.0). A pusher stamps `receipts/.jsonl` // after it actually types a message into the receiving pane — proof of // delivery the *sender* can poll for, WITHOUT the receipt ever entering any // agent's context window (it lives in a file, not an inbox/room). This is what // lets send_command return a truthful `delivered:true` instead of merely // `written-to-jsonl:true`, at zero added agent token cost. export const RECEIPTS_DIR = path.join(ROOT, "receipts"); // Reversible-history cache (CCR pattern, v0.9.x). When read_messages would // flood an agent with a large channel backlog, the overflow (everything older // than the recent window) is stashed here as one entry and replaced inline by // a compact digest carrying a `hash`. The agent expands it on demand via // retrieve_room_history(hash). Entries are content+scope addressed, TTL'd, and // scoped to the (room, agent) they were produced for so a hash can't be // replayed to read a channel the caller never read itself. This is a cache of // data ALREADY present in rooms/.jsonl — not a new source of truth — so // losing it (TTL/eviction) only costs the agent a re-read at a higher limit. export const HISTORY_DIR = path.join(ROOT, "history"); export const HISTORY_TTL_MS = 30 * 60_000; // session-scale; mirrors headroom DEFAULT_CCR_TTL_SECONDS // Channels. The default channel `general` keeps using the legacy single-room // file (room.jsonl) + the flat `roomOffset` cursor key, so existing agents and // the notification hooks keep working with zero migration. Every other channel // lives in rooms/.jsonl with its offset under cursor.roomOffsets[chan]. export const ROOMS_DIR = path.join(ROOT, "rooms"); export const ROOMS_FILE = path.join(ROOT, "rooms.json"); export const DEFAULT_ROOM = "general"; // Cold storage (v0.15.0). Nothing on the bus is ever deleted by prune or // compaction — aged-out entries are APPENDED here first, one file per room // (general included, despite its live file being the legacy room.jsonl), plus // status.jsonl and inbox/.jsonl. The server never reads these back; // they exist for offline analysis of what happened in a room. Receipts are // the one exception: delivery proofs carry no analysis value and are deleted. export const ARCHIVE_DIR = path.join(ROOT, "archive"); export const ARCHIVE_ROOMS_DIR = path.join(ARCHIVE_DIR, "rooms"); export const ARCHIVE_INBOX_DIR = path.join(ARCHIVE_DIR, "inbox"); export const ARCHIVE_STATUS_FILE = path.join(ARCHIVE_DIR, "status.jsonl"); // Live session-binding markers (v0.20.0). One small file per *bound* stdio MCP // session: which agentId the session claimed, which pid holds it, and how the // bind was established. Written at bind time, removed on clean exit; a file // whose pid is dead is garbage doctor can clean. This is what makes two live // sessions bound to the same id VISIBLE (doctor `duplicate-session-binding`) // — in-process closure state can't be, by definition. HTTP sessions are not // tracked here: with tokens.json they are already identity-enforced, and many // share one pid, so pid-liveness would be meaningless for them. export const SESSIONS_DIR = path.join(ROOT, "sessions"); export type SessionBinding = { agentId: string; pid: number; boundAt: number; // How the bind was established: "tofu" (first claim, id verified not live), // "env" (AGENT_COORD_BOUND_AGENT), "token", "force", "same-pane" (live // marker types into this session's own tmux pane), "rename". via: string; tmuxPane?: string; }; export function sessionFile(agentId: string, pid: number, nonce: string): string { return path.join(SESSIONS_DIR, `${sanitize(agentId)}.${pid}.${nonce}.json`); } export async function listSessionFiles(): Promise { if (!existsSync(SESSIONS_DIR)) return []; const names = await fs.readdir(SESSIONS_DIR); return names.filter((n) => n.endsWith(".json")).map((n) => path.join(SESSIONS_DIR, n)); } // Per-agent token map for identity-bound bus auth (v0.7.0). Shape on disk: // { "alice": "tk_", "bob": "tk_" } // HTTP transport reverse-looks-up the bearer to bind the session to an // agentId, then enforces that bound id against every tool call's // from/agentId field. Absent → advisory mode (legacy behaviour, with a // startup warning). Should be mode 600; operator-managed. export const TOKENS_FILE = path.join(ROOT, "tokens.json"); // Declared write scopes for managed documents (v0.18.0, Phase 8 Task 4). // Opt-in and operator-managed: absent → nothing is owned and nothing warns. // ADVISORY. The bus does not mediate writes to docs/QUEUE.md & friends — // agents edit them with ordinary file tools, so there is no interception // point. This file lets an agent ASK who owns a document (list_scopes) and // lets `doctor` DETECT drift after the fact. Pre-emptive enforcement waits // for Phase 8 Task 5, when work state moves into the store. export const SCOPES_FILE = path.join(ROOT, "scopes.json"); // Work state as data (v0.18.0, Phase 8 Task 5). One file per project holding // the parsed QUEUE/DONE/board documents. DERIVED, not authoritative: the // markdown in the repo remains the source of truth, and deleting this // directory costs an `import_work`, never data (see src/work.ts). export const WORK_DIR = path.join(ROOT, "work"); export function workFile(project: string): string { return path.join(WORK_DIR, `${sanitize(project)}.json`); } export function ensureDirs(): void { for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR, WORK_DIR, SESSIONS_DIR]) { if (!existsSync(d)) mkdirSync(d, { recursive: true }); } for (const f of [ROOM_FILE, STATUS_FILE]) { if (!existsSync(f)) mkdirSync(path.dirname(f), { recursive: true }); } } // ⟨q-4b9cdeab⟩ RELEASE A — THE READER ACCEPTS BOTH FORMS AND WRITES NEITHER. // // `tokens.json` holds every seat's bearer in the clear, so one readable file is the whole fleet's // identity. The fix is that the file stores a HASH while the seat's config keeps the secret. Because // 13 live HTTP seats depend on this file's shape, the migration is two-form first (David's ruling, // 2026-09-23, docs/notes/Q_4B9CDEAB_HASHED_TOKENS_PLAN.md): this release READS both forms and writes // NEITHER. No migration command, no hashed file written, no cutover. `coord-token` keeps minting // plaintext. // // v1 (today): { "alice": "tk_" } // v2 (hashed): { "alice": { "alg": "sha256", "hash": "", "createdAt": "…", "previous": {…} } } // // The two are told apart by the VALUE TYPE, so an untouched file keeps working with no version field. // // ⭐ ONE LOOKUP PATH FOR BOTH FORMS: a v1 entry is HASHED AT LOAD, so the in-process map is always // hash → agentId and `agentForBearer` hashes whatever is presented. There is no second comparison to // drift, and no branch where a plaintext file authenticates differently from a hashed one. // // ⛔ AN ENTRY THIS BUILD CANNOT EVALUATE REFUSES THE WHOLE FILE, BY NAME. An unknown `alg` is never // ignored (which would silently drop a seat) and never treated as plaintext (which would compare a // bearer against a hash and lock that seat out while looking fine). Same for a missing or malformed // hash. The refusal names the AGENT and the ALG — never any part of a bearer, and never the hash. // // ⚠ `previous` (the rotation grace window) is NOT honoured in release A: it is written by nothing in // this release, so accepting it would be accepting a shape no tool here produces. A bearer that // matches only `previous` does not authenticate, and that is asserted rather than left implicit. // // Synchronous, deliberate: startup fails loudly on a malformed file rather than silently degrading // to advisory mode. Returns null if the file is absent (binding not configured). /** The hash algorithms this build can evaluate. An entry naming anything else refuses the file. */ export const TOKEN_HASH_ALGS = Object.freeze(["sha256"] as const); const HEX64 = /^[0-9a-f]{64}$/; /** * ⛔ sha256 OF THE EMPTY STRING. v1 refused an empty token STRUCTURALLY (`value.length === 0`); the * hashed form has no such shape, because this hex is as well-formed as any other. It is the exact * artefact of an ordinary shell slip during the hand migration this release exists for — * `printf '%s' "$TOK" | shasum -a 256` with `$TOK` unset or mistyped writes precisely this — and an * entry holding it authenticates an `Authorization: Bearer ` header with nothing after it. */ const EMPTY_BEARER_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; /** sha256 hex of a bearer. Unsalted on purpose: a bearer is 24 CSPRNG bytes, so there is no * dictionary to run, and a per-entry salt would force a linear scan of every entry on every * request. See §2 of the plan for the condition that would make that choice wrong. */ export function bearerHash(bearer: string): string { return createHash("sha256").update(bearer, "utf8").digest("hex"); } /** How many entries of each form the loaded map came from — what the census line reports. */ export type TokenMapCensus = { total: number; hashed: number; plaintext: number }; let tokenMapCensus: TokenMapCensus = { total: 0, hashed: 0, plaintext: 0 }; export function getTokenMapCensus(): TokenMapCensus { return { ...tokenMapCensus }; } function refuse(detail: string): never { throw new Error(`[agent-coord-mcp] ${TOKENS_FILE}: ${detail}`); } /** * Insert a hash → agentId claim, REFUSING a hash two agents both claim. * * ⛔ LAST-WINS WOULD RE-ATTRIBUTE A LIVE BEARER. Under v1 a collision meant two identical plaintext * strings, visible to anyone reading the file. With one side hashed it is invisible on inspection — * a property this release removed, so the reader has to put it back. The invariant this keeps is * `map.size === census.total`: every entry claims its own key or the file is refused. */ function claim(out: Map, hash: string, agentId: string): void { const held = out.get(hash); if (held !== undefined) { refuse( `agents "${held}" and "${agentId}" both resolve to the SAME token hash. One bearer cannot ` + `authenticate two identities: whichever entry loaded last would silently take the other's ` + `traffic, and with one side hashed the clash is invisible in the file. Give each agent its own token.`, ); } out.set(hash, agentId); } /** Read the file into a hash → agentId map, accepting v1 and v2 entries. NEVER writes. */ export function readTokenMapSync(): Map | null { if (!existsSync(TOKENS_FILE)) return null; const raw = readFileSync(TOKENS_FILE, "utf8"); let parsed: unknown; try { parsed = JSON.parse(raw); } catch (e) { throw new Error( `[agent-coord-mcp] ${TOKENS_FILE} is not valid JSON: ${(e as Error).message}. ` + `Fix or remove the file (the bus refuses to start with a broken token map).`, ); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error( `[agent-coord-mcp] ${TOKENS_FILE} must be a JSON object mapping agentId → token or hashed entry.`, ); } const out = new Map(); const census: TokenMapCensus = { total: 0, hashed: 0, plaintext: 0 }; for (const [agentId, value] of Object.entries(parsed as Record)) { census.total += 1; if (typeof value === "string") { // v1: the secret itself is on disk. Hash it here so the map has ONE shape. if (value.length === 0) refuse(`agent "${agentId}" has a non-string/empty token.`); claim(out, bearerHash(value), agentId); census.plaintext += 1; continue; } if (!value || typeof value !== "object" || Array.isArray(value)) { refuse(`agent "${agentId}" has a non-string/empty token.`); } const entry = value as { alg?: unknown; hash?: unknown }; const alg = entry.alg; if (typeof alg !== "string" || !(TOKEN_HASH_ALGS as readonly string[]).includes(alg)) { refuse( `agent "${agentId}" uses hash alg ${JSON.stringify(alg ?? null)}, which this build cannot evaluate ` + `(known: ${TOKEN_HASH_ALGS.join(", ")}). Refusing the whole file: ignoring the entry would drop that ` + `seat silently, and treating it as a plaintext token would compare a bearer against a hash and lock ` + `the seat out while every file on disk looked correct.`, ); } if (typeof entry.hash !== "string" || !HEX64.test(entry.hash)) { refuse(`agent "${agentId}" has a ${alg} entry whose hash is not 64 lowercase hex characters.`); } if (entry.hash === EMPTY_BEARER_HASH) { refuse( `agent "${agentId}" has a ${alg} entry whose hash is sha256 of the EMPTY STRING. An empty ` + `Authorization bearer would authenticate as that agent. The plaintext form refused an empty ` + `token by its shape; this hex is well-formed, so it is refused by name instead. It is what ` + `hashing an unset or mistyped shell variable produces — re-hash the real token.`, ); } claim(out, entry.hash, agentId); census.hashed += 1; } tokenMapCensus = census; return out; } /** The agent a presented bearer belongs to, or undefined. The ONE place a bearer is compared. */ export function agentForBearer(bearer: string): string | undefined { // ⚠ NO SEPARATE EMPTY-BEARER GUARD HERE, DELIBERATELY. An empty `Authorization: Bearer ` slices to // "" and hashes to sha256(""), and the reader REFUSES any entry carrying that hash — so the map can // never hold the only key an empty bearer could match. A guard here would therefore be unkillable // by any test: I added one, the mutation matrix showed removing it changed nothing, and a conjunct // no test can fire belongs in a comment rather than in the code. The enforcement point is the // reader's refusal, and it is tested. // // ⛔ WHAT THAT DELETION COSTS, NAMED BECAUSE NOTHING ASSERTS IT: the guarantee is no longer local to // this function. It holds only while `readTokenMapSync` is the map's ONLY PRODUCER — true today at // all three assignments to `tokenMapCache` (the lazy first load, `reloadTokenMapSync`, and the // refresh, which reads through `readTokenMapSync`). TWO changes void it, and BOTH are silent because // the guard that would have caught them was deleted for being unfireable: // 1. LOOSENING THE REFUSAL — if an entry carrying sha256("") stops being refused; // 2. ⛔ A SECOND PRODUCER — anything that populates or mutates the map outside `readTokenMapSync`. // This is the one release B actually SCHEDULES: `coord-token migrate` writes hashed entries, and // a migrate path that seeds or patches the in-process map rather than re-reading the file // reintroduces the empty-bearer key with nothing between it and the auth path. // Either one is where the guard goes back — with a test that can fail. If you are here writing // `coord-token migrate`, this paragraph is addressed to you. return getTokenMap()?.get(bearerHash(bearer)); } /** One line an operator can act on. Says nothing about any bearer, prefix or hash. */ export function tokenMapCensusLine(): string | null { const map = getTokenMap(); if (!map) return null; const c = getTokenMapCensus(); const tail = c.total === 0 ? " — no entries: every bearer will be refused" : c.plaintext > 0 ? ` — ${c.plaintext} still plaintext, run coord-token migrate` : " — all hashed"; return `[agent-coord-mcp] token map: ${c.total} entries (${c.hashed} hashed, ${c.plaintext} plaintext)${tail}`; } // In-process cache of the token map, shared by the HTTP identity-binding // layer (server.ts) and rotateAgentToken below. A module-level cache (rather // than each caller re-reading the file) is what lets a rename refresh the // live server's view without an operator SIGHUP. let tokenMapCache: Map | null = null; let tokenMapInitialized = false; // Current in-process token map (bearer -> agentId), or null if unbound (no // tokens.json). Lazily loads from disk on first call. export function getTokenMap(): Map | null { if (!tokenMapInitialized) { tokenMapCache = readTokenMapSync(); tokenMapInitialized = true; // Stamp the file we just read, or the first change check would report a map that never // changed as newly "appeared" and re-read a file nobody touched. tokenMapStamp = stampOf(); tokenMapCheckedAt = Date.now(); } return tokenMapCache; } // Re-read tokens.json from disk and replace the in-process cache. Throws on // a malformed file — callers decide whether that's fatal (startup) or // recoverable (SIGHUP, post-rotate refresh). export function reloadTokenMapSync(): Map | null { tokenMapCache = readTokenMapSync(); tokenMapInitialized = true; // Keep the change-detection stamp in step, or the next stat would read the same file again. tokenMapStamp = stampOf(); tokenMapCheckedAt = Date.now(); return tokenMapCache; } // ⟨q-54adf191⟩ RELOAD WHEN THE FILE CHANGES, AND SAY WHAT HAPPENED. // // The daemon read tokens.json once at startup and had no watcher, so a token minted while it ran // was refused with a 401 that looks exactly like a broken daemon — every file on disk correct. The // operator had to remember SIGHUP. This weakens nothing: the file is the authority either way, and // a reload cannot grant a bearer the file does not contain. // // THE TRIGGER IS A STAMP CHECK ON THE AUTH PATH, NOT A WATCHER AND NOT A TIMER. // · a watcher (fs.watch) is per-platform and silently unreliable on network and virtualised // filesystems — exactly where a shared bus dir often lives — and it would hold the event loop // of an otherwise idle daemon; // · a poll timer wakes a process that may serve nothing for hours, and still misses a change // that lands between ticks; // · a stat on a request the daemon is already serving costs one syscall, is rate-limited, and // reads the file ONLY when the stamp moved. An unknown bearer forces one extra check, so a // newly minted token authenticates on its first call rather than on the next tick. // SIGHUP stays as the explicit path and still forces a read. const TOKEN_RELOAD_MIN_MS = (() => { const n = Number(process.env.AGENT_COORD_TOKEN_RELOAD_MS); return Number.isFinite(n) && n >= 0 ? n : 1000; })(); /** What a refresh did. `changed` means the in-process map was REPLACED. */ export type TokenMapRefresh = { changed: boolean; reason: "unbound" | "rate-limited" | "unchanged" | "changed" | "appeared" | "malformed" | "vanished"; count: number | null; /** Present only when the reload was REFUSED; the previous map still serves. */ refused?: string; }; // The file identity we compare against: mtime, size AND inode. // // ⛔ THE INODE IS LOAD-BEARING, NOT BELT-AND-BRACES. `coord-token` rotates by writing a temp file and // renaming it into place, so the replacement is a DIFFERENT INODE that can carry the SAME mtime and // the SAME size (a rotated token is the same length as the one it replaces). Without the inode this // stamp reads "unchanged" and the daemon keeps serving the REVOKED bearer until someone remembers // SIGHUP — the exact defect ⟨q-54adf191⟩ exists to end. Pinned by "a rotation that keeps mtime AND // size" below, which goes red the moment the inode leaves this line. // // ⚠ STATED LIMIT, and the one case this stamp CANNOT see: a rewrite IN PLACE, at equal length, with // mtime forced back (`utimes`). Same inode, same size, same mtime is indistinguishable from no write // at all, so a bearer revoked that way keeps working until SIGHUP. Measured, and asserted below as a // known limit rather than left to be discovered. // // ⚠⚠ WHY WE ARE SAFE TODAY IS ANOTHER TOOL'S IMPLEMENTATION DETAIL, NOT A PROPERTY OF THIS ONE: // `coord-token` never writes this file in place — every write is temp+rename, which moves the inode. // The day anything else writes `tokens.json` in place (an editor with a truncating save, a config // manager, a hand-rolled script), this comment is the only warning that the reload will not see it. // A content hash would close it, at the cost of reading the whole file on every check instead of one // stat; that trade was not taken here and is the thing to revisit if that day comes. let tokenMapStamp: string | null = null; let tokenMapCheckedAt = 0; function stampOf(): string | null { try { const st = statSync(TOKENS_FILE); return `${st.mtimeMs}:${st.size}:${st.ino}`; } catch { return null; } } /** The state a refresh decides against: what is loaded, the file identity it came from, when it was last checked. */ export type TokenMapState = { map: Map | null; stamp: string | null; checkedAt: number }; /** The filesystem, as the decision sees it. Injected so the rules can be exercised without a disk. */ export type TokenMapIo = { stamp: () => string | null; read: () => Map | null }; /** * THE WHOLE RULE, IN ONE PURE FUNCTION. The live path and the capability probe both call this, so * there is no second implementation of "did it change, and may the new map replace the old one". */ export function decideTokenMapRefresh( state: TokenMapState, io: TokenMapIo, opts: { force?: boolean; now?: number } = {}, ): { next: TokenMapState; result: TokenMapRefresh } { const now = opts.now ?? Date.now(); const keep = (result: TokenMapRefresh, checkedAt = state.checkedAt) => ({ next: { ...state, checkedAt }, result }); // A daemon with no tokens.json is in advisory mode: there is no map to keep fresh, and a file // APPEARING must not silently promote it into bound mode mid-flight. if (state.map === null) return keep({ changed: false, reason: "unbound", count: null }); if (!opts.force && now - state.checkedAt < TOKEN_RELOAD_MIN_MS) { return keep({ changed: false, reason: "rate-limited", count: state.map.size }); } const size = state.map.size; const stamp = io.stamp(); if (stamp === null) { // ⛔ A VANISHED FILE IS A REFUSAL, NEVER A DOWNGRADE. Dropping to advisory mode because the file // is missing would turn `rm tokens.json` into "every bearer is now accepted". return keep({ changed: false, reason: "vanished", count: size, refused: `${TOKENS_FILE} is gone` }, now); } if (stamp === state.stamp) return keep({ changed: false, reason: "unchanged", count: size }, now); try { const next = io.read(); if (next === null) { return keep({ changed: false, reason: "vanished", count: size, refused: `${TOKENS_FILE} is gone` }, now); } return { next: { map: next, stamp, checkedAt: now }, result: { changed: true, reason: state.stamp === null ? "appeared" : "changed", count: next.size }, }; } catch (e) { // The stamp is deliberately NOT advanced: a half-written file that is fixed in place, with no // further change to mtime or size, must still be picked up on the next check. return keep({ changed: false, reason: "malformed", count: size, refused: (e as Error).message }, now); } } export function refreshTokenMapIfChanged(opts: { force?: boolean; now?: number } = {}): TokenMapRefresh { if (!tokenMapInitialized) getTokenMap(); const { next, result } = decideTokenMapRefresh( { map: tokenMapCache, stamp: tokenMapStamp, checkedAt: tokenMapCheckedAt }, { stamp: stampOf, read: readTokenMapSync }, opts, ); tokenMapCache = next.map; tokenMapStamp = next.stamp; tokenMapCheckedAt = next.checkedAt; return result; } // Atomically rotate the token entry for an agent rename (used by // rename_agent so the same bearer continues to authenticate the renamed // identity). No-op if the file is absent or the old id isn't in the map. export async function rotateAgentToken(oldAgentId: string, newAgentId: string): Promise { if (!existsSync(TOKENS_FILE)) return; let rotated = false; await updateJson>(TOKENS_FILE, {}, (current) => { if (current[oldAgentId] !== undefined) { current[newAgentId] = current[oldAgentId]; delete current[oldAgentId]; rotated = true; } return current; }); if (rotated) { // Refresh the in-process cache immediately: without this, the renamed // agent's bearer keeps resolving to the OLD id in this process until an // operator sends SIGHUP, silently misattributing its calls. try { reloadTokenMapSync(); } catch (e) { console.error( `[agent-coord-mcp] rename_agent: token map reload after rotate failed: ${(e as Error).message} ` + `(keeping previous in-process map; send SIGHUP to retry)`, ); } } } export type RoomEntry = { topic?: string; motd?: string; createdAt: number; createdBy: string; members: string[]; }; export type RoomRegistry = Record; // Normalize a channel name: strip leading '#', lowercase, restrict to a safe // charset, empty → the default channel. Display layers re-add the '#'. export function normalizeRoom(name?: string): string { if (!name) return DEFAULT_ROOM; const n = name.trim().replace(/^#+/, "").toLowerCase().replace(/[^a-z0-9._-]/g, ""); return n || DEFAULT_ROOM; } // Resolve a channel to its physical JSONL file. `general` maps to the legacy // room.jsonl for backward compatibility; everything else to rooms/.jsonl. export function roomFile(chan: string): string { const c = normalizeRoom(chan); return c === DEFAULT_ROOM ? ROOM_FILE : path.join(ROOMS_DIR, `${sanitize(c)}.jsonl`); } function blankRoom(createdBy: string): RoomEntry { return { createdAt: Date.now(), createdBy, members: [] }; } // Read the channel registry, always surfacing `general` even before it has been // explicitly persisted (it exists implicitly via room.jsonl). export async function getRooms(): Promise { const reg = await readJson(ROOMS_FILE, {}); if (!reg[DEFAULT_ROOM]) reg[DEFAULT_ROOM] = { createdAt: 0, createdBy: "system", members: [] }; return reg; } export async function ensureRoom(chan: string, createdBy: string): Promise { const c = normalizeRoom(chan); await updateJson(ROOMS_FILE, {}, (cur) => { if (!cur[c]) cur[c] = blankRoom(createdBy); return cur; }); } export async function setRoomMeta(chan: string, meta: { topic?: string; motd?: string }, by = "system"): Promise { const c = normalizeRoom(chan); await updateJson(ROOMS_FILE, {}, (cur) => { const e = (cur[c] ??= blankRoom(by)); if (meta.topic !== undefined) e.topic = meta.topic; if (meta.motd !== undefined) e.motd = meta.motd; return cur; }); } export async function addMember(chan: string, agentId: string): Promise { const c = normalizeRoom(chan); await updateJson(ROOMS_FILE, {}, (cur) => { const e = (cur[c] ??= blankRoom(agentId)); if (!e.members.includes(agentId)) e.members.push(agentId); return cur; }); } export async function removeMember(chan: string, agentId: string): Promise { const c = normalizeRoom(chan); await updateJson(ROOMS_FILE, {}, (cur) => { if (cur[c]) cur[c].members = cur[c].members.filter((m) => m !== agentId); return cur; }); } // Channels this agent has joined (always includes the default channel). export async function memberRooms(agentId: string): Promise { const reg = await getRooms(); const out = new Set([DEFAULT_ROOM]); for (const [chan, e] of Object.entries(reg)) { if (e.members?.includes(agentId)) out.add(chan); } return [...out]; } export function transportFile(agentId: string): string { return path.join(TRANSPORT_DIR, `${sanitize(agentId)}.json`); } export function pidFile(agentId: string, kind: string): string { return path.join(PID_DIR, `${kind}-${sanitize(agentId)}.pid`); } export function logFile(agentId: string, kind: string): string { return path.join(LOG_DIR, `${kind}-${sanitize(agentId)}.log`); } export async function listTransportFiles(): Promise { if (!existsSync(TRANSPORT_DIR)) return []; const names = await fs.readdir(TRANSPORT_DIR); return names.filter((n) => n.endsWith(".json")); } async function ensureFile(file: string): Promise { if (!existsSync(file)) { await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, "", "utf8"); } } async function withLock(file: string, fn: () => Promise): Promise { await ensureFile(file); const release = await lockfile.lock(file, { retries: { retries: 10, minTimeout: 20, maxTimeout: 200 }, stale: 5000, }); try { return await fn(); } finally { await release(); } } export async function appendJsonl(file: string, entry: unknown): Promise { await withLock(file, async () => { const line = JSON.stringify(entry) + "\n"; await fs.appendFile(file, line, "utf8"); }); } export async function readJsonl(file: string): Promise { if (!existsSync(file)) return []; const raw = await fs.readFile(file, "utf8"); const out: T[] = []; for (const line of raw.split("\n")) { if (!line.trim()) continue; try { out.push(JSON.parse(line) as T); } catch { // skip malformed line } } return out; } export async function readJson(file: string, fallback: T): Promise { if (!existsSync(file)) return fallback; try { const raw = await fs.readFile(file, "utf8"); if (!raw.trim()) return fallback; return JSON.parse(raw) as T; } catch { return fallback; } } // Like readJson, but a file that EXISTS and cannot be parsed THROWS instead of // silently returning the fallback. For callers whose decision flips on // "verified absent" vs "cannot verify": the first-claim binding guard must // refuse when evidence is unreadable, because a guard that treats unreadable // evidence as absent is disabled by the very corruption it should be // reporting (the absence-is-not-exemption class, #36). export async function readJsonStrict(file: string, fallback: T): Promise { if (!existsSync(file)) return fallback; const raw = await fs.readFile(file, "utf8"); if (!raw.trim()) return fallback; return JSON.parse(raw) as T; } export async function writeJson(file: string, data: unknown): Promise { await withLock(file, async () => { await fs.writeFile(file, JSON.stringify(data, null, 2), "utf8"); }); } async function readJsonNoLock(file: string, fallback: T): Promise { if (!existsSync(file)) return fallback; try { const raw = await fs.readFile(file, "utf8"); if (!raw.trim()) return fallback; return JSON.parse(raw) as T; } catch { return fallback; } } export async function updateJson(file: string, fallback: T, mutate: (current: T) => T | Promise): Promise { return withLock(file, async () => { const current = await readJsonNoLock(file, fallback); const next = await mutate(current); await fs.writeFile(file, JSON.stringify(next, null, 2), "utf8"); return next; }); } export function inboxFile(agentId: string): string { return path.join(INBOX_DIR, `${sanitize(agentId)}.jsonl`); } export function cursorFile(agentId: string): string { return path.join(CURSOR_DIR, `${sanitize(agentId)}.json`); } // Per-agent delivery-receipt log. The agent's own pusher appends here after it // types a message into the pane; senders poll it to confirm delivery. export function receiptFile(agentId: string): string { return path.join(RECEIPTS_DIR, `${sanitize(agentId)}.jsonl`); } export async function listReceiptFiles(): Promise { if (!existsSync(RECEIPTS_DIR)) return []; const names = await fs.readdir(RECEIPTS_DIR); return names.filter((n) => n.endsWith(".jsonl")).map((n) => path.join(RECEIPTS_DIR, n)); } function sanitize(id: string): string { return id.replace(/[^a-zA-Z0-9._-]/g, "_"); } export async function rewriteJsonl( file: string, filter: (entry: T) => boolean ): Promise<{ kept: number; removed: number }> { if (!existsSync(file)) return { kept: 0, removed: 0 }; return withLock(file, async () => { const raw = await fs.readFile(file, "utf8"); let kept = 0; let removed = 0; const out: string[] = []; for (const line of raw.split("\n")) { if (!line.trim()) continue; try { const entry = JSON.parse(line) as T; if (filter(entry)) { out.push(line); kept++; } else { removed++; } } catch { removed++; } } await fs.writeFile(file, out.length ? out.join("\n") + "\n" : "", "utf8"); return { kept, removed }; }); } // Archive destinations for each live JSONL stream. export function archiveRoomFile(chan: string): string { return path.join(ARCHIVE_ROOMS_DIR, `${sanitize(normalizeRoom(chan))}.jsonl`); } export function archiveInboxFile(agentId: string): string { return path.join(ARCHIVE_INBOX_DIR, `${sanitize(agentId)}.jsonl`); } // Like rewriteJsonl, but entries failing the filter are appended to // archiveFile instead of discarded. The archive append happens under the // source-file lock and BEFORE the rewrite, so a crash mid-operation can at // worst duplicate entries into the archive — never lose them. Malformed // lines are archived verbatim rather than parsed. export async function archiveJsonl( file: string, archiveFile: string, filter: (entry: T) => boolean ): Promise<{ kept: number; removed: number; archived: number }> { if (!existsSync(file)) return { kept: 0, removed: 0, archived: 0 }; return withLock(file, async () => { const raw = await fs.readFile(file, "utf8"); let kept = 0; let removed = 0; const keep: string[] = []; const archive: string[] = []; for (const line of raw.split("\n")) { if (!line.trim()) continue; try { const entry = JSON.parse(line) as T; if (filter(entry)) { keep.push(line); kept++; } else { archive.push(line); removed++; } } catch { archive.push(line); removed++; } } if (archive.length) { await fs.mkdir(path.dirname(archiveFile), { recursive: true }); await fs.appendFile(archiveFile, archive.join("\n") + "\n", "utf8"); } await fs.writeFile(file, keep.length ? keep.join("\n") + "\n" : "", "utf8"); return { kept, removed, archived: archive.length }; }); } export async function deleteFile(file: string): Promise { if (!existsSync(file)) return false; await fs.unlink(file); return true; } export async function listInboxFiles(): Promise { if (!existsSync(INBOX_DIR)) return []; const names = await fs.readdir(INBOX_DIR); return names.filter((n) => n.endsWith(".jsonl")); } export async function listCursorFiles(): Promise { if (!existsSync(CURSOR_DIR)) return []; const names = await fs.readdir(CURSOR_DIR); return names.filter((n) => n.endsWith(".json")); } export async function fileSize(file: string): Promise { if (!existsSync(file)) return 0; const st = await fs.stat(file); return st.size; } // ---------- reversible history cache (CCR) ---------- export type HistoryEntry = { hash: string; room: string; forAgent: string; // scope key — only this agent may retrieve (see HISTORY_DIR note) createdTs: number; messages: T[]; }; function historyFile(hash: string): string { return path.join(HISTORY_DIR, `${sanitize(hash)}.json`); } // Content+scope address: same backlog read by two agents (or twice by one) // yields distinct entries, and the hash can't be guessed for a (room, agent) // pair the caller never read. 12 hex chars ≈ 48 bits — collision-safe at this // volume, short enough to sit inline in the digest marker. function historyHash(room: string, forAgent: string, messages: { ts: number }[]): string { const first = messages[0]?.ts ?? 0; const last = messages[messages.length - 1]?.ts ?? 0; const sig = `${room}${forAgent}${first}${last}${messages.length}`; return createHash("sha1").update(sig).digest("hex").slice(0, 12); } // Best-effort sweep of expired entries. Cheap (one readdir + stat per file) and // only the data is a disposable cache, so we swallow all errors. export async function pruneHistory(now = Date.now()): Promise { if (!existsSync(HISTORY_DIR)) return; let names: string[]; try { names = await fs.readdir(HISTORY_DIR); } catch { return; } await Promise.all( names .filter((n) => n.endsWith(".json")) .map(async (n) => { const f = path.join(HISTORY_DIR, n); try { const e = await readJsonNoLock(f, null); if (!e || now - e.createdTs > HISTORY_TTL_MS) await fs.unlink(f).catch(() => {}); } catch { /* leave it; next sweep retries */ } }), ); } // Stash a backlog slice and return its hash. Caller embeds the hash in the // digest marker it returns to the agent. export async function stashHistory( room: string, forAgent: string, messages: T[], ): Promise { const hash = historyHash(room, forAgent, messages); const entry: HistoryEntry = { hash, room, forAgent, createdTs: Date.now(), messages }; await writeJson(historyFile(hash), entry); void pruneHistory(); return hash; } export type RetrieveHistoryResult = | { ok: true; room: string; total: number; messages: T[] } | { ok: false; reason: "not_found" | "expired" | "forbidden" }; // Expand a stashed backlog. Enforces the (forAgent) scope and TTL. `query`, if // given, returns only entries whose serialized form contains the substring // (case-insensitive) — the lossless analogue of headroom's BM25 search-within. export async function retrieveHistory( hash: string, forAgent: string, query?: string, ): Promise> { const f = historyFile(hash); const entry = await readJson | null>(f, null); if (!entry) return { ok: false, reason: "not_found" }; if (Date.now() - entry.createdTs > HISTORY_TTL_MS) { await deleteFile(f).catch(() => {}); return { ok: false, reason: "expired" }; } if (entry.forAgent !== forAgent) return { ok: false, reason: "forbidden" }; let messages = entry.messages; if (query && query.trim()) { const q = query.toLowerCase(); messages = messages.filter((m) => JSON.stringify(m).toLowerCase().includes(q)); } return { ok: true, room: entry.room, total: entry.messages.length, messages }; }