import type { Citation, DecisionPayload, MessageRecord, MessageRecordType, SummaryPayload, SummaryRecordType, VerdictPayload, } from "@davidbalzan/groundwork-seam/protocol"; import { randomUUID } from "node:crypto"; import { existsSync, openSync, watch } from "node:fs"; import { promises as fsp } from "node:fs"; import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; 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, type RoomRegistry, } from "../store.js"; export type AgentEntry = { agentId: string; project?: string; // DISPLAY NAME — free to change. Stays a plain string on disk so existing // agents.json files, coord-chat's `/whois`, and every v1 reader keep working // untouched. role?: string; // FROZEN IDENTITY (Phase 8 Task 4). Optional and additive: when absent the id // is derived from `role` at read time (resolveRole), which is what every // pre-Task-4 entry does. Once DECLARED it is immutable — register rejects an // attempt to change it — so a role can be renamed (curator → liaison → aide) // without every id, skill and script that keys off it having to move. roleId?: string; registeredAt: number; lastHeartbeat: number; capabilities?: string[]; // PROSE-ONLY EXEMPTION from the typed-record rule (Phase 5.1 Task 12.8). // // Declared PER AGENT at register/join, never as a global env var: a global // switch turns the rule off fleet-wide in one line and nobody notices, while // a per-agent declaration is a statement ABOUT THAT AGENT and shows up in // `list_agents` next to it. It exists so a model that cannot reliably pick a // `record.type` is not locked off the bus — opt-in, never the default. // // THE ASYMMETRY: granted to the SENDER, paid by every READER. An untyped // message cannot be slimmed by hooks/tier.mjs, so a prose-only agent spends // OTHER agents' context on every multi-line send. That is why it is stamped, // counted, and reviewed rather than self-service. proseOnly?: { since: number; reason?: string }; /* * ⛔⛆ THE ANSWERING PROCESS, PUBLISHED — `⟨q-cec42e20⟩`. ADDITIVE AND OPTIONAL: * absent on every pre-existing entry, and no reader that ignores unknown keys changes * behaviour. * * Two seats ran the same verb, got `153` and `138`, and both servers honestly reported * `versionLabel 0.26.20`. Nothing detected it — each seat had to VOLUNTEER it. * * ⭐ WHY THESE TWO FIELDS AND NOT A VERSION: a server loads its code once, at spawn, * so START TIME against the installed build is the axis that separates two processes * running different code. A label cannot: it AGREED while the behaviour differed, and * `serverBuildMtime` cannot either — it is stamped at ATTACH and read IDENTICAL across * all six live transports while the spread was live. * * ⚠ AND IT IS THE SERVER, NEVER THE PUSHER. `transports/.json` already carries a * pid and it is the pusher's — a different process, on different code, which is the * distinction this fleet paid for twice on 2026-09-12. */ serverPid?: number; serverStartedAt?: number; serverModule?: string; }; /** * THE MARKER TYPE NOW LIVES IN THE SEAM, and is re-exported here so the dozens * of `from "./shared.js"` imports keep working. * * It was defined here, and the transport extraction gave it a second definition * in `transports/types.ts` — two structurally-similar types that a consumer * could satisfy while missing a field, which is the duplication this task exists * to remove rather than double. One definition, one home: the transport layer, * which is what the field describes. The nine fields and their comments moved * with it verbatim, including `target`'s dual-write rule. */ export type { TransportMarker } from "../transports/types.js"; /** * How to REPORT a transport's room capability. * * Three states, not two. `undefined` is a marker written before the field * existed: it cannot answer, and "unknown" is the only truthful report. The * defect being fixed is precisely a two-state reading of a three-state world — * `attached: true` covered "full", "DM-only", and "cannot say" alike. */ export const roomFeedOf = (m: { rooms?: boolean } | null | undefined): "on" | "off" | "unknown" => !m || m.rooms === undefined ? "unknown" : m.rooms ? "on" : "off"; export type AgentRegistry = Record; export type { Citation, MessageRecordType, DecisionPayload, VerdictPayload, SummaryPayload, SummaryRecordType, MessageRecord, }; export type Message = { id: string; ts: number; from: string; to?: string; room?: string; text: string; // System notices (join/part/topic/nick) — rendered distinctly by clients. system?: boolean; // Control commands (`/clear`, `/compact`, `/reload-skills`) addressed at the agent's CLI, not // its operator. The tmux pushers inject these RAW (no banner/prefix) so the // TUI runs them as real slash commands; every other consumer ignores them. control?: boolean; // Server-generated push-now override (e.g. the post-/clear identity // reminder). Only server code can set this — send_message constructs the // Message from fixed fields, so peers cannot smuggle it in. urgent?: boolean; // Semantic weight of a room post (absent = chatter). Decisions get a longer // prune retention (decisionDays), survive live compaction while fresh, and // are surfaced verbatim in overflow digests. // // This field is on disk in every JSONL file, so it is the half of the old // name collision that could not move. The pushers' *synthetic* channel tag // ("DM" / "room #general"), read by injectLine and classifyTier, was also // called `kind` until Phase 8 Task 3 renamed it to `tag` — it is // process-local and never persisted, so renaming it cost no migration. // The two no longer collide; see hooks/tmux-pusher.mjs's collectSource. kind?: "decision" | "status" | "chatter"; // Typed protocol record (Phase 8). Optional and additive; see MessageRecord. record?: MessageRecord; // Additive reply link (optional). A DM that answers a DAVID_DECISION // (or any other message) carries the parent message id so consumers can // mark the packet answered. Absent on every v1/v2 message — those stay valid. inReplyTo?: string; // PROVENANCE (q-314e0187): the pid of the process that wrote this entry, // and its tmux pane when known. `from` names an IDENTITY, not a PROCESS — // two live sessions bound to the same agent id write byte-identical `from` // values, and that is exactly the condition that let worker-3 truthfully // deny sending a message that exists under its name on 2026-08-31: both // sessions' accounts were true, and nothing on the message could say which // wrote it. Cross-reference against sessions/*.json (SessionBinding) or // `ps -p ` to attribute a disputed entry. Absent on every message // written before this field existed — that is UNKNOWN, not "same process". provenance?: { pid: number; tmuxPane?: string }; }; // THE retention predicate — one definition, three call sites (prune, // live compaction, overflow-digest quoting). It lived as three copies of // `e.kind === "decision"`, and they drifted: a v2 agent doing exactly what // Phase 8 asks — sending `record:{type:"decision"}` and omitting the legacy // `kind` — had its decisions compacted at chatter rate and dropped from // digests. A protocol whose correct usage loses data. // // MONOTONE, like the tier floor: `||` can only ever GRANT the long retention, // never remove it. So there is no cutover, no migration, and no existing file // whose behaviour changes — a legacy `kind:"decision"` keeps exactly the // retention it had. Two sources that can each override each other is the // disagreement this phase deletes; a source that can only raise is not that. // // Deliberately structural about `record`: it takes anything with the two // fields, so status entries and archive rows can be passed without a cast. export { isDecision } from "@davidbalzan/groundwork-seam/protocol"; export type StatusEntry = { id: string; ts: number; agentId: string; status: string; detail?: string; }; export type Cursor = { inboxOffset?: number; roomOffset?: number; // the default channel (`general` → room.jsonl) statusOffset?: number; // Per-channel read offsets for every non-default channel. roomOffsets?: Record; }; export type Source = "inbox" | "room" | "status"; // q-40449919 (regression from 0.26.8): a `cursors/*.json` filename has TWO // shapes since the push/read cursor split (hooks/push-cursor.mjs) — // `.json` (read) and `.push.json` (push) — and every reader of // `listCursorFiles()` was stripping only the outer `.json`, so a push // cursor for `worker-3` parsed as an agent literally named `worker-3.push`. // That id is unregistered by construction (false `orphan-inboxes-cursors`), // and looking up ITS inbox resolves to a file that never existed, so its // real, correct offset compares as past-EOF against an empty file — the // bus's only error-level finding, driving `healthy:false` for a healthy // fleet. ONE parser, used everywhere a cursor filename becomes an agent id, // so the assumption cannot re-drift into some other reader the way the // filename shape itself drifted out from under the callers that predate it. export function agentIdFromCursorFilename(fname: string): { id: string; kind: "read" | "push" } { const push = /^(.*)\.push\.json$/.exec(fname); if (push) return { id: push[1], kind: "push" }; return { id: fname.replace(/\.json$/, ""), kind: "read" }; } // Resolve the physical file for a (source, agent, channel) tuple. export function sourceFile(source: Source, agentId: string, room?: string): string { if (source === "inbox") return inboxFile(agentId); if (source === "status") return STATUS_FILE; return roomFile(room ?? DEFAULT_ROOM); } // Read/write the cursor offset for a (source, channel). `general` keeps using // the flat roomOffset key (hook compat); other channels use roomOffsets[chan]. export function getOffset(cursor: Cursor, source: Source, room?: string): number { if (source === "inbox") return cursor.inboxOffset ?? 0; if (source === "status") return cursor.statusOffset ?? 0; const chan = normalizeRoom(room); return chan === DEFAULT_ROOM ? cursor.roomOffset ?? 0 : cursor.roomOffsets?.[chan] ?? 0; } export function setOffset(cursor: Cursor, source: Source, room: string | undefined, n: number): void { if (source === "inbox") { cursor.inboxOffset = n; return; } if (source === "status") { cursor.statusOffset = n; return; } const chan = normalizeRoom(room); if (chan === DEFAULT_ROOM) cursor.roomOffset = n; else (cursor.roomOffsets ??= {})[chan] = n; } export function sysMsg(from: string, room: string, text: string): Message { return { id: randomUUID(), ts: Date.now(), from, room, text, system: true }; } export const STALE_MS = 5 * 60 * 1000; export const EVICT_MS = 24 * 60 * 60 * 1000; export const MAX_WAIT_MS = 60_000; // ---------- helpers ---------- export async function moveFile(from: string, to: string): Promise { if (!existsSync(from)) return false; await fsp.mkdir(path.dirname(to), { recursive: true }); try { await fsp.rename(from, to); } catch { // Cross-device or other rename failure — fall back to copy + unlink. const data = await fsp.readFile(from); await fsp.writeFile(to, data); await fsp.unlink(from); } return true; }