@cello-protocol/daemon 0.0.31 → 0.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -33,8 +33,10 @@ import { acquireLock, removeLock } from "./lock-file.js";
33
33
  import { createIpcServer } from "./ipc-server.js";
34
34
  import { SessionNodeManager } from "./session-node-manager.js";
35
35
  import { classifySession } from "./session-category.js";
36
+ import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT } from "@cello-protocol/gateway";
36
37
  import { RetryQueue } from "./retry-queue.js";
37
38
  import { NonceDedupStore } from "./nonce-dedup.js";
39
+ import { HttpTelegramBotClient } from "./telegram-bot-client.js";
38
40
  import { ContentParkClient } from "./content-park-client.js";
39
41
  import { NotificationDispatcher } from "./notification-dispatcher.js";
40
42
  import { createNode, SignalingManager } from "@cello-protocol/transport";
@@ -174,8 +176,11 @@ class ProductionSessionNodeFactory {
174
176
  });
175
177
  }
176
178
  }
179
+ // M8C-TTL-1: receiver-side session-request TTL. CORE ships the DoD's own 24h default;
180
+ // per-agent configurability is PARKED on M9-CFG-001 (D17 — same pattern as D14/D15/D16).
181
+ export const INBOUND_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
177
182
  export async function startDaemon(config) {
178
- const { celloDir, socketPath, lockFilePath, maxConnections, version, logger, manifestProvider, manifestRootKeys, manifestThreshold, manifestVersionStore: injectedManifestVersionStore, manifestPollScheduler, directoryHttpUrl, signalingConnect, challengeVerifier, directoryEndpointResolver, sessionNodeFactory, sessionNegotiator, getRelayCircuitAddress, } = config;
183
+ const { celloDir, socketPath, lockFilePath, maxConnections, version, logger, manifestProvider, manifestRootKeys, manifestThreshold, manifestVersionStore: injectedManifestVersionStore, manifestPollScheduler, directoryHttpUrl, signalingConnect, challengeVerifier, directoryEndpointResolver, sessionNodeFactory, sessionNegotiator, getRelayCircuitAddress, telegramBotClient: injectedTelegramBotClient, } = config;
179
184
  // CELLO-M7-TRANSPORT-001: composition-root selection of the transport selector.
180
185
  // Driven by CELLO_ENV; fails fast at startup (here, not at first session) when a
181
186
  // production environment is missing the required transport dialer (AC-010).
@@ -203,12 +208,24 @@ export async function startDaemon(config) {
203
208
  await mkdir(celloDir, { recursive: true });
204
209
  await mkdir(dirname(socketPath), { recursive: true });
205
210
  await acquireLock(lockFilePath, { pid: process.pid, socketPath, version });
211
+ // M9-CORE-001: one security-gateway client, shared by both seams — the outbound screen in
212
+ // cello_send and the inbound screen inside SessionNodeManager. Absent config falls back to a
213
+ // PassthroughGatewayClient (always-allow), so pre-M9 daemons behave exactly as before while
214
+ // still returning a verdict (SI-001).
215
+ const securityGateway = config.securityGateway ?? new PassthroughGatewayClient();
216
+ // M9-CORE-001 observability: announce the gateway mode at startup. The sidecar socket connects
217
+ // lazily on the first screen, so this records which adapter is wired (sidecar vs the always-allow
218
+ // passthrough default), not a live socket handshake.
219
+ logger.info("security.gateway.connected", {
220
+ mode: config.securityGateway ? "sidecar" : "passthrough",
221
+ });
206
222
  const sessionNodeManager = new SessionNodeManager({
207
223
  factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(),
208
224
  logger,
209
225
  dbPath: join(celloDir, "sessions.db"),
210
226
  contentTtfMs: config.contentTtfMs,
211
227
  autoNatProbers: () => [],
228
+ securityGateway,
212
229
  });
213
230
  await sessionNodeManager.initialize();
214
231
  // AC-008: the manifest version store is DB-backed by default (encrypted manifest_state table). A
@@ -525,6 +542,14 @@ export async function startDaemon(config) {
525
542
  logger,
526
543
  maxReconnectAttempts: Number.MAX_SAFE_INTEGER,
527
544
  maxBackoffMs: 30_000,
545
+ // M8C-RELAYWAKE-1: "check relay on wakeup" — every time this agent's directory signaling
546
+ // reaches 'connected' (the first connect AND every reconnect after a drop), re-drain its
547
+ // parked mailbox from every relay it has session history with. Without this, a message
548
+ // parked while signaling was down (network blip, directory node restart, daemon offline)
549
+ // is only ever discovered at the NEXT agent start, not on the reconnect that actually
550
+ // brings the agent back — this closes that gap. Fire-and-forget; autoRecoverForAgent
551
+ // already catches its own per-relay errors internally.
552
+ onConnected: () => { void autoRecoverForAgent(agentName); },
528
553
  });
529
554
  const entry = { signaling: mgr, getNode: () => nodeRef };
530
555
  perAgentSignaling.set(agentName, entry);
@@ -721,6 +746,237 @@ export async function startDaemon(config) {
721
746
  const perConnectionState = new Map();
722
747
  // Set of agents currently in "online" state (transitioned via cello_start_agent)
723
748
  const onlineAgents = new Set();
749
+ // M8C-CURSOR-1: per-connection, per-session read cursor (read-before-write gating).
750
+ // Distinct from message_watermarks (INBOX-1, per-AGENT delivery watermark, persisted) — this is
751
+ // per-CONNECTION, in-memory only, and intentionally dies with the connection (a fresh connection
752
+ // has read nothing yet, so it must catch up before it may send — the WhatsApp-group-chat model
753
+ // for two attended sessions on one agent). Key = connectionId → sessionId → highest sequence this
754
+ // connection has read (or authored). Absent entry = -1 (nothing read yet).
755
+ const connectionCursors = new Map();
756
+ function getConnectionCursor(connectionId, sessionId) {
757
+ return connectionCursors.get(connectionId)?.get(sessionId) ?? -1;
758
+ }
759
+ function advanceConnectionCursor(connectionId, sessionId, seq) {
760
+ let byId = connectionCursors.get(connectionId);
761
+ if (!byId) {
762
+ byId = new Map();
763
+ connectionCursors.set(connectionId, byId);
764
+ }
765
+ const prior = byId.get(sessionId) ?? -1;
766
+ if (seq > prior)
767
+ byId.set(sessionId, seq); // monotonic — never lowers
768
+ }
769
+ // M8C-CURSOR-1 (cello-unit-reviewer HIGH finding, confirmed by live reproduction): a
770
+ // received-only delivery (since_seq / live-drain cello_receive) must NOT blindly advance the
771
+ // cursor to the max sequence it happened to see — leaf indices are shared and strictly
772
+ // contiguous across BOTH directions (appendSessionLeaf always assigns leafCount, no gaps), so a
773
+ // gap between the connection's actual cursor and that max can hide an unread SENT leaf authored
774
+ // by a DIFFERENT local connection. Advancing past it would silently let this connection send
775
+ // without ever having seen it — defeating the read-before-write guarantee (C4/C5). Only advance
776
+ // through a CONTIGUOUS run of sequence numbers that were actually in this delivery, starting
777
+ // right after the connection's current cursor; stop at the first gap.
778
+ function safeCursorAdvance(connectionId, sessionId, deliveredSeqs) {
779
+ let cursor = getConnectionCursor(connectionId, sessionId);
780
+ while (deliveredSeqs.has(cursor + 1))
781
+ cursor += 1;
782
+ advanceConnectionCursor(connectionId, sessionId, cursor);
783
+ }
784
+ // M8C-AWAY-1: away response — an unattended Primary auto-answers session requests + messages
785
+ // with the default transparent away text and queues them (the DoD's own mandated default).
786
+ // CORE ships now; the operator-configurable custom-text / opaque-privacy-mode SWITCH is PARKED
787
+ // on M9-CFG-001, journaled as D15 (M8C-DECISIONS.md, mirrors D14) — a genuine per-agent operator
788
+ // preference that needs the deferred config store, whereas transparent is already the correct,
789
+ // non-fake default this unit must ship regardless.
790
+ // "Attended" per the design doc (2026-07-01 command-surface discussion, Agent State Model):
791
+ // Primary + a live client session has claimed the agent via use_agent. Scanning
792
+ // perConnectionState is cheap (a handful of connections) and needs no separate tracked state.
793
+ function isAttended(agentName) {
794
+ for (const s of perConnectionState.values())
795
+ if (s.currentAgent === agentName)
796
+ return true;
797
+ return false;
798
+ }
799
+ const AWAY_TEXT = {
800
+ request: "Agent is currently away. Your session request has been received and queued.",
801
+ message: "Agent is currently away. Your message has been received and will be read when the operator returns.",
802
+ };
803
+ // M8C-CONTACT-1: "unknown senders learn only 'dispatched' by default" — a single shared,
804
+ // deliberately minimal template regardless of kind, distinct from AWAY-1's richer per-type text.
805
+ const STRANGER_TEXT = "Dispatched.";
806
+ // Coalescing: one away ack per (agent, session, kind) per away period — cleared when the agent
807
+ // becomes attended again (cello_use_agent) so the NEXT away period gets a fresh ack rather than
808
+ // staying silent forever. Known imprecision (journaled, not silent): clearing fires on ANY
809
+ // use_agent selecting this name, even if another connection kept it attended throughout — an
810
+ // edge case, not a correctness gap in the core "queue + one calm ack while genuinely away" promise.
811
+ const awayAckSent = new Set();
812
+ async function sendAwayResponse(agentName, sessionId, kind) {
813
+ if (isAttended(agentName))
814
+ return;
815
+ const dedupKey = `${agentName}:${sessionId}:${kind}`;
816
+ if (awayAckSent.has(dedupKey))
817
+ return;
818
+ const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
819
+ if (!record || record.status !== "active")
820
+ return;
821
+ // M8C-CONTACT-1: this check MUST run synchronously (before any await below) — the caller of
822
+ // sendAwayResponse for a fresh inbound request fires this THEN calls addContact right after
823
+ // (D6: "accepting X's request adds X"), relying on this line executing first so a stranger's
824
+ // VERY FIRST contact is correctly judged unknown, not already-added.
825
+ const isKnown = sessionNodeManager.isContact(agentName, record.counterparty_pubkey);
826
+ awayAckSent.add(dedupKey); // guard BEFORE the async send — concurrent arrivals must not double-ack
827
+ try {
828
+ const contentBytes = new TextEncoder().encode(isKnown ? AWAY_TEXT[kind] : STRANGER_TEXT);
829
+ const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
830
+ const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
831
+ if (!sendResult.ok) {
832
+ // Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
833
+ // away period — clear the guard so the next inbound arrival retries the ack.
834
+ awayAckSent.delete(dedupKey);
835
+ logger.warn("session.away.response.failed", { agentName, sessionId, kind, reason: sendResult.reason });
836
+ return;
837
+ }
838
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
839
+ const { leafIndex } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, randomUUID());
840
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, leafIndex, "sent", contentBytes, randomUUID());
841
+ logger.info("session.away.response.sent", { agentName, sessionId, kind, isKnown, sequenceNumber: leafIndex });
842
+ }
843
+ catch (err) {
844
+ // Reviewer MEDIUM fix: same as above — an unexpected throw must not permanently lock out
845
+ // future retries for the rest of this away period.
846
+ awayAckSent.delete(dedupKey);
847
+ logger.warn("session.away.response.failed", { agentName, sessionId, kind, error: err instanceof Error ? err.message : String(err) });
848
+ }
849
+ }
850
+ // M8C-TGDOOR-1: Telegram Mode 1 doorbell — a daemon-owned bot pushes discrete, content-free
851
+ // events (session request / message-waiting / state change) to one allowlisted operator chat.
852
+ // "NO channel machinery" (DoD) — this talks to the Telegram Bot API directly, unrelated to the
853
+ // claude/channel MCP capability from Tiers 1-2. Inert until telegram_settings exist.
854
+ let telegramBotClient = injectedTelegramBotClient ?? null;
855
+ let telegramChatId = null;
856
+ let telegramBotToken = null; // tracked only to detect an actual token CHANGE
857
+ // Generation counter, NOT a shared boolean — a boolean flip-to-false-then-true-again (e.g. a
858
+ // settings update that restarts the poller) would let the OLD loop's `while` condition still
859
+ // read true on its next check, running TWO concurrent loops. Each loop instance captures its
860
+ // OWN generation at start and exits the instant the counter no longer matches (a new start, or
861
+ // shutdown, both just bump it).
862
+ let telegramPollerGeneration = 0;
863
+ let telegramUpdateOffset = 0;
864
+ // Coalescing: ring once for the FIRST message-waiting event since a session was last fully
865
+ // read; cleared on read (cello_receive/since_seq advancing that session's watermark). Session
866
+ // requests and state changes bypass this Set entirely — DoD says they always ring.
867
+ const telegramRungUnread = new Set();
868
+ async function sendTelegramDoorbell(agentName, sessionId, kind, detail) {
869
+ if (!telegramBotClient || !telegramChatId)
870
+ return; // TGDOOR not configured — inert, not an error
871
+ if (kind === "message_waiting") {
872
+ const rungKey = `${agentName}:${sessionId}`;
873
+ if (telegramRungUnread.has(rungKey))
874
+ return; // already rung, still unread — coalesced
875
+ telegramRungUnread.add(rungKey);
876
+ }
877
+ // DOD-INV-CONTENTFREE: `detail` is a fixed, content-free label per kind — NEVER message text.
878
+ const text = `[${agentName} · ${sessionId.slice(0, 8)}] ${detail}`;
879
+ try {
880
+ const result = await telegramBotClient.sendMessage(telegramChatId, text);
881
+ if (!result.ok) {
882
+ logger.warn("telegram.doorbell.send.failed", { agentName, sessionId, kind, reason: result.error });
883
+ return;
884
+ }
885
+ logger.info("telegram.doorbell.sent", { agentName, sessionId, kind });
886
+ }
887
+ catch (err) {
888
+ logger.warn("telegram.doorbell.send.failed", { agentName, sessionId, kind, error: err instanceof Error ? err.message : String(err) });
889
+ }
890
+ }
891
+ // Read clears the ring (DoD: "ring-once-until-read") — called wherever a receive advances a
892
+ // session's read watermark, mirroring AWAY-1's own dedup-clear-on-attend pattern.
893
+ function clearTelegramRung(agentName, sessionId) {
894
+ telegramRungUnread.delete(`${agentName}:${sessionId}`);
895
+ }
896
+ // Wraps notificationDispatcher.dispatchSessionStateChanged so every call site gets the
897
+ // Telegram state-change doorbell for free (DoD: state changes ALWAYS ring, never coalesced) —
898
+ // one wrapper rather than hooking each of the several existing call sites individually.
899
+ function dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey) {
900
+ notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey);
901
+ void sendTelegramDoorbell(agentName, sessionId, "state_change", `Session ${state}`);
902
+ // Reviewer HIGH fix (a60d68ed): telegramRungUnread had NO cleanup at all — a session that
903
+ // rings once and is never read via cello_receive/since_seq (e.g. the operator only ever uses
904
+ // cello_get_transcript, which does not advance the read watermark) left a permanent entry for
905
+ // the life of the daemon process. Every state change is a natural point to drop it — the
906
+ // worst case if the session is still genuinely active is one possible extra ring later, far
907
+ // preferable to an unbounded leak (the exact class of bug fixed for TTL-1's expired-log at
908
+ // af8a701 in this same milestone).
909
+ clearTelegramRung(agentName, sessionId);
910
+ }
911
+ async function handleInboundTelegramUpdate(update, client) {
912
+ const chatId = update.message?.chat?.id !== undefined ? String(update.message.chat.id) : undefined;
913
+ if (!chatId)
914
+ return;
915
+ if (chatId !== telegramChatId) {
916
+ // D6: any other chat is silently dropped — nothing enters CELLO content paths.
917
+ logger.info("telegram.inbound.rejected", { chatId });
918
+ return;
919
+ }
920
+ logger.info("telegram.inbound.acknowledged", { chatId });
921
+ try {
922
+ await client.sendMessage(chatId, "CELLO doesn't process messages here — this channel only sends you notifications. Use your CELLO client to reply.");
923
+ }
924
+ catch (err) {
925
+ logger.warn("telegram.inbound.ack.failed", { chatId, error: err instanceof Error ? err.message : String(err) });
926
+ }
927
+ }
928
+ // Single long-lived getUpdates poller (DoD) — one in-flight loop per daemon; started once
929
+ // settings exist, stopped on daemon shutdown. A network hiccup backs off and retries rather
930
+ // than killing the poller (best-effort — the doorbell is a convenience, never load-bearing).
931
+ // Reviewer MEDIUM fix (a60d68ed): the loop previously read the SHARED, reassignable
932
+ // telegramBotClient/telegramUpdateOffset fresh each iteration — only myGeneration was truly
933
+ // loop-local. A settings update mid-await could let a stale response from the OLD client (1)
934
+ // still be processed once (the generation check only blocks the loop's NEXT iteration, not an
935
+ // in-flight call) and (2) stomp telegramUpdateOffset with the OLD bot's update_id numbering,
936
+ // which is meaningless to a new token (Telegram scopes update_id per bot). Fix: capture the
937
+ // client as a PARAMETER (never re-read from the shared variable) and re-check the generation
938
+ // immediately after every await before acting on its result or touching shared state.
939
+ async function runTelegramPollerLoop(myGeneration, myClient) {
940
+ let myOffset = telegramUpdateOffset;
941
+ while (telegramPollerGeneration === myGeneration) {
942
+ try {
943
+ const updates = await myClient.getUpdates(myOffset, 25);
944
+ if (telegramPollerGeneration !== myGeneration)
945
+ break; // superseded while awaiting — drop stale results
946
+ for (const u of updates) {
947
+ myOffset = u.update_id + 1;
948
+ telegramUpdateOffset = myOffset;
949
+ if (u.message)
950
+ void handleInboundTelegramUpdate(u, myClient);
951
+ }
952
+ }
953
+ catch (err) {
954
+ if (telegramPollerGeneration !== myGeneration)
955
+ break; // superseded — don't retry under a dead generation
956
+ logger.warn("telegram.poller.error", { error: err instanceof Error ? err.message : String(err) });
957
+ await new Promise((r) => setTimeout(r, 2000)); // back off before retrying
958
+ }
959
+ }
960
+ }
961
+ // Always bumps the generation (invalidating any prior loop, which exits on its next check) and
962
+ // starts a fresh one — correct both for the first start AND a settings-change restart.
963
+ function startTelegramPollerIfConfigured() {
964
+ const settings = sessionNodeManager.getTelegramSettings();
965
+ if (!settings)
966
+ return; // not configured — TGDOOR stays inert
967
+ // Reviewer MEDIUM fix: reset the offset on an actual token/chat CHANGE — a prior bot's
968
+ // update_id numbering is meaningless to a new one (Telegram scopes it per bot token).
969
+ if (telegramBotToken !== settings.botToken || telegramChatId !== settings.allowlistedChatId) {
970
+ telegramUpdateOffset = 0;
971
+ }
972
+ telegramBotToken = settings.botToken;
973
+ telegramChatId = settings.allowlistedChatId;
974
+ const client = injectedTelegramBotClient ?? new HttpTelegramBotClient(settings.botToken);
975
+ telegramBotClient = client;
976
+ telegramPollerGeneration += 1;
977
+ void runTelegramPollerLoop(telegramPollerGeneration, client);
978
+ logger.info("telegram.poller.started", {});
979
+ }
724
980
  // SessionNodeManager was constructed + initialized at the top of startDaemon (PERSIST-002 — the
