import { EventEmitter } from "node:events"; import { CotalEndpoint, type ControlReply, type Presence, type PresenceStatus, type AttentionMode, type ChannelMode, type CotalMessage } from "@cotal-ai/core"; import type { AgentConfig } from "./config.js"; export type { AttentionMode, ChannelMode }; /** Client-side floor for a spawn action's submit + follow. The manager acceptance carries the exact * connector-selected readiness budget, and core extends the follow through that budget plus * delivery margin. This floor still outlives the manager's generic 30s default and protects * older responders whose acceptance predates that field. */ export declare const SPAWN_TIMEOUT_MS = 40000; /** Grace for a mesh op issued while the link is still coming up. `start()` deliberately returns * immediately so the connector's MCP surface boots while the broker is absent — which means a * host that auto-submits a prompt (`cotal spawn --prompt`) can call a mesh tool a second or two * after launch, several seconds before the first bind lands. Failing that call on the spot * reports "the mesh is down" about a mesh that is merely still connecting, and the agent then * repeats it to the user as fact. Waiting the window out is the honest answer; the bound is what * keeps a genuinely dead broker from becoming a hang instead of an error. */ export declare const CONNECT_GRACE_MS = 10000; /** Whether the configured broker is one the caller could actually start themselves. Matches the * host of a `nats://`/`ws://`/`wss://` server string; anything else (a hosted mesh, a LAN box) is * someone else's process. */ export declare function isLocalServer(servers: string): boolean; /** A message that has arrived for us, normalized for the agent to read. */ export interface InboxItem { id: string; /** Opaque per-delivery RECEIVE key (#624): the address a host uses to drain/ack THIS buffered * delivery. It is the wire `id` for every message that carries one; a message whose `id` is the * empty string gets a minted key, because an empty id is never a dedup key and never a * selectable one. It is NOT wire identity and NOT dedup authority: nothing coalesces on it, so * a redelivered copy of an id-less message mints its own key and surfaces again (at-least-once, * the disclosed cost). It exists so the exact-id drains and in-flight protection can select the * DELIVERY rather than an id value that two distinct messages share. */ recvKey: string; ts: number; fromId: string; fromName: string; fromRole?: string; kind: "channel" | "dm" | "anycast"; /** Set when kind === "channel". */ channel?: string; /** Set when kind === "anycast" (the role addressed). */ service?: string; /** Lowercased names called out on a channel message (priority hint). */ mentions?: string[]; /** True iff this message mentions us by name — computed once, here. Drives high-priority wake. */ mentionsMe: boolean; /** True iff this is backfilled history (a "catching up" block on join), not a live message. */ historical: boolean; text: string; replyTo?: string; contextId?: string; } /** Where a session's focus-mode recall has been read to: a timestamp plus the id that breaks its ties. */ export interface RecallMark { ts: number; id: string; } /** Total order on {@link RecallMark}: by time, then by id, so items sharing a millisecond still queue. */ export declare function afterRecallMark(a: RecallMark, b: RecallMark): boolean; export type InboxScope = "all" | "automatic" | "pull-only"; export interface ExactDrainResult { items: InboxItem[]; missingKeys: string[]; } /** * A thin, mesh-native agent: a {@link CotalEndpoint} plus a buffered inbox and * name-based peer resolution. This is the shared core behind the MCP server * (and, later, the lifecycle hooks) — it owns the NATS connection and presence. * * Connecting is resilient: {@link start} kicks off a background retry loop so the * MCP server is responsive immediately even if the mesh isn't up yet. * * Emits `"incoming"` (InboxItem) when a message is buffered or an unacked durable copy * redelivers, so a push layer can apply its normal delivery policy again; `"mention-wake"` * (InboxItem) when a `focus`-mode agent is @-mentioned on a channel — the body was * acked-and-dropped (not buffered), so this * only asks the push layer to *wake* the agent to pull it; `"wake"` (no payload) to ask that * layer to wake the session now (the Stop→idle flush of held messages); `"error"` (Error) for * endpoint faults. */ /** * The five states a caller has to tell apart, derived in one place so every consumer agrees. * * `degraded` is the one that matters and the one a single boolean gets wrong: the endpoint is bound * but the socket underneath it is down, so sends queue or fail while the client reconnects. It is * NOT the same as `disconnected`, and it is not a stopped session either. `stopped` is terminal and * is not a fault at all. */ export type ConnectionState = "ready" | "stalled" | "degraded" | "connecting" | "disconnected" | "stopped"; /** * How long a non-empty automatic queue may make no progress before this session stops calling * itself `ready` (#1233). * * Generous on purpose. A healthy seat commits automatic deliveries within seconds of the turn that * carries them, and the incident this bound exists for ran for HOURS: 27 deliveries held 13.8h * behind timed-out soft interrupts, reported `ready` throughout. Ten minutes is far outside normal * turn length and far inside the window in which an operator still has a healthy seat to save. */ export declare const AUTOMATIC_QUEUE_STALL_MS = 600000; export declare class MeshAgent extends EventEmitter { readonly ep: CotalEndpoint; readonly config: AgentConfig; private inbox; /** Ids already SURFACED to the model (handled) — bounded, commit-aware dedup ACROSS a drain. The * live↔durable transition window can deliver the two copies of one message far enough apart that the * first is already drained (removed from {@link inbox}) when the second arrives; the pending-inbox * check alone would then re-buffer and double-surface it. Recorded at HANDLE time ({@link drainInbox}), * never at receive time — so a later durable duplicate of an already-handled id is safe to ack (the * logical message was delivered), which is exactly what the removed endpoint-level `firstSeenChat` * got wrong (it acked at receive time, before handling). Two rotating windows bound memory. */ private handledIds; private handledIdsPrev; /** Receive-time classes for overflow-evicted, unhandled channel ambient. Capacity exhaustion * permanently degrades unknown ambient to pull-only for this session rather than risk a late * live/durable copy changing from quiet to automatic. */ private evictedClassifications; /** How many times overflow has evicted each directed id without it ever being handled. Bounds the * un-ack reprieve so a redelivery cycle cannot run forever — see the eviction path in `buffer`. */ private overflowEvictions; /** Surfaced to the host but not yet committed or abandoned, counted per holding frame because * frames overlap. See {@link holdInFlight}. */ private inFlightIds; private classificationUnsafe; /** Terminal receive-time decisions that must survive the live→durable transition: successfully * surfaced pull-only messages and hard drops under muted/focus. Capacity loss degrades the * corresponding whole class fail-closed for the rest of the session. */ private protectedPullOnlyIds; private protectedDropIds; private dropUnsafe; private _connected; /** Raw NATS transport liveness, separate from `_connected` (the full Cotal bind/readiness). */ private _transportConnected; /** Wall-clock time of the latest inbox drain that actually committed at least one delivery. * This is measured only after the backing acknowledgements succeed, never inferred from a read * attempt or from an empty inbox. */ private _lastInboxDrainedAt?; /** Wall-clock time of the latest drain that committed at least one AUTOMATIC delivery. * * Deliberately separate from {@link _lastInboxDrainedAt}, and #1233 is why: on a host-mode * connector the tool inbox and the connector-managed automatic queue are different queues, and * the measured seat was draining the first every few minutes while the second had not moved in * 13.8 hours. A progress mark that counts a pull-only drain as progress would have reported that * seat as making progress, which is the exact lie this field exists to refuse. */ private _lastAutomaticDrainedAt?; /** The latest commit that took the OLDEST automatic delivery then queued (#1526). * * Distinct from {@link _lastAutomaticDrainedAt}, and the distinction is the defect: any automatic * commit used to count, so a seat that kept committing fresh arrivals over a head it could not * deliver reset its own stall clock on every turn and reported `ready` indefinitely, while the * entries at the front of its queue aged past two hours. A queue drains when its head moves; a * queue that serves only its newest arrivals is not draining, it is skipping. */ private _lastAutomaticHeadDrainedAt?; /** Latest connection failure, retained until the endpoint binds so a bounded readiness gate can * explain why an otherwise healthy host never joined the mesh. */ private lastConnectionError?; private endpointNoticeLog; private _status; private _attention; private _recallCursor; /** Recall items stamped ahead of this session's clock that it has already handed over. They are * tracked by id rather than by {@link _recallCursor}, because a sender's clock must not be able to * move this session's mark. Bounded by {@link MAX_AHEAD}. */ private aheadDelivered; /** Per-channel attention overrides — the AUTHORITATIVE runtime state (read by {@link ingest} on * every message). Seeded from the agent-file default; mutated by {@link setChannelMode}; mirrored * to presence for peers. An absent key ⇒ that channel follows the global {@link _attention}. Reset * on restart (rebuilt from config; presence sweep clears the mirror). */ private channelModes; /** The turn relay's seat-side intake: every pulled-and-unyielded run turn, by goal id. Fed by * {@link pollTurns}; surfaced into host context by {@link surfacePendingTurns}; drained by * {@link yieldTurn} (explicit) and the working→idle boundary in {@link setStatus} (automatic * `done`). The poll is also the reconciler: a turn settled elsewhere (deadline, another yield * path) vanishes from `turn-pending` and is dropped here on the next pull. */ private activeTurns; private turnPollTimer?; /** The last pull failure this seat reported, so one that keeps failing is said once. */ private pullTrouble?; private turnPollBusy; private _contextId; /** Chat-stream frontier captured when this agent entered `focus` — recall surfaces ambient * published after it ("since you entered focus"). Undefined unless in focus. */ private focusSince?; private enteringFocus; /** The receive-key namespace secret (#624): a per-session random value minted at construction, * never written to any wire or log. Minted receive keys are `${secret}.${seq}`, so they are * DISJOINT from wire ids by construction: an attacker-chosen wire id cannot equal one, so one * verdict can never select two entries through a forged collision. Recognition is FUNCTIONAL * (the key starts with the secret), so it cannot saturate the way a bounded set would: every * minted key stays recognizable for the session's life, with nothing to expire or overflow. */ private readonly recvKeySecret; private recvKeySeq; private focusExcludedIds; private focusRecallUnsafeChannels; private _stopping; constructor(config: AgentConfig); get id(): string; get connected(): boolean; /** Whether this session's current NATS transport is live, independent of full endpoint readiness. */ get transportConnected(): boolean; /** Latest pre-bind failure. A successful bind clears it; stop preserves it for post-mortem diagnosis. */ get connectionIssue(): string | undefined; /** #1356: the space's presence bucket has been refusing writes since this time, or `undefined`. * * Deliberately NOT folded into {@link connectionIssue}. That field has a defined relationship to * readiness which existing callers already read, and a bound endpoint surviving refused heartbeats * IS still ready — the neighbouring decision at the `warning` listener is correct and stands. This * is a distinct fact that can be true at the same time as `ready`, which is the actual shape of the * incident: connected, serving, and silently unable to publish presence. * * Presence writes are the CANARY, not the scope — see CotalEndpoint.presenceWriteFailure. */ get presenceWriteFailure(): { since: number; forMs: number; error?: string; bucket: string; } | undefined; /** The latest successful, non-empty inbox drain in this session. */ get lastInboxDrainedAt(): number | undefined; /** The latest drain that committed at least one AUTOMATIC (connector-managed) delivery. */ get lastAutomaticDrainedAt(): number | undefined; /** The latest drain that committed the automatic delivery that was then at the FRONT of the queue * (#1526). This is the fact {@link automaticQueueStalledForMs} measures progress by; the looser * {@link lastAutomaticDrainedAt} is still reported beside it, because the two disagreeing is * precisely the shape of a wedged head being served around. */ get lastAutomaticHeadDrainedAt(): number | undefined; /** The receive key of the oldest still-queued automatic delivery: the queue's head, and the entry * whose commit counts as progress. */ private oldestAutomaticKey; /** Record head progress when this batch takes the current head of the automatic queue. Call * BEFORE the batch leaves {@link inbox}, since the head is read from the live queue. */ private noteAutomaticHeadProgress; /** * How long a non-empty automatic queue has been making no progress, or `undefined`. * * PROGRESS AT THE HEAD, not depth and not throughput. The queue is not stalled because it is deep; * it is stalled because the entries at the front of it are not coming off. The clock therefore * starts at the later of the arrival of the oldest still-queued delivery and the last commit that * took the queue's head, so a seat that is steadily draining keeps resetting it however busy it * is, and a seat that has not moved its head since that queue formed accrues from the moment it * formed. * * Keyed on the HEAD rather than on any automatic commit (#1526). The reported session held 96 * deliveries with the oldest over two hours old and reported `ready` throughout: it was committing * fresh arrivals, and under a looser measure each of those reset the clock for the backlog behind * them. A seat that serves only its newest traffic is skipping its queue, not draining it, and the * messages that are actually undelivered are the ones the measure must speak for. * * Measured from the oldest ARRIVAL rather than from session start so a session that never drained * anything is still measurable — that is precisely the shape of a seat whose first soft interrupt * timed out. */ automaticQueueStalledForMs(now?: number): number | undefined; /** Whether {@link stop} has been called. Terminal, and never cleared: a stopped session does not * serve again. This is the ONLY way to tell a deliberate shutdown from a lost connection, because * `stop()` clears readiness and transport together, so those two read identically in both cases. */ get stopping(): boolean; /** The three liveness facts combined, in one place. Every combination maps, so a caller never has * to guess what an unlisted pair means, and a caller that disagrees with this reading can still * read {@link connected}, {@link transportConnected} and {@link stopping} directly. * * `stalled` is the fourth fact and it is not about the connection at all — see * {@link automaticQueueStalledForMs}. It is reported HERE, ahead of `ready`, because #1233 was * not a caller misreading the facts: a seat holding 27 undeliverable messages for 13.8 hours * answered this question with `ready`, and every operator who asked it was told the seat was * fine. A status that reads healthy while the thing it describes is not happening is the defect, * so the one word a caller acts on has to move. It is ordered below `degraded` deliberately: a * dead socket is the more specific and more actionable fault, and reporting the queue symptom * over its own cause would hide it. */ get connectionState(): ConnectionState; /** Wait for the endpoint's real post-bind connection signal. `start()` deliberately stays * background for connectors whose MCP surface must boot while the broker is absent; a host that * advertises mesh readiness uses this bounded gate before making that claim. */ waitUntilConnected(timeoutMs?: number): Promise; /** Correlates outgoing messages to the host agent's current context/window. */ setContextId(contextId: string | undefined): void; /** Begin connecting with background retry. Resolves after the first completed mesh join. */ start(retryMs?: number): Promise; private connectLoop; stop(): Promise; /** Manual reconnect: tear down the mesh connection and rebuild it in-process, WITHOUT * stopping the agent (the recovery path, so it does NOT assert connected). Delegates to * {@link CotalEndpoint.reconnect}, which is serialized with the self-heal supervisor and * interruptible. Returns a one-line status for the caller to surface (e.g. the * cotal_reconnect tool → TUI); on failure the endpoint keeps retrying in the background. */ reconnect(): Promise<{ ok: boolean; message: string; }>; private ingest; private buffer; private rememberEvicted; private excludeFromFocus; private protectDisposition; /** Normalize a wire message into an {@link InboxItem}. `kind` is the **authenticated** class * from {@link MessageMeta} (subject-derived), never the forgeable payload `to`/`toService`; * core has already normalized `channel` from the authenticated chat subject, while `service` * remains a payload display label. Shared by live ingest and * focus recall ({@link recallAmbient}). */ private toInboxItem; /** Return pending messages in stable receive order. Automatic delivery excludes quiet ambient; * pull-only is the explicit cotal_inbox lane. */ peekInbox(scope?: InboxScope): InboxItem[]; /** Mark a surfaced batch as mid-delivery, so the overflow valve will not ack it out from under the * host. Pair with {@link releaseInFlight} on the delivery verdict, whichever way it goes. * * **Counted, not a set.** Hook frames overlap, so the same id is routinely in two open batches at * once; if a hold were a boolean, the first frame's verdict would unprotect ids the second is * still delivering, and an arrival between the two verdicts would ack one — the very loss this * guard exists to stop, reached through the concurrency the per-frame keying deliberately allows. * * **All or nothing, and it says which.** At the ceiling this refuses the whole batch and returns * `false`; the caller must then NOT surface it. Protecting only part of a batch, or protecting * none while the caller surfaces anyway, silently reopens exactly the loss this guard exists to * close — the unprotected ids sit in the inbox for the whole handoff window with the overflow * valve free to ack them. Declining to surface costs a deferral: the messages stay buffered and go * out on a later frame, once a verdict releases capacity. */ holdInFlight(ids: readonly string[]): boolean; /** Whether this id is currently protected from the overflow valve — i.e. some frame is mid-delivery * holding it. Read-only observability for the property {@link holdInFlight} establishes. A caller * that needs a batch to actually BE in flight before it acts must wait on this, never on a sleep: * the handoff runs through a real relay process whose latency is not the caller's to predict. */ isInFlight(id: string): boolean; /** One frame's verdict is in (either way). The id is ordinary backlog again only once EVERY frame * holding it has reported — a release from one must not speak for another still in flight. */ releaseInFlight(ids: readonly string[]): void; /** Return scoped pending messages and ack them — call only when they're actually surfaced. */ drainInbox(limit?: number, scope?: InboxScope): InboxItem[]; /** Ack exact surfaced deliveries without assuming they still form the physical inbox prefix. * Takes RECEIVE keys ({@link InboxItem.recvKey}): the wire id for real messages, a minted key * for id-less ones, and selects by them, so a host that surfaced one empty-id item drains THAT * delivery, never its neighbors: the sweep the raw id produced (every pending empty-id item * acked and marked handled in one call) is closed by construction, not by filtering. Every * requested key whose item is present is acked and (for a real id) marked handled, including an * item overflow-evicted during the turn. */ drainInboxDeliveries(keys: readonly string[]): ExactDrainResult; private commitPending; private inScope; /** Record an id as surfaced/handled, for {@link ingest}'s commit-aware cross-path dedup. Bounded via * two rotating windows: when the live set fills, it becomes the previous window and a fresh one * starts — so memory stays ~2× the cap while the lookup horizon never shrinks below it. */ private markHandled; inboxCount(scope?: InboxScope): number; /** Local receive time of the oldest still-buffered automatic delivery, if any. */ oldestAutomaticReceivedAt(): number | undefined; /** * How far this session has read the focus-mode channel recall. * * {@link recallAmbient} re-derives the same items from an unchanged frontier on every call, so a * reader that shows only what fits in one response would show the same prefix forever. This mark * moves when a response actually delivered recall items, and it belongs to ONE walk over ONE * frontier: it says how far this focus episode has read, not what the stream still holds, and * {@link setAttention} forgets it whenever the frontier under it changes. * * WHAT THIS MARK DOES NOT OWN, said here so nothing above it claims otherwise. It orders what * {@link recallAmbient} hands over; it does not decide what becomes recallable. A message that only * becomes readable after this mark has passed its timestamp, through late persistence, is below the * watermark and will not be walked to. That is governed by the frontier `recallAmbient` derives from * ({@link chatFrontier} and the focus watermark), not here, and it is the same for any * timestamp-ordered reader of that stream. * * What the mark DOES own is where it can be moved to, and only this session's own traffic may move * it: an item stamped ahead of the local clock is walked by {@link recallAhead}, not by this mark, * so no sender can push it past the messages it has not read yet. */ get recallCursor(): RecallMark; /** * Record the last recall item actually handed to the caller. * * The mark is a PAIR, not a timestamp, because two recall items can share a millisecond: a * timestamp alone either filters the twin out for good, if it advances past both, or re-serves the * one already delivered, if it stops below them. Ordering by `(ts, id)` gives every item a place of * its own, so the next call resumes strictly after the last one delivered. */ noteRecalled(mark: RecallMark): void; /** * Does this recall item claim a time this session has not reached? * * `ts` is stamped by the SENDING endpoint, so it is neither trustworthy nor bounded, and a mark * that walks it is a mark a peer can move. One message stamped far ahead otherwise parks * {@link recallCursor} in the future: every ordinary message after it sorts below the mark, is * filtered out of recall for the rest of the session, and the reply says there is no chatter. That * is one peer suppressing OTHER peers' recall, which makes it a security property and not only a * clock one, and the defence has to hold when the field is chosen rather than merely wrong. * * So the walk splits. An item at or behind this session's clock is ordered by its timestamp and * moves the mark. An item ahead of it is not ordered at all and NEVER moves the mark: it is handed * over once and remembered by id ({@link noteRecalledAhead}), so it neither leads the walk nor * comes back on the next call. * * THE LANES SWAP MEMBERSHIP, and the record is what keeps them honest with each other. An item * stamped just ahead of the clock crosses into the ordered lane the moment the clock passes it, * arriving above a mark that never moved for it, so the reader has to consult * {@link recallAheadSeen} there too or hand it over a second time. That crossing is the one part of * this mechanism that cannot be staged by choosing timestamps, so its cell waits for real time * rather than reasoning about it. */ recallAhead(item: { ts: number; }): boolean; /** Has this future-stamped recall item already been handed over in this session? */ recallAheadSeen(id: string): boolean; /** * How many more future-stamped recall items this session will take responsibility for. * * Exactness costs memory, and that memory is what a flood of forged stamps would grow, so it is * bounded. A caller must not hand over what it cannot record, or it will hand it over again on * every call forever; past this bound it has to say so instead. Note where the cost lands: an item * at or behind the clock never enters this set, so a peer that spends the whole bound spends it on * ITS OWN messages and cannot use it to silence anyone else's. */ recallAheadRoom(): number; /** Record that a future-stamped recall item was actually handed to the caller. */ noteRecalledAhead(id: string): void; /** * Forget how far a recall walk had read, because the thing it was walking is gone. * * The mark is session-local, which is a longer life than it can honestly carry: it describes a * position in ONE walk over ONE frontier, and {@link setAttention} both drops the frontier on the * way out of focus and captures a new one on the way in. Measured before this reset, a mark left * over from an earlier focus episode filtered a new episode's messages out of recall whenever they * were stamped behind it, which a lagging or a chosen clock produces. The focus watermark was * already cleared here; the mark that walks it was not. */ private resetRecallWalk; /** Buffered receive-time lane for one delivery, addressed by its receive key. Undefined means it * is no longer pending. */ inboxScope(key: string): Exclude | undefined; /** Count of buffered messages that count as *directed* for a wake decision: real dm/anycast * (authenticated kind) or a channel @-mention. The Stop→idle flush uses this in `dnd`/`focus` * so held *ambient* alone never wakes a turn (which would empty-wake busy-loop). In `focus` * the buffer is directed-only, so this equals {@link inboxCount}. */ directedPendingCount(): number; /** Buffered items that should WAKE a Stop→idle flush — the mode-and-channel-aware predicate the * connectors use instead of branching on attention themselves: * - directed (dm/anycast) or an @mention → always (a quiet @mention still wakes; muted never buffers); * - NORMAL automatic ambient → only under global `open` (today's behavior); * - receive-time pull-only ambient → never. * Subsumes {@link directedPendingCount}: in `dnd`/`focus` (no override) the open term is false, so it * equals the directed count; in `open` it adds normal ambient but excludes quiet-channel ambient. */ pendingWake(): number; /** Ask any push layer (the channel) to wake the session now — used by the Stop→idle flush * to deliver a batch of held messages. Emits `"wake"`; a no-op if nothing listens. Never acks * or drains. Ack sites are now two: {@link drainInbox} (surfaced items) and the focus ingest * ack-drop (ambient/@mentions a focus agent chose not to receive into context). */ requestWake(): void; /** This agent's global attention mode. Authoritative here; mirrored to presence (advisory) so peers * can see it. Delivery never reads it back from presence — local state wins. */ get attention(): AttentionMode; /** This agent's per-channel override for `channel` (undefined ⇒ follow the global mode). */ channelMode(channel?: string): ChannelMode | undefined; /** A snapshot of every per-channel override (for the at-a-glance views). */ channelModeEntries(): Record; /** Set (or clear, with `"normal"`) one channel's attention override. Validates the channel is * concrete and within this agent's read ACL (`allowSubscribe` — so a mode can be pre-set for a * channel it may read but hasn't joined yet), updates the AUTHORITATIVE in-memory map, then mirrors * the whole map to presence (best-effort; advisory). Per-instance + runtime: it NEVER writes the * agent file (a shared template) and resets on restart. * * **Prospective only:** it does NOT purge messages already buffered from that channel — those were * already received and still drain/wake per their original handling. Muting changes what arrives * next, not what's already in the inbox. */ setChannelMode(channel: string, mode: ChannelMode | "normal"): Promise; /** Set the attention mode. Entering `focus` captures the chat frontier as the focus-watermark * (recall surfaces ambient published after it); leaving focus clears it. Requires a live * connection only for `focus` (it reads the stream frontier). Ambient already *buffered* when * focus is entered is not retroactively ack-dropped. Traffic retained during the asynchronous * frontier read is tagged out of recall, so it surfaces once from its receive-time lane whether * it landed just before or after the captured frontier. Only ambient arriving after the local * switch is ack-dropped. */ setAttention(mode: AttentionMode): Promise; /** Focus recall: the channel ambient + @mentions ack-dropped since this agent entered focus, * read back from the chat stream on demand and **replay-gated per channel** (a `replay=off` * channel yields nothing, and is named in `droppedChannels` — recall must not become a history * bypass, and it must not claim a suppressed channel's window was empty and complete either). * Items are marked `historical` (catch-up framing). `droppedChannels` also names channels whose * earliest retained message postdates the focus-watermark — older ambient may have aged out of * the per-channel window — and wildcard subscriptions (`team.>`), which recall cannot read back * per concrete sub-channel (#977: a wildcard join is not itself a channel ingest can consult a * replay policy for) and so cannot vouch for either (never-silent throughout). Empty unless in * focus. */ recallAmbient(): Promise<{ items: InboxItem[]; droppedChannels: string[]; }>; send(text: string, channel?: string, mentions?: string[]): Promise; /** * What a caller can TELL about a send target BEFORE the publish: whether the name * already existed (joined, registry, or prior traffic) and close matches when it * did not. Does not refuse create. Graded before multicast so the new message cannot * make the name look pre-existing. */ describeSendChannel(channel: string): Promise; /** Throw if any name isn't a peer we've observed. Validates against the FULL roster * (incl. self — your own name is a valid participant; resolvePeer's self-filter would * wrongly reject it), case-insensitively. Send is all-or-nothing: one unknown @name aborts * the whole broadcast (fail-loud on typos). Caveat: only catches peers THIS client has seen * — an offline peer lingers in the roster, but one never observed (or not yet filled in * after connect) throws. See docs/architecture.md. */ private assertKnownMentions; anycast(role: string, text: string): Promise; /** Resolve a peer by instance id (exact) or display name. Deterministic and fail-loud: returns * one peer, `undefined` if none match, or throws `AmbiguousPeerError` on a same-name collision — * it never silently picks. See `resolvePeer` in @cotal-ai/core. */ resolvePeer(target: string): Presence | undefined; dm(target: string, text: string): Promise<{ msg: CotalMessage; peer: Presence; }>; /** Ask the manager to spawn a new teammate into this space (its `spawn` action). * The request uses {@link SPAWN_TIMEOUT_MS} as its floor; after acceptance, core follows through * the exact connector-selected readiness budget carried by the acceptance. * How it lands — a detached PTY, a tmux window, a cmux tab — is the manager's * runtime; from here it just joins the mesh as a lateral peer. `opts.agent` picks * the harness (default the manager's `COTAL_DEFAULT_AGENT`, else `cotal`/Claude), `opts.model` / * `opts.variant` override the persona file's model selectors, `opts.prompt` submits the new * peer's first turn, and `opts.cwd` roots it at a different folder/repo * than the manager's workspace — the same knobs the operator's `cotal spawn --detach` carries, so * the agent and operator spawn doors share one control-op contract. (Session `resume` is * intentionally NOT forwarded here: forking a host-local `~/.claude` transcript is an * operator-local intent, kept off the peer-facing spawn door — see #159.) */ spawn(name: string, role?: string, opts?: { agent?: string; model?: string; variant?: string; launchOptions?: Record; cwd?: string; prompt?: string; }): Promise; /** The manager's recorded model pin for a managed seat (`inspect.model`). Absence is a real * state: a launch may pin none. Distinct from a failed inspect, which cannot attest. */ inspectModel(name: string): Promise<{ ok: true; model?: string; } | { ok: false; error: string; }>; /** One v0.4 manager-endpoint invoke (P2 item 1, 1c.2b): the generic {@link CotalEndpoint.invokeService} * path (describe → §13.7 store fetch → digest-verified recompile → typed command), adapted back to * the {@link ControlReply} shape every tool above consumes. `undefined` args are stripped BEFORE the * compiled input contract validates (closed schemas reject present-but-undefined keys; the ctl path * shed them in JSON serialization). A broker-denied publish (a capability the caller's credential * does not hold, e.g. a capability-less purge) has NO responder and surfaces as the deadline — * named here so the refusal reads as the tier boundary it is. */ private managerInvoke; /** Resolve a managed agent's CURRENT principal triple (owner-mode targets are (owner, actor, * lifecycleUid), never an alias — §13.2) through the manager's `inspect` read. A STATIC row's * `id` is the bare actor (nkey) under the caller's own owner; a USER-mode row's `id` is the * composite `owner.actor` principal key — split it, or the embedded dot breaks the target * block's subject arity. The owner-mode standing mint pins the caller's OWN owner, so a * foreign-owner target is broker-denied at publish (the same own-domain boundary as ctl). */ private managerTargetFor; /** Ask the manager to tear a teammate down (its `stop` op). Graceful by default — * the session is told to exit cleanly (so it leaves the mesh) before the * process/tab is closed; `graceful:false` is a hard, immediate kill. * * No `name` ⇒ self-despawn: rides the self-service control subject and the manager * resolves the target as the managed agent whose id == this caller — so it can only * ever stop itself, never a peer. A `name` ⇒ rides the privileged control subject * (transport-gated to spawn-capable/admin); the manager refines own-child vs admin. */ despawn(name?: string, opts?: { graceful?: boolean; }): Promise; /** One `run-*` command to the hosting manager. The five verbs are untargeted and ride the `run` * capability's rows (open mode: anyone). `start` and `resume` return the run id as soon as the * manager has the drive; the run continues there. Their deadline outlives the manager's own * activation wait, so a slow launch reads as the manager's "still launching" refusal and never * as a manager that did not answer. */ run(verb: "start" | "resume" | "answer" | "status" | "ps", args: Record): Promise; private ensureTurnPoll; /** Pull this seat's pending run turns from the manager (`turn-pending`, self-mode). New turns * request a wake so the payload surfaces on the next injectable frame; turns gone from the * reply were settled elsewhere (deadline, agent-down, a competing yield path) and are dropped. * A seat with no manager in its space, or no self-service reach, gets a refused invoke and * simply has no relay — silence, not an error, because most joined sessions are exactly that. */ private pollTurns; /** * A pull that did not come back, said ONCE. * * The poll runs every few seconds for the life of the session, so a line per failure would be a * torrent and there was none at all instead: a seat whose relay had gone quiet looked exactly * like a seat with no relay, and neither the operator nor the agent could tell which. The * ordinary shapes stay silent, because most joined sessions genuinely have no manager to pull * from and that is not trouble. Everything else is said once per distinct reason, and saying it * again waits for the reason to change or for a pull to succeed. */ private notePullTrouble; /** Format every not-yet-surfaced turn as an injectable context block, WITHOUT marking anything * (undefined when none wait). Two-phase on purpose, like the inbox's format-then-verdict rail: * marking at format time would let a lost frame auto-yield `done` for work the model never * saw. A caller commits with {@link commitSurfacedTurns} only once the injection verifiably * reached the host. The payload is opaque relay bytes; when it parses as JSON with a string * `context`, that rendered context is what the model reads, else the raw payload is shown. */ peekPendingTurns(): { text: string; goalIds: string[]; } | undefined; /** Mark peeked turns as surfaced — the second phase, called once their injection verifiably * reached the host. Surfacing is what arms the automatic `done` at the next turn boundary; a * turn never committed re-surfaces on a later frame instead of yielding a lie. */ commitSurfacedTurns(goalIds: readonly string[]): void; /** Yield one active turn back to the run (`turn-yield`, self-mode). Without `turn` it targets * the OLDEST surfaced turn — the one the current session turn is working on. The entry is * dropped locally only on a confirmed yield; a refused one stays for the poll to reconcile * (the manager's answer, not a local guess, decides whether it is settled). */ yieldTurn(status: "done" | "blocked" | "handoff", opts?: { to?: string; note?: string; turn?: string; }): Promise; /** Ask the manager to purge the space's retained chat backlog (its `purge` op). Cleanup only — * it doesn't touch live agents or the anycast work queue. `includeDms` also clears DM history. */ purgeHistory(opts?: { includeDms?: boolean; }): Promise; /** Define a persona and persist it as config (the manager's `definePersona` op writes * .cotal/agents/.md). `spawn(name)` then launches an agent wearing it. * * Writing is the whole job: announcing is OPT-IN via `announce`, and silence is the default. * This used to end in a bare `this.send(...)`, which has no channel argument, so * {@link Endpoint.multicast} resolved the destination as the caller's FIRST CONCRETE CHANNEL — * `general` for most personas, purely by list order. Nobody ever chose that: defining a review * panel sprayed one broadcast per seat into every peer's inbox on the mesh. `reply.ok` was the * send's ONLY gate, so a failed manager reply announced nothing — but a host-level tool retry * hit an idempotent overwrite, succeeded again, and announced the same persona twice. The text * was worse than the volume: "spawn it to bring it online" is an imperative addressed to * strangers, indistinguishable from an attempt to get unrelated agents to run someone else's * code, and it tripped a real provenance investigation. * * So: no `announce` ⇒ no mesh traffic at all, and the definer already knows what it defined (the * reply says so, and `spawn` on a missing persona fails loud). With `announce` ⇒ that channel and * only that channel, never one inferred from ordering; post rights stay broker-enforced, so a * definer without them fails there loudly rather than falling back to `general`. The wording is a * statement of what the sender did, not an instruction to the reader — the sender's identity is * already on the envelope, so an attributed fact is what a peer can actually evaluate. */ definePersona(def: { name: string; prompt: string; model?: string; role?: string; agent?: string; subscribe?: string[]; allowSubscribe?: string[]; allowPublish?: string[]; announce?: string; }): Promise; /** Mesh-side persona catalog list: name, role, model, description, scoped by the same * ownership rule as `definePersona`. */ listPersonas(): Promise; /** Mesh-side persona catalog show of one card. Unauthorized / missing names are not-found. */ showPersona(name: string): Promise; /** The full roster, including ourselves. */ roster(): Presence[]; /** Our last self-reported presence status. */ get status(): PresenceStatus; setStatus(status: PresenceStatus, activity?: string): Promise; /** * Publish presence for something that is NOT a turn ending. * * The boundary above reads working→idle as "the seat finished its turn", and that reading holds * only at a real turn terminal. A session (re)start writes idle too: Claude Code fires * `SessionStart` on compact, clear and resume, and an auto-compaction lands in the MIDDLE of a * long turn. Routed through `setStatus` it yielded `done` for work the model had not finished, * and the run moved on. An adapter uses this for every idle that is a lifecycle event rather * than an ending. */ resetStatus(status: PresenceStatus, activity?: string): Promise; private publishStatus; /** The working→idle boundary: yield `done` for every SURFACED turn (its payload was in the * context of the turn that just ended; ending without an explicit yield IS the done signal), * then re-poll immediately so a queued turn wakes the seat without waiting out the cadence. * Fire-and-forget from the presence path — a yield must never block a status write. */ private onTurnBoundary; /** Record the host's actual model and optional variant learned after launch, so peers see the * selection in `cotal_roster` and the web roster even when the operator never pinned one. Explicit * `model:` / `variant:` config wins; this only fills the gap. Best-effort presence mirror (no * `requireConnected` — safe pre-connect; it rides the first publish). */ setModel(model: string, variant?: string): Promise; /** The boot-time "push" half of channel onboarding: a fenced, one-line description per * subscribed channel that has one (the full `instructions` stay pull-only via * cotal_channel_info — N paragraphs of least-attended text don't belong at boot). Attributed, * advisory framing — the same injection fence as the pull. Best-effort: empty until the * registry cache has loaded (returns undefined when there's nothing to say). */ channelBriefing(): string | undefined; /** A channel's registry config + effective replay policy, from the endpoint's live cache. * Config only — never membership (that view is kept off agents on purpose). */ channelInfo(channel: string): { description?: string; instructions?: string; replay: boolean; registered: boolean; }; /** Channels we're currently subscribed to (live — reflects join/leave). */ joinedChannels(): string[]; /** Discoverable channel list: every channel with traffic or a registry entry, tagged with * its one-line description, replay policy, and whether WE are subscribed (self only — never * other peers' membership). The companion to cotal_join. */ listChannels(): Promise<{ channel: string; description?: string; replay: boolean; joined: boolean; durableUnclosed: boolean; deliveryHealth?: "active" | "degraded"; messages: number; mode: ChannelMode | "normal"; }[]>; /** Join a channel mid-session (backfills history if replay is on; idempotent). `durable` reports * whether a durable backstop is active (Plane-3, SPEC §8, for a `durable`-class channel when a * manager is present) — `false` means joined LIVE only, so messages sent while this session is * offline won't be replayed. `reason` explains a `durable:false` on a channel that EXPECTED a * backstop (e.g. no privileged provisioner); absent on a `live`-class channel (joined live is the * contract there). */ joinChannel(channel: string): Promise<{ joined: boolean; backfilled: number; durable: boolean; reason?: string; }>; /** Leave a channel mid-session (refuses to leave the last one). */ leaveChannel(channel: string): Promise<{ left: boolean; }>; private who; /** The connectedness gate every mesh op goes through. Waits out the initial connect window * rather than failing into it (see CONNECT_GRACE_MS), so the common startup race resolves as * a slightly slow first call instead of a false "mesh is down". */ private requireConnected; /** Public bounded wait for the mesh link, for callers OUTSIDE the op methods that need to gate * their own startup on it. The AG-UI emitter is the proven case: it lazy-starts from the first * lifecycle hook, `--prompt` fires that hook within a second of launch, and its start reached * the endpoint before the first bind — "endpoint not started", terminal by the holder's design, * so one race at spawn silenced the event plane for the whole session. */ whenConnected(timeoutMs?: number): Promise; /** Resolves true on the endpoint's real post-bind connection signal, false if the window closes * first. Listens on the ENDPOINT because its (re)binds are the single source of truth (see the * constructor), and re-checks after binding the listener so a connection that lands between the * guard above and the subscription cannot be missed. */ private awaitConnection; /** `cotal up` starts a broker on THIS machine, so it is only advice when the configured one is * local. Someone whose agent joins a hosted mesh cannot fix it by starting their own, and being * told to try sends them to repair the wrong thing entirely. */ private notConnectedMessage; /** Keep an ordered-consumer reset storm from painting hundreds of status lines through an * attached Codex TUI. Consumer names are generated per reset, so normalize them before * deduplicating; otherwise every `_71`, `_72`, ... would look like a new fault. */ private handleEndpointError; private handleEndpointWarning; private logEndpointNotice; private log; } /** Names already known that differ from `channel` by one insertion, deletion, or substitution. */ export declare function closeChannelNames(channel: string, known: readonly string[]): string[]; //# sourceMappingURL=agent.d.ts.map