/** * GlobalMailbox — project-level inter-agent mailbox with cross-session support. * * Stores messages at `~/.wrongstack/projects//_mailbox.jsonl` so every * client and agent working on the same canonical project shares one inbox, * including agents in different processes, sessions, branches, and linked Git * worktrees. * * Features: * - Agent registration + heartbeat (agents go stale after 60s without heartbeat) * - Per-recipient read receipts (readBy[agentId] = ISO8601) * - Atomic file-locking for concurrent multi-process writes * - Unread count for new-mail notifications * - Online agent list * * @module GlobalMailbox */ import type { HqPublisher } from '../hq/publisher.js'; import type { EventBus } from '../kernel/events.js'; import type { MailboxEventEmitter } from './mailbox-events.js'; import type { AgentHeartbeatInput, AgentRegistrationInput, AutoCompactOptions, AutoCompactResult, ClientHeartbeatInput, ClientRegistrationInput, ClientStatus, Mailbox, MailboxAckBatchInput, MailboxAckInput, MailboxAgentStatus, MailboxMessage, MailboxQuery, MailboxSendInput, PurgeOptions, PurgeResult } from './mailbox-types.js'; type HqPublisherRef = HqPublisher | (() => HqPublisher | undefined); /** * Derive the project-level mailbox directory path. * * Delegates to the CANONICAL `projectSlug()` from wstack-paths so every * surface (CLI, TUI, WebUI, mailbox tool, loop checker) lands in the exact * same `~/.wrongstack/projects//` directory. `projectSlug()` also folds * linked Git worktrees into their shared common project identity, preventing * branch/worktree isolation from accidentally becoming mailbox isolation. * * @param projectRoot — absolute path to the project root * @param globalRoot — `~/.wrongstack` (or custom global root) */ export declare function resolveProjectDir(projectRoot: string, globalRoot: string): string; export declare function getSharedMailbox(projectDir: string, events?: EventBus, hqPublisher?: HqPublisherRef, opts?: { forceNew?: boolean; }): GlobalMailbox; /** Clear the singleton registry — used by tests to avoid cross-test leakage. */ export declare function _clearMailboxSingletons(): void; export declare class GlobalMailbox implements Mailbox { /** Path to the JSONL message file. */ readonly messagePath: string; /** Path to the JSON agent registry file. */ readonly registryPath: string; /** Path to the JSON client registry file. */ readonly clientRegistryPath: string; /** Optional event bus for emitting agent registration/heartbeat events. */ private readonly _events?; /** Optional HQ publisher for cross-project command-center telemetry. */ private readonly _hqPublisher?; /** * Optional SSE event emitter for real-time push to HTTP bridge clients. * When present, `send`/`ack`/`softDelete`/`restore` emit events that * connected SSE subscribers receive instantly. */ readonly eventEmitter?: MailboxEventEmitter | undefined; /** * Local cache of the agent registry to avoid re-reading on every call. * Time-bounded: the registry file is shared ACROSS PROCESSES (that's the * whole point of GlobalMailbox), so a cache served forever would never see * agents registered by other sessions. Writers always bypass it. */ private _registryCache; /** When the registry cache was last refreshed from disk (epoch ms). */ private _registryCacheAt; /** * Local cache of the client registry to avoid re-reading on every call. * Same reasoning as agent registry cache. */ private _clientRegistryCache; /** When the client registry cache was last refreshed from disk (epoch ms). */ private _clientRegistryCacheAt; /** Last time each local agent sent a heartbeat (throttle). */ private _lastHeartbeat; /** Last time each local client sent a heartbeat (throttle). */ private _lastClientHeartbeat; /** Keep heartbeat throttle state bounded to currently registered entities. */ private pruneHeartbeatThrottleMap; /** * In-memory mirror of the JSONL message file. The mailbox is shared * ACROSS PROCESSES, so reads cannot trust the cache blindly — we pair it * with an mtime check. The file lock serializes every write, so a * changed mtimeMs is a definitive signal that another process (or this * one) wrote; an unchanged mtimeMs guarantees no write happened and the * cache is current. This collapses the per-iteration `query()` cost from * O(file_size) disk + parse to O(messages) in memory. */ private _messageCache; /** mtimeMs of the file when `_messageCache` was populated. */ private _messageCacheMtime; /** Size of the file when `_messageCache` was populated (extra guard). */ private _messageCacheSize; /** * Serializes reads of the message file so overlapping concurrent * `_readMessagesCached()` calls don't both enter the incremental "file * only grew" branch against the same stale `_messageCacheSize` and * each push the same tail bytes onto the cache (duplicating every * appended message). The chain runs each read to completion before * the next starts, in issue order — readers are read-only and never * conflict with each other on content, only on the cache mutation * that follows the read. Pattern mirrors a serialized mutation chain. */ private _readChain; /** * Recipient → Set of indices into `_messageCache`. Maintained alongside * the cache so `query({ to })` and `unreadCount(agentId)` can skip the * O(N) full scan and iterate only the messages addressed to the target * recipient (plus broadcasts `*`). * * The index is rebuilt whenever the cache array is replaced * (`_setMessageCache`), extended when new messages are pushed * (`_pushToCache`), and cleared on `close()`. * It is `null` when the cache itself is null (> MESSAGE_CACHE_MAX_ENTRIES * or never populated). * * Safety: array indices are valid only as long as the cache array * reference hasn't changed. Since we rebuild the index in the same * synchronous step that replaces the cache, a reader that calls * `_readMessagesCached()` either gets the old array + old index, or * the new array + new index — never a mismatch. */ private _recipientIndex; /** * Sender → Set of indices into `_messageCache`. Same lifecycle and * safety properties as `_recipientIndex`, but keyed on `msg.from` * so `query({ from })` can skip the full scan. */ private _senderIndex; /** * @param projectDir — `~/.wrongstack/projects//` * @param events — optional EventBus for real-time TUI/WebUI notifications * @param hqPublisher — optional HQ publisher, or getter, for cross-project telemetry * @param eventEmitter — optional SSE event emitter for HTTP bridge push */ constructor(projectDir: string, events?: EventBus, hqPublisher?: HqPublisherRef, eventEmitter?: MailboxEventEmitter); private get hqMailboxId(); private get hqPublisher(); private publishHqMailboxEvent; private publishHqMailboxSnapshot; send(input: MailboxSendInput): Promise; query(q: MailboxQuery): Promise; ack(input: MailboxAckInput): Promise; /** Diagnostic counters exposed for test/tool introspection. */ readonly diag: { ackManyCacheDesync: number; ackManyPreLockMtime: number; ackManyPostLockMtime: number; ackManyPreLockSize: number; ackManyPostLockSize: number; ackManyMessageCount: number; ackManyAckCount: number; cacheHitCount: number; cacheMissCount: number; cacheDesyncNoOp: number; applyAckTargetMissing: number; setMessageCacheCount: number; pushToCacheCount: number; }; ackMany(input: MailboxAckBatchInput): Promise; unreadCount(forAgentId: string, sessionId?: string): Promise; softDelete(mailId: string, by: string): Promise; restore(mailId: string): Promise; registerAgent(input: AgentRegistrationInput): Promise; deregisterAgent(agentId: string): Promise; heartbeat(input: AgentHeartbeatInput): Promise; getAgentStatuses(): Promise; getOnlineAgents(): Promise; registerClient(input: ClientRegistrationInput): Promise; deregisterClient(clientId: string): Promise; clientHeartbeat(input: ClientHeartbeatInput): Promise; getClientStatuses(): Promise; /** * Explicitly purge stale clients from the registry and write back to disk. * Removes client entries whose lastSeenAt is older than CLIENT_STALE_MS. * Returns the number of entries purged. * * Idempotent — safe to call regularly. The same pruning runs implicitly * inside registerClient, clientHeartbeat, and getClientStatuses, but if * no client registers or heartbeats for a long time, this public method * ensures the file gets cleaned up on demand. */ purgeClients(): Promise; close(): Promise; clearAll(): Promise; purgeStale(opts?: PurgeOptions): Promise; private _autoCompactTimer; autoCompact(opts?: AutoCompactOptions): Promise; startAutoCompactTimer(opts?: AutoCompactOptions): () => void; /** * Read all messages from the JSONL file. Always reads + parses the file. * Callers that can tolerate a stale-by-mtime view should use * {@link _readMessagesCached}; writers that need the post-lock truth * should call this directly (it's what {@link _readMessagesFresh} aliases). */ private _readMessages; /** Parse a JSONL string into MailboxMessage[], applying ack records. */ private _parseLines; /** * Read messages, then adopt the result as the in-memory cache. Use this * from writers that just took the file lock — the read reflects the * authoritative post-lock state and should be served to subsequent * queries without re-reading. * * The mtime/size are captured from the stat at read time so the cache * trackers match exactly what was parsed. Writers that subsequently * rewrite the file MUST re-stat after the write and re-promote with the * post-write values (see ackMany / softDelete / restore / purgeStale / * clearAll) — otherwise a concurrent reader misclassifies the rewrite * as a "file only grew" append and corrupts the cache. */ private _readMessagesFresh; /** * Stat the message file, returning its mtimeMs and size. Returns * `-1/-1` when the file does not yet exist (ENOENT) so callers can * still promote a cache snapshot — the next read will re-stat and * fall through to a full re-read. Call from inside the file lock so * the result reflects the post-write on-disk state, not a later * intermediate state from another process. */ private _statMessageFile; /** * Reconcile an existing message cache with the authoritative file while the * caller holds `messagePath`'s lock. * * Call this before any append/rewrite under the lock that updates * `_messageCacheSize` or `_messageCacheMtime`. Otherwise a cross-process * append that landed since the last cached read is absent from * `_messageCache`, while the newer size/mtime makes later readers * incorrectly treat that incomplete cache as current. * * Returns true only when a stale populated cache was refreshed. A null * cache is intentionally left alone: it is either not initialized or was * disabled by the size cap, and the next query will perform a full read. */ private _refreshMessageCacheUnderLock; /** * Read messages, consulting the mtime-bounded in-memory cache first, * serialized so concurrent callers don't both mutate the cache. * * The mailbox file is shared across processes; every `send`/`ack`/ * `clearAll`/`purgeStale` takes the file lock, so writes are serialized * and a changed mtimeMs is a definitive freshness signal. When the * stat matches the cached mtime+size we return the cached array — no * file read and no JSON.parse — collapsing the per-iteration query * cost on the mailbox-loop hot path. * * When the file only grew (new messages appended by another process), * we read and parse just the tail bytes instead of the entire file. * This avoids re-parsing the full 10K-message history on every check. * * SERIALIZATION: the actual work is chained onto `_readChain` so two * overlapping calls can't both pass the `st.size > _messageCacheSize` * incremental check against the same stale tracker and each push the * same tail bytes onto the cache (duplicating every appended message). * Readers don't conflict on file content — only on the cache mutation * that follows the read — so we run them one at a time in issue order. * Errors in the chain are swallowed so a failed read never poisons * subsequent reads; each caller observes and re-throws its own error. */ private _readMessagesCached; /** * The un-serialized body of {@link _readMessagesCached}. Reads the * message file with the mtime-bounded cache + incremental-tail * optimization. Must only be called from `_readMessagesCached` so the * cache mutations here don't race a sibling read. */ private _readMessagesCachedWork; /** * Replace the in-memory cache, setting the mtime/size trackers * synchronously in the same step. Callers MUST pass the stat of the * on-disk file state that produced `messages`. * * Why both are required (not fire-and-forget): `_readMessagesCached()` * validates the cache against a fresh stat using these two trackers. * If the cache array is updated but the trackers lag behind (e.g. via * a deferred `stat().then(...)`), a concurrent reader landing in that * window sees a mismatched mtime and falls into the incremental * "file only grew" branch — parsing rewritten bytes as an appended * tail and corrupting the cache with duplicates/garbage. So both * values are captured under the file lock and applied synchronously * here, matching the fix already shipped in DefaultMailbox. */ private _setMessageCache; /** * Fold a single ack record into the in-memory cache, mutating the target * message in place. Called from `ackMany` after appending the record to * disk so the cache reflects the ack without a full re-read + re-parse. * * An ack only touches `readBy`/`completed*`/`outcome` — none of which * affect the recipient/sender indexes — so no index rebuild is needed. * No-op when the cache is empty or the target message isn't cached. */ private _applyAckToCache; /** * Build the recipient AND sender indexes from the current `_messageCache`. * Called only from `_setMessageCache` so both indexes are always in sync * with a freshly-replaced cache array. */ private _rebuildRecipientIndex; /** * Append a single just-sent message to the in-memory cache without * re-reading the file. The caller must hold the file lock (or have * just released it after a successful append) and MUST pass the * post-append stat so the mtime/size trackers advance in lock-step * with the pushed content. * * Why the stat is required here: without it, `_messageCacheSize` * stays at the pre-append value. A concurrent `_readMessagesCached()` * then sees `st.size > _messageCacheSize` (the file did grow), takes * the incremental branch, and re-reads the just-appended tail — * pushing the same message onto the cache a second time. Setting the * trackers here closes that window for the local-process append path. */ private _pushToCache; private _ensureRegistry; /** Minimal shape-check for a deserialized agent registry entry. * Logs a warning and returns `false` for structurally invalid data * so a single corrupted entry doesn't break the entire registry. */ private _parseAgentEntry; /** Minimal shape-check for a deserialized client registry entry. */ private _parseClientEntry; private _readRegistry; private _pruneStaleInPlace; private _writeRegistry; private _ensureClientRegistry; private _readClientRegistry; private _pruneStaleClientsInPlace; private _writeClientRegistry; } export {}; //# sourceMappingURL=global-mailbox.d.ts.map