725
981
  // encrypted store must open before agents load from the `agents` table). Its standing receiver +
726
982
  // interrupted-session detection are already ready here, before the IPC socket opens.
@@ -1705,6 +1961,12 @@ export async function startDaemon(config) {
1705
1961
  }
1706
1962
  const fromAgent = connState.currentAgent;
1707
1963
  connState.currentAgent = name;
1964
+ // M8C-AWAY-1: this agent just became attended — clear its away-ack dedup entries so the NEXT
1965
+ // away period (after this attended stretch ends) gets a fresh ack instead of staying silent.
1966
+ for (const key of Array.from(awayAckSent)) {
1967
+ if (key.startsWith(`${name}:`))
1968
+ awayAckSent.delete(key);
1969
+ }
1708
1970
  // MCP-002: Update dispatcher's routing table and send notification to this connection only
1709
1971
  notificationDispatcher.setCurrentAgent(connectionId, name);
1710
1972
  notificationDispatcher.dispatchAgentCurrentChanged(connectionId, fromAgent, name);
@@ -2577,6 +2839,9 @@ export async function startDaemon(config) {
2577
2839
  });
2578
2840
  }
2579
2841
  }
2842
+ // M8C-CONTACT-1 (D6): "initiating a session to X adds X" — pin at the pubkey the negotiator
2843
+ // actually used (not re-resolved later).
2844
+ sessionNodeManager.addContact(agentName, counterpartyPubkey);
2580
2845
  // AC-007: the session is usable immediately upon (relay) connection — the dcutr
2581
2846
  // upgrade runs in the background and is intentionally NOT awaited here.
2582
2847
  return { ok: true, sessionId, transportMode: result.mode, correlationId };
@@ -2965,6 +3230,14 @@ export async function startDaemon(config) {
2965
3230
  const { messages, undecryptable } = sessionNodeManager.readTranscript(connState.currentAgent, sessionId);
2966
3231
  // undecryptable > 0 means some rows failed GCM auth (tamper / wrong key) — surfaced, not hidden,
2967
3232
  // so the reader can tell a real gap from an empty transcript.
3233
+ // M8C-CURSOR-1: this is the ONLY reader that covers BOTH directions (sent + received), so it's
3234
+ // the correct general catch-up path — e.g. a second attended connection on the same agent
3235
+ // catching up on a message a DIFFERENT local connection sent (since_seq is received-only and
3236
+ // would never surface it). Route through safeCursorAdvance rather than trusting the raw max —
3237
+ // reviewer HIGH finding (aa5928e2/a9099571): recordTranscriptMessage swallows a DB write
3238
+ // failure without rolling back the tree's leaf count, so a hole in `messages` is possible in
3239
+ // principle; the contiguous-run walk refuses to vault the cursor past such a hole even here.
3240
+ safeCursorAdvance(connectionId, sessionId, new Set(messages.map((m) => m.sequence)));
2968
3241
  return { ok: true, session_id: sessionId, messages, undecryptable };
2969
3242
  });
2970
3243
  // cello_list_sessions: the discovery surface — every persisted session for the
@@ -3183,13 +3456,25 @@ export async function startDaemon(config) {
3183
3456
  if (env.structure1Cbor && env.structure2Cbor) {
3184
3457
  sessionNodeManager.recordOrderingRecord(recipientAgent.name, e.sessionIdHex, env.structure1Cbor, env.structure2Cbor, contentHashBytes);
3185
3458
  }
3186
- const ingest = sessionNodeManager.ingestReceivedContent(recipientAgent.name, e.sessionIdHex, env.content, contentHashBytes);
3459
+ const ingest = await sessionNodeManager.ingestReceivedContent(recipientAgent.name, e.sessionIdHex, env.content, contentHashBytes);
3187
3460
  if (ingest.ok && ingest.held) {
3188
3461
  // DOD-MSG-4 (review finding #4): a held entry is NOT yet an appended leaf — its sequence is
3189
3462
  // the FUTURE canonical index, not a completed recovery. Do not count it as recovered; log it
3190
3463
  // distinctly so the tally reflects leaves actually written, not content still queued in memory.
3191
3464
  logger.info("content.recover.held", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, canonicalSeq: ingest.sequenceNumber });
3192
3465
  }
3466
+ else if (ingest.ok && ingest.screenedOut) {
3467
+ // M9 (code-review LOW-3): a terminal-screened recovered entry IS durably leafed (so it must be
3468
+ // confirm-deleted, below) but was NEVER delivered to the agent — do not count it as a delivered
3469
+ // recovery, and log it distinctly so observability separates "delivered" from "leafed-but-screened".
3470
+ logger.info("content.recover.screened_out", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, sequenceNumber: ingest.sequenceNumber });
3471
+ try {
3472
+ await client.confirm(node, Buffer.from(recipientPubkey, "hex"), contentHashBytes, kp);
3473
+ }
3474
+ catch (err) {
3475
+ logger.warn("content.recover.confirm.failed", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, error: err instanceof Error ? err.message : String(err) });
3476
+ }
3477
+ }
3193
3478
  else if (ingest.ok) {
3194
3479
  // DOD-MSG-4 (review #3): count leaves ACTUALLY written — the directly-ingested leaf PLUS any
3195
3480
  // held out-of-order entries this ingest unblocked (appendedCount), not just 1.
@@ -3284,13 +3569,13 @@ export async function startDaemon(config) {
3284
3569
  const sessionPeerId = params?.sessionPeerId ?? "";
3285
3570
  const correlationId = params?.correlationId ?? "";
3286
3571
  logger.info("session.node.created", { sessionId, agentName, sessionPeerId, correlationId });
3287
- notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, "created", counterpartyPubkey);
3572
+ dispatchSessionStateChangedWithTelegram(agentName, sessionId, "created", counterpartyPubkey);
3288
3573
  }
3289
3574
  else if (type === "destroyed") {
3290
3575
  const state = params?.state ?? "interrupted";
3291
3576
  const reason = params?.reason ?? state;
3292
3577
  logger.info("session.node.destroyed", { sessionId, agentName, reason });
3293
- notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey);
3578
+ dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey);
3294
3579
  }
3295
3580
  return { ok: true };
3296
3581
  });
@@ -3305,6 +3590,15 @@ export async function startDaemon(config) {
3305
3590
  return { error: "missing_params", guidance: "Provide agentName and sessionId." };
3306
3591
  }
3307
3592
  enqueueInboundSession(agentName, { sessionIdHex, counterpartyPubkeyHex, genesisPrevRootHex: "" });
3593
+ // M8C-TTL-1: let a test backdate the just-enqueued entry's timestamp (simulating age) without
3594
+ // waiting real hours or faking global timers — enqueueInboundSession always stamps Date.now().
3595
+ const enqueuedAtOverride = params?.enqueuedAtOverride;
3596
+ if (enqueuedAtOverride !== undefined) {
3597
+ const q = inboundSessionQueues.get(agentName);
3598
+ const entry = q?.[q.length - 1];
3599
+ if (entry)
3600
+ entry.enqueuedAt = enqueuedAtOverride;
3601
+ }
3308
3602
  return { ok: true };
3309
3603
  });
3310
3604
  // M8C-INBOX-1 (reviewer F1): buffer a received message so a test can drive a live cello_receive
@@ -3461,6 +3755,9 @@ export async function startDaemon(config) {
3461
3755
  correlationId,
3462
3756
  });
3463
3757
  }
3758
+ // Expired requests move HERE (from inboundSessionQueues) rather than vanishing — visible via
3759
+ // cello_check_notifications so the operator can see what they missed, not just silence.
3760
+ const expiredSessionRequests = new Map();
3464
3761
  // Per-agent FIFO queue (events accepted while no cello_await_session is blocked) and
3465
3762
  // the per-agent list of blocked waiters. Inbound sessions are addressed to a specific
3466
3763
  // local agent (participant_b), so both are keyed by agent name.
@@ -3486,9 +3783,45 @@ export async function startDaemon(config) {
3486
3783
  return;
3487
3784
  }
3488
3785
  const q = inboundSessionQueues.get(agentName) ?? [];
3489
- q.push(event);
3786
+ q.push({ ...event, enqueuedAt: Date.now() }); // M8C-TTL-1: stamp queue entry time
3490
3787
  inboundSessionQueues.set(agentName, q);
3491
3788
  }
3789
+ // M8C-TTL-1 (reviewer HIGH fix, aed2d71f, D19): expiredSessionRequests is a lasting log, not a
3790
+ // consumed queue (unlike inboundSessionQueues, nothing ever drains it) — a whitelisted CONTACT-1
3791
+ // contact is EXEMPT from ABUSE-1's acceptance bounds ("bounded only by disk"), so they can push
3792
+ // unlimited accepted sessions the operator never claims, each becoming a permanent, unremovable
3793
+ // entry every 24h for the life of the daemon process. Capped the same way STATUS_RESUMABLE_CAP
3794
+ // bounds the interrupted-sessions list — keep only the MOST RECENT N per agent.
3795
+ const EXPIRED_SESSION_REQUESTS_CAP = 20;
3796
+ // M8C-TTL-1: move any queue entries past the TTL into expiredSessionRequests (visible via
3797
+ // INBOX), instead of leaving them to sit forever or vanish silently. Called lazily on every
3798
+ // read of the queue (cello_await_session's immediate-check, cello_check_notifications) rather
3799
+ // than on a timer — no background sweep needed, and it's always correct-as-of-the-read.
3800
+ function reapExpiredInboundSessions(agentName) {
3801
+ const q = inboundSessionQueues.get(agentName);
3802
+ if (!q || q.length === 0)
3803
+ return;
3804
+ const now = Date.now();
3805
+ const live = [];
3806
+ let expiredList = expiredSessionRequests.get(agentName);
3807
+ for (const e of q) {
3808
+ if (e.enqueuedAt !== undefined && now - e.enqueuedAt > INBOUND_SESSION_TTL_MS) {
3809
+ if (!expiredList) {
3810
+ expiredList = [];
3811
+ expiredSessionRequests.set(agentName, expiredList);
3812
+ }
3813
+ expiredList.push({ sessionIdHex: e.sessionIdHex, counterpartyPubkeyHex: e.counterpartyPubkeyHex, expiredAt: now });
3814
+ if (expiredList.length > EXPIRED_SESSION_REQUESTS_CAP) {
3815
+ expiredList.splice(0, expiredList.length - EXPIRED_SESSION_REQUESTS_CAP); // keep newest N
3816
+ }
3817
+ logger.info("session.request.expired", { agentName, sessionId: e.sessionIdHex, enqueuedAt: e.enqueuedAt });
3818
+ }
3819
+ else {
3820
+ live.push(e);
3821
+ }
3822
+ }
3823
+ inboundSessionQueues.set(agentName, live);
3824
+ }
3492
3825
  // CBOR-decoded byte fields arrive as Uint8Array or Buffer; a field may also already be
3493
3826
  // a hex string. Hex strings are lowercased so the case-sensitive agent-pubkey match
3494
3827
  // (agents store lowercase hex) cannot silently miss (review L2).
@@ -3614,6 +3947,20 @@ export async function startDaemon(config) {
3614
3947
  }
3615
3948
  async function acceptInboundAssignment(parsed, agentName, correlationId) {
3616
3949
  try {
3950
+ // M8C-ABUSE-1 (anti-drip-feed / anti-swarm): bound acceptance from unknown (non-contact)
3951
+ // senders — a per-sender cap (many sessions from ONE stranger) and a global cap (many
3952
+ // sessions across ALL strangers combined). Known contacts are exempt ("bounded only by
3953
+ // disk" — DoD). Checked FIRST, before any standing-receiver work, so a refusal is cheap.
3954
+ const bound = sessionNodeManager.checkUnknownSenderAcceptanceBound(agentName, parsed.participantAPubkeyHex);
3955
+ if (!bound.ok) {
3956
+ logger.warn("session.inbound.accept.failed", {
3957
+ sessionId: parsed.sessionIdHex,
3958
+ agentName,
3959
+ reason: bound.reason,
3960
+ correlationId,
3961
+ });
3962
+ return;
3963
+ }
3617
3964
  // M8B F14 (fix 2): KICK creation before polling — an inbound offer arriving while no
3618
3965
  // receiver exists and none is being created must trigger the ensure itself (the doc
3619
3966
  // comment's "retries on demand" made true), instead of polling a creation nobody
@@ -3682,7 +4029,17 @@ export async function startDaemon(config) {
3682
4029
  counterpartyPubkeyHex: parsed.participantAPubkeyHex,
3683
4030
  genesisPrevRootHex,
3684
4031
  });
3685
- notificationDispatcher.dispatchSessionStateChanged(agentName, parsed.sessionIdHex, "created", parsed.participantAPubkeyHex);
4032
+ dispatchSessionStateChangedWithTelegram(agentName, parsed.sessionIdHex, "created", parsed.participantAPubkeyHex);
4033
+ // M8C-TGDOOR-1: session requests ALWAYS ring (DoD) — no coalescing, unlike message-waiting.
4034
+ void sendTelegramDoorbell(agentName, parsed.sessionIdHex, "session_request", "New session request");
4035
+ // M8C-AWAY-1: an unattended agent auto-acks a fresh inbound session request. Fire-and-forget
4036
+ // (sendAwayResponse never throws) — best-effort, must not delay/block acceptance completion.
4037
+ // ORDER MATTERS: sendAwayResponse's isContact check runs synchronously (before any await) as
4038
+ // soon as it's invoked, so calling addContact on the NEXT line still lets a stranger's very
4039
+ // first contact be judged correctly unknown (M8C-CONTACT-1 D6: "accepting X's request adds X"
4040
+ // — they become known immediately after, for every subsequent interaction).
4041
+ void sendAwayResponse(agentName, parsed.sessionIdHex, "request");
4042
+ sessionNodeManager.addContact(agentName, parsed.participantAPubkeyHex);
3686
4043
  }
3687
4044
  finally {
3688
4045
  inboundInFlight.delete(parsed.sessionIdHex);
@@ -3882,6 +4239,7 @@ export async function startDaemon(config) {
3882
4239
  counterparty_pubkey: e.counterpartyPubkeyHex,
3883
4240
  genesis_prev_root: e.genesisPrevRootHex,
3884
4241
  });
4242
+ reapExpiredInboundSessions(agentName); // M8C-TTL-1: don't hand back a stale expired entry
3885
4243
  const queued = inboundSessionQueues.get(agentName);
3886
4244
  if (queued && queued.length > 0) {
3887
4245
  return toResponse(queued.shift());
@@ -4326,6 +4684,42 @@ export async function startDaemon(config) {
4326
4684
  if (record.status !== "active") {
4327
4685
  return { ok: false, reason: "session_not_active", guidance: `Session is '${record.status}', not active. Content can only be sent on an active session. If it is interrupted, call cello_close_session to seal it.` };
4328
4686
  }
4687
+ // M8C-CURSOR-1: read-before-write gate. current_seq is the tree's highest leaf index
4688
+ // (message_count is kept in sync with leafCount on every append, both directions — DAEMON-004
4689
+ // finding #2), so it reflects EVERY message in the session regardless of which connection sent
4690
+ // or received it. If this connection hasn't read up to current_seq (e.g. a second attended
4691
+ // session on the same agent that hasn't polled since the other connection's last send), refuse
4692
+ // rather than let it send blind — the WhatsApp-group-chat model. Runs BEFORE M9's
4693
+ // governance-decisions parsing below — an access-control gate should short-circuit before any
4694
+ // unrelated prep work for a send that may not even be allowed to proceed.
4695
+ const currentSeq = record.message_count - 1;
4696
+ const lastReadSeq = getConnectionCursor(connectionId, sessionId);
4697
+ if (lastReadSeq < currentSeq) {
4698
+ // M8C-CURSOR-1 (reviewer MEDIUM fix): every sibling rejection in this handler logs; this
4699
+ // gate must too — a security-relevant control-flow path with no observability is a gap.
4700
+ logger.warn("session.send.blocked", { sessionId, currentSeq, lastReadSeq, connectionId });
4701
+ return {
4702
+ ok: false,
4703
+ reason: "session_not_current",
4704
+ current_seq: currentSeq,
4705
+ last_read_seq: lastReadSeq,
4706
+ guidance: `This connection hasn't caught up on session ${sessionId} — ${currentSeq - lastReadSeq} message(s) unread (this may include messages authored by another connection on this same agent). Call cello_get_transcript to read the full history (covers both sent and received), then retry the send.`,
4707
+ };
4708
+ }
4709
+ // M9-FEED-001 §6: the agent's governance re-send decisions, keyed by the flagId a prior `warn`
4710
+ // returned. Optional; validated shape only (the gateway re-scans + applies them, INV-4). A
4711
+ // malformed map is ignored rather than failing the send (the gateway will just re-warn).
4712
+ const rawDecisions = params?.governance_decisions;
4713
+ let governanceDecisions;
4714
+ if (rawDecisions && typeof rawDecisions === "object" && !Array.isArray(rawDecisions)) {
4715
+ const valid = {};
4716
+ for (const [k, v] of Object.entries(rawDecisions)) {
4717
+ if (v === "redact" || v === "allow_once" || v === "allow_always")
4718
+ valid[k] = v;
4719
+ }
4720
+ if (Object.keys(valid).length > 0)
4721
+ governanceDecisions = valid;
4722
+ }
4329
4723
  const correlationId = randomUUID();
4330
4724
  const contentBytes = new TextEncoder().encode(contentStr);
4331
4725
  // CELLO-M7-MSG-001 (AC-013/AC-018/AC-021): enforce the 1 MB application content cap
@@ -4346,17 +4740,74 @@ export async function startDaemon(config) {
4346
4740
  guidance: `This message is ${contentBytes.length} bytes, over the ${MAX_CONTENT_BYTES}-byte (1 MB) per-message content cap. Split it into multiple messages each under the cap, or use the large-object/file transfer path for large payloads (not cello_send). Nothing was sent and the session is still active — retry with smaller content.`,
4347
4741
  };
4348
4742
  }
4349
- const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
4350
- const contentHashHex = Buffer.from(contentHash).toString("hex");
4351
4743
  const recipientPubkey = record.counterparty_pubkey;
4352
- const sendResult = await sessionNodeManager.sendContent(record.agent_name, sessionId, contentBytes, new Uint8Array(contentHash), correlationId);
4744
+ // M9 outbound screening seam (INV-5/SI-001). Screen BEFORE anything reaches the wire. The
4745
+ // gateway verdict drives the four cello_send outcomes (M9-FEED-001): block / warn → NOT sent;
4746
+ // allow → sent as-is; redact → sent in ALTERED form. A configured-but-unreachable gateway fails
4747
+ // closed (block, gateway_unavailable), so a screening outage can never let content out ungated.
4748
+ const outboundVerdict = await securityGateway.screenOutbound(contentBytes, {
4749
+ direction: "outbound",
4750
+ agentName: record.agent_name,
4751
+ sessionId,
4752
+ correlationId,
4753
+ ...(governanceDecisions !== undefined ? { governanceDecisions } : {}),
4754
+ });
4755
+ if (outboundVerdict.disposition === "block") {
4756
+ if (outboundVerdict.reason === GOVERNANCE_TIMEOUT) {
4757
+ logger.error("security.gateway.timeout", { sessionId, reason: outboundVerdict.reason, correlationId });
4758
+ }
4759
+ else if (outboundVerdict.reason === GATEWAY_UNAVAILABLE) {
4760
+ logger.error("security.gateway.unavailable", { direction: "outbound", reason: outboundVerdict.reason, correlationId });
4761
+ }
4762
+ else {
4763
+ logger.info("security.verdict.returned", { disposition: "block", sessionId, reason: outboundVerdict.reason, correlationId });
4764
+ }
4765
+ return {
4766
+ ok: false,
4767
+ reason: outboundVerdict.reason ?? "blocked_by_governance",
4768
+ guidance: outboundVerdict.guidance ??
4769
+ "This message was blocked by the security gateway and was NOT sent. The session is still active.",
4770
+ blocks: (outboundVerdict.events ?? []).filter((e) => e.disposition === "block"),
4771
+ };
4772
+ }
4773
+ if (outboundVerdict.disposition === "warn") {
4774
+ logger.info("security.verdict.returned", { disposition: "warn", sessionId, correlationId });
4775
+ return {
4776
+ ok: false,
4777
+ reason: "governance_warn",
4778
+ guidance: outboundVerdict.guidance ??
4779
+ "This message was held for a governance decision and was NOT sent. Re-send the same content with a " +
4780
+ "governance_decisions map ({flagId: redact | allow_once | allow_always}) to resolve each flagged item.",
4781
+ flags: (outboundVerdict.events ?? []).filter((e) => e.disposition === "warn"),
4782
+ };
4783
+ }
4784
+ // FAIL-CLOSED (code-review MED): a `redact` verdict MUST carry the redacted content. If it ever
4785
+ // arrives without it, sending the original `contentBytes` would leak the pre-redaction draft — the
4786
+ // one place M9 could fail OPEN. Treat it as a block, never an allow-original. (Unreachable today:
4787
+ // the gateway always includes content on redact; this is the defensive floor.)
4788
+ if (outboundVerdict.disposition === "redact" && outboundVerdict.content === undefined) {
4789
+ logger.error("security.verdict.redact_without_content", { sessionId, correlationId });
4790
+ return {
4791
+ ok: false,
4792
+ reason: "redact_without_content",
4793
+ guidance: "The security gateway returned a redact verdict without the redacted content. To avoid " +
4794
+ "leaking the original, nothing was sent. This is a gateway fault — check the gateway logs and retry.",
4795
+ };
4796
+ }
4797
+ // allow or redact → send. On redact the ALTERED bytes are what go on the wire AND what the leaf
4798
+ // hash binds — the transcript records what was actually sent, not the pre-redaction draft.
4799
+ const modified = outboundVerdict.disposition === "redact" && outboundVerdict.content !== undefined;
4800
+ const sendBytes = modified ? new Uint8Array(outboundVerdict.content) : contentBytes;
4801
+ const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(sendBytes).digest();
4802
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
4803
+ const sendResult = await sessionNodeManager.sendContent(record.agent_name, sessionId, sendBytes, new Uint8Array(contentHash), correlationId);
4353
4804
  if (!sendResult.ok) {
4354
4805
  // DB-001 / dead-channel contract: never silently drop, never desync. Preserve
4355
4806
  // the content in the durable retry_queue so it is retried on reconnect, and
4356
4807
  // surface a named, diagnosable failure.
4357
4808
  const nonce = randomUUID();
4358
4809
  try {
4359
- retryQueue.enqueue(sessionId, new TextEncoder().encode(nonce), contentBytes);
4810
+ retryQueue.enqueue(sessionId, new TextEncoder().encode(nonce), sendBytes);
4360
4811
  }
4361
4812
  catch (err) {
4362
4813
  logger.error("session.content.queue.failed", {
@@ -4378,11 +4829,46 @@ export async function startDaemon(config) {
4378
4829
  guidance: "The content could not be delivered over the session stream right now. It has been queued in the durable retry queue and will be retried when the counterparty reconnects. The session remains usable — check cello_list_connections for the counterparty's status.",
4379
4830
  };
4380
4831
  }
4381
- // Delivered append the message leaf to the daemon-owned tree (advances root).
4832
+ // Delivered directly OR dispatched to relay (DOD-LEAVEMSG-1) — either way the content is now
4833
+ // part of the daemon-owned tree: the relay witness (R1) already assigned it a sequence before
4834
+ // direct delivery was even attempted, so a parked message occupies the SAME leaf position it
4835
+ // would have taken if delivered live. Append once, for both outcomes.
4382
4836
  const { leafIndex, newRootHex } = sessionNodeManager.appendSessionLeaf(record.agent_name, sessionId, "msg", contentHashHex, correlationId);
4383
4837
  // DOD-LOG-1: persist the readable SENT plaintext to the durable transcript, keyed by the
4384
4838
  // canonical leaf sequence so it joins the committed hash chain (survives restart).
4385
- sessionNodeManager.recordTranscriptMessage(record.agent_name, sessionId, leafIndex, "sent", contentBytes, correlationId);
4839
+ // M9 merge fix: use sendBytes (the ALTERED bytes on a redact verdict), never the pre-redaction
4840
+ // contentBytes — the leaf hash above already binds sendBytes; the transcript must match what
4841
+ // actually went on the wire, not the pre-redaction draft (M9's own stated seam invariant).
4842
+ sessionNodeManager.recordTranscriptMessage(record.agent_name, sessionId, leafIndex, "sent", sendBytes, correlationId);
4843
+ // M8C-CURSOR-1: the sender authored this leaf — advance ITS OWN cursor so it doesn't get
4844
+ // blocked by session_not_current on its own just-sent message.
4845
+ advanceConnectionCursor(connectionId, sessionId, leafIndex);
4846
+ void newRootHex;
4847
+ if (modified) {
4848
+ logger.info("security.verdict.returned", { disposition: "redact", sessionId, sequenceNumber: leafIndex, correlationId });
4849
+ }
4850
+ if (!sendResult.delivered) {
4851
+ // DOD-LEAVEMSG-1 (sender half): direct delivery failed but the sealed, hashed content was
4852
+ // successfully deposited at the relay (pickup_queue) — this is a SUCCESS outcome, not a
4853
+ // failure. The recipient's daemon pulls it via RELAYWAKE on next reconnect. Reporting this
4854
+ // as ok:false (the pre-LEAVEMSG-1 behavior) misrepresented an in-flight message as lost.
4855
+ logger.info("session.content.dispatched_to_relay", {
4856
+ sessionId,
4857
+ recipientPubkey,
4858
+ contentHashHex,
4859
+ sequenceNumber: leafIndex,
4860
+ correlationId,
4861
+ });
4862
+ return {
4863
+ ok: true,
4864
+ sequence_number: leafIndex,
4865
+ delivered: false,
4866
+ reason: "dispatched_to_relay",
4867
+ modified,
4868
+ guidance: "The counterparty is not directly reachable right now, so this message was sealed and dispatched to relay store-and-forward. It will be delivered the next time the counterparty's daemon reconnects — no further action is needed.",
4869
+ ...(modified ? { transformations: (outboundVerdict.events ?? []).filter((e) => e.disposition === "redact") } : {}),
4870
+ };
4871
+ }
4386
4872
  logger.info("session.content.sent", {
4387
4873
  sessionId,
4388
4874
  recipientPubkey,
@@ -4390,8 +4876,14 @@ export async function startDaemon(config) {
4390
4876
  sequenceNumber: leafIndex,
4391
4877
  correlationId,
4392
4878
  });
4393
- void newRootHex;
4394
- return { ok: true, sequence_number: leafIndex };
4879
+ return {
4880
+ ok: true,
4881
+ sequence_number: leafIndex,
4882
+ delivered: true,
4883
+ modified,
4884
+ // On a redact, tell the agent exactly what was transformed (the §6 sender-side surface).
4885
+ ...(modified ? { transformations: (outboundVerdict.events ?? []).filter((e) => e.disposition === "redact") } : {}),
4886
+ };
4395
4887
  });
4396
4888
  // ─── CELLO-M7-DAEMON-004 / F1-a: cello_receive (BLOCKING, session-scoped) ────────
4397
4889
  // F1-a fix: the daemon port had dropped the blocking receive (the handler was a
@@ -4418,6 +4910,36 @@ export async function startDaemon(config) {
4418
4910
  if (record.agent_name !== agentName) {
4419
4911
  return { ok: false, reason: "session_not_owned", guidance: "This session belongs to a different agent. Call cello_use_agent to switch to the agent that owns it, then retry." };
4420
4912
  }
4913
+ // M8C-SINCESEQ-1: stateless catch-up. When since_seq is provided, return a BATCH of received
4914
+ // transcript messages with sequence > since_seq (durable transcript, not the ephemeral buffer —
4915
+ // so concurrent arrivals don't shift what a given since_seq returns; no replay race). Replaces
4916
+ // the cello_get_transcript workaround for away-then-return. Received-direction only (the messages
4917
+ // you'd have gotten live). Advances the read watermark (delivery marks read — clears INBOX
4918
+ // unread). A distinct early branch: the plain (no since_seq) receive is entirely unchanged.
4919
+ const rawSince = params?.since_seq;
4920
+ if (typeof rawSince === "number" && Number.isFinite(rawSince)) {
4921
+ const sinceSeq = rawSince;
4922
+ const from = record.counterparty_pubkey;
4923
+ const { messages } = sessionNodeManager.readTranscript(agentName, sessionId);
4924
+ const received = messages.filter((m) => m.direction === "received" && m.sequence > sinceSeq);
4925
+ if (received.length > 0) {
4926
+ // readTranscript is ordered by sequence ASC → the last is the max.
4927
+ const maxSeq = received[received.length - 1].sequence;
4928
+ sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, maxSeq);
4929
+ clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
4930
+ }
4931
+ // M8C-CURSOR-1 (reviewer HIGH fix): only advance through the CONTIGUOUS run this batch
4932
+ // actually delivered — if a sent leaf from another local connection sits in a gap, this
4933
+ // correctly refuses to advance past it (cello_get_transcript is still required to catch up).
4934
+ safeCursorAdvance(connectionId, sessionId, new Set(received.map((m) => m.sequence)));
4935
+ logger.info("session.receive.since_seq", { sessionId, agentName, since_seq: sinceSeq, count: received.length });
4936
+ return {
4937
+ ok: true,
4938
+ since_seq: sinceSeq,
4939
+ count: received.length,
4940
+ messages: received.map((m) => ({ sequence: m.sequence, content: m.text, from })),
4941
+ };
4942
+ }
4421
4943
  const rawTimeout = params?.timeout_ms;
4422
4944
  const timeoutMs = typeof rawTimeout === "number" && Number.isFinite(rawTimeout) && rawTimeout >= 0
4423
4945
  ? rawTimeout
@@ -4430,6 +4952,11 @@ export async function startDaemon(config) {
4430
4952
  // M8C-INBOX-1 (N3): delivery marks read — advance the persisted read watermark so this
4431
4953
  // message no longer counts as unread in cello_check_notifications. Monotonic (never lowers).
4432
4954
  sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, entry.sequenceNumber);
4955
+ clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
4956
+ // M8C-CURSOR-1 (reviewer HIGH fix): a single delivered message only proves THIS sequence
4957
+ // was read — safeCursorAdvance refuses to vault past a gap (e.g. an unread sent leaf from
4958
+ // another local connection) even though this specific sequence number is now known.
4959
+ safeCursorAdvance(connectionId, sessionId, new Set([entry.sequenceNumber]));
4433
4960
  return {
4434
4961
  ok: true,
4435
4962
  content: Buffer.from(entry.contentHex, "hex").toString("utf8"),
@@ -4503,19 +5030,91 @@ export async function startDaemon(config) {
4503
5030
  agentNames = [current];
4504
5031
  }
4505
5032
  const agents = agentNames.map((agent) => {
5033
+ reapExpiredInboundSessions(agent); // M8C-TTL-1: expired ones surface below, not as "pending"
4506
5034
  const pending = (inboundSessionQueues.get(agent) ?? []).map((e) => ({
4507
5035
  session_id: e.sessionIdHex,
4508
5036
  from: e.counterpartyPubkeyHex,
4509
5037
  }));
5038
+ // M8C-TTL-1: expired requests stay VISIBLE (not silently dropped) — the operator can see
5039
+ // what they missed rather than a request just vanishing from the pending list.
5040
+ const expired = (expiredSessionRequests.get(agent) ?? []).map((e) => ({
5041
+ session_id: e.sessionIdHex,
5042
+ from: e.counterpartyPubkeyHex,
5043
+ expired_at: e.expiredAt,
5044
+ }));
4510
5045
  const unread = sessionNodeManager.getUnreadSummary(agent);
4511
5046
  const total_unread = unread.reduce((sum, u) => sum + u.unread_count, 0);
4512
- return { agent, pending_session_requests: pending, unread, total_unread };
5047
+ return { agent, pending_session_requests: pending, expired_session_requests: expired, unread, total_unread };
4513
5048
  });
4514
5049
  const totalUnread = agents.reduce((sum, a) => sum + a.total_unread, 0);
4515
5050
  const totalPending = agents.reduce((sum, a) => sum + a.pending_session_requests.length, 0);
4516
- logger.info("inbox.checked", { connectionId, scope, agentCount: agents.length, totalUnread, totalPending });
5051
+ // M8C-TTL-1 (reviewer finding, D19): surface expired-log size so unbounded growth (were the
5052
+ // cap ever removed or misconfigured) would be visible here, not just in an internal Map.
5053
+ const totalExpired = agents.reduce((sum, a) => sum + a.expired_session_requests.length, 0);
5054
+ logger.info("inbox.checked", { connectionId, scope, agentCount: agents.length, totalUnread, totalPending, totalExpired });
4517
5055
  return { ok: true, scope, agents };
4518
5056
  });
5057
+ // M8C-CONTACT-1: cello contact add/remove/list [--agent <name>]. All three resolve the target
5058
+ // agent the same way — an explicit params.agent, else this connection's current/sole-online
5059
+ // agent (F18) — so a CLI/AI operator gets the same no_current_agent guidance INBOX already uses.
5060
+ function resolveContactAgent(connState, params) {
5061
+ const explicit = typeof params?.agent === "string" ? params.agent : undefined;
5062
+ if (explicit)
5063
+ return { ok: true, agent: explicit };
5064
+ const current = resolveCurrentAgent(connState);
5065
+ if (!current) {
5066
+ return {
5067
+ ok: false,
5068
+ reason: "no_current_agent",
5069
+ guidance: "No current agent for this connection. Pass --agent <name>, or call cello_use_agent to select one first.",
5070
+ };
5071
+ }
5072
+ return { ok: true, agent: current };
5073
+ }
5074
+ handlers.set("cello_contact_add", async (params, connectionId) => {
5075
+ const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5076
+ if (!pubkey) {
5077
+ return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to add." };
5078
+ }
5079
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5080
+ if (!resolved.ok)
5081
+ return resolved;
5082
+ sessionNodeManager.addContact(resolved.agent, pubkey);
5083
+ logger.info("contact.added", { agent: resolved.agent, pubkey });
5084
+ return { ok: true, agent: resolved.agent, pubkey };
5085
+ });
5086
+ handlers.set("cello_contact_remove", async (params, connectionId) => {
5087
+ const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5088
+ if (!pubkey) {
5089
+ return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to remove." };
5090
+ }
5091
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5092
+ if (!resolved.ok)
5093
+ return resolved;
5094
+ const removed = sessionNodeManager.removeContact(resolved.agent, pubkey);
5095
+ logger.info("contact.removed", { agent: resolved.agent, pubkey, removed });
5096
+ return { ok: true, agent: resolved.agent, pubkey, removed };
5097
+ });
5098
+ handlers.set("cello_contact_list", async (params, connectionId) => {
5099
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5100
+ if (!resolved.ok)
5101
+ return resolved;
5102
+ const contacts = sessionNodeManager.listContacts(resolved.agent);
5103
+ return { ok: true, agent: resolved.agent, contacts };
5104
+ });
5105
+ // M8C-TGDOOR-1: cello_telegram_set_token — persist the daemon-wide bot token + allowlisted
5106
+ // operator chat ID, then start the poller immediately (no restart needed).
5107
+ handlers.set("cello_telegram_set_token", async (params, _connectionId) => {
5108
+ const botToken = typeof params?.bot_token === "string" ? params.bot_token : undefined;
5109
+ const chatId = typeof params?.allowlisted_chat_id === "string" ? params.allowlisted_chat_id : undefined;
5110
+ if (!botToken || !chatId) {
5111
+ return { ok: false, reason: "missing_params", guidance: "Provide 'bot_token' and 'allowlisted_chat_id'." };
5112
+ }
5113
+ sessionNodeManager.setTelegramSettings(botToken, chatId);
5114
+ startTelegramPollerIfConfigured(); // always bumps the generation — restarts even if already running
5115
+ logger.info("telegram.settings.updated", {});
5116
+ return { ok: true };
5117
+ });
4519
5118
  let shutdownPromise = null;
4520
5119
  handlers.set("shutdown", async (_params, _connectionId) => {
4521
5120
  if (!shutdownPromise) {
@@ -4549,12 +5148,23 @@ export async function startDaemon(config) {
4549
5148
  // used because the dispatcher is constructed AFTER the SessionNodeManager
4550
5149
  // (it depends on the IPC server), so constructor injection would be circular.
4551
5150
  sessionNodeManager.setOnSessionStateChanged((agentName, sessionId, state, counterpartyPubkey) => {
4552
- notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey);
5151
+ dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey);
5152
+ });
5153
+ // M8C-MSGWAKE-1 (channel stage 2): per-message wake — a verified inbound message fires a
5154
+ // content-free `cello_message` doorbell to the current-agent connection(s). The shim's generic
5155
+ // bridge (WAKE) forwards it to a live --channels session as notifications/claude/channel.
5156
+ sessionNodeManager.setOnContentArrived((agentName, sessionId, senderPubkey) => {
5157
+ notificationDispatcher.dispatchCelloMessage(agentName, sessionId, senderPubkey);
5158
+ // M8C-AWAY-1: an unattended agent auto-acks an inbound message on an existing session.
5159
+ void sendAwayResponse(agentName, sessionId, "message");
5160
+ // M8C-TGDOOR-1: message-waiting — coalesced (ring-once-until-read) inside sendTelegramDoorbell.
5161
+ void sendTelegramDoorbell(agentName, sessionId, "message_waiting", "New message waiting");
4553
5162
  });
4554
5163
  // MCP-001: Clean up per-connection state when a connection disconnects
4555
5164
  // MCP-002: Also unregister from notification dispatcher
4556
5165
  ipcServer.onDisconnect((connectionId) => {
4557
5166
  perConnectionState.delete(connectionId);
5167
+ connectionCursors.delete(connectionId); // M8C-CURSOR-1: cursor is connection-scoped, dies with it
4558
5168
  notificationDispatcher.unregisterConnection(connectionId);
4559
5169
  // Seam 2 (review H2): evict any cello_await_session waiters owned by this connection.
4560
5170
  // Otherwise enqueueInboundSession would hand the next inbound session to a closed
@@ -4592,6 +5202,9 @@ export async function startDaemon(config) {
4592
5202
  // lifecycle is the daemon's, not any agent connection's — so it polls even with zero agents.
4593
5203
  // Graceful shutdown
4594
5204
  async function stop(reason) {
5205
+ // M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
5206
+ // the generation so the running loop's while-condition fails on its next check.
5207
+ telegramPollerGeneration += 1;
4595
5208
  // CELLO-M7-CONN-001: stop the HTTP manifest poll (sets the stopped flag so an in-flight
4596
5209
  // tick cannot re-arm, and cancels the scheduler). Belt-and-suspenders cancel for the
4597
5210
  // no-poll case (scheduler present but poll not started).
@@ -4618,6 +5231,9 @@ export async function startDaemon(config) {
4618
5231
  function getAutoNatService() {
4619
5232
  return autoNatService;
4620
5233
  }
5234
+ // M8C-TGDOOR-1: cold-capable — start the poller if a token was already configured from a
5235
+ // prior run, without waiting for any agent to come online or any client to attach.
5236
+ startTelegramPollerIfConfigured();
4621
5237
  return { stop, getStatus, getSessionNodeManager, getDirectoryNode, getTransportSelector, getAutoNatService };
4622
5238
  }
4623
5239
  //# sourceMappingURL=daemon.js.map