@cello-protocol/daemon 0.0.32 → 0.0.34

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.
@@ -1135,8 +1391,13 @@ export async function startDaemon(config) {
1135
1391
  sessionNodeManager.setContentParkHook(async ({ sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor }) => {
1136
1392
  const node = sessionNodeManager.getStandingReceiverNode();
1137
1393
  if (!node) {
1138
- logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason: "standing_receiver_unavailable" });
1139
- return;
1394
+ const reason = "standing_receiver_unavailable";
1395
+ logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason });
1396
+ // DOD-LEAVEMSG-1 (reviewer HIGH fix): return the typed failure, never resolve as if this
1397
+ // were a success — #parkContent's caller (sendContent) shapes a live "dispatched to relay"
1398
+ // response from this, and a silently-resolved void here would report a message as safely
1399
+ // parked when nothing was ever deposited.
1400
+ return { ok: false, reason };
1140
1401
  }
1141
1402
  const recipientPubkey = Buffer.from(recipientPubkeyHex, "hex");
1142
1403
  // DOD-MSG-4 (2b): seal the ORDERING ENVELOPE (content + the relay's signed Structure2), not bare
@@ -1151,10 +1412,10 @@ export async function startDaemon(config) {
1151
1412
  });
1152
1413
  if (res.ok) {
1153
1414
  logger.info("content.park.deposited", { sessionId, contentHash: contentHashHex, recipientPubkey: recipientPubkeyHex.slice(0, 16) });
1415
+ return { ok: true };
1154
1416
  }
1155
- else {
1156
- logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason: res.reason });
1157
- }
1417
+ logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason: res.reason });
1418
+ return { ok: false, reason: res.reason ?? "relay_deposit_failed" };
1158
1419
  });
1159
1420
  // CELLO-M7-MSG-001 (AC-004/AC-005, D-d): startup flush of locally-persisted un-acked
1160
1421
  // content (the crash backstop). Runs HERE — before the IPC socket opens, consistent
@@ -1705,6 +1966,12 @@ export async function startDaemon(config) {
1705
1966
  }
1706
1967
  const fromAgent = connState.currentAgent;
1707
1968
  connState.currentAgent = name;
1969
+ // M8C-AWAY-1: this agent just became attended — clear its away-ack dedup entries so the NEXT
1970
+ // away period (after this attended stretch ends) gets a fresh ack instead of staying silent.
1971
+ for (const key of Array.from(awayAckSent)) {
1972
+ if (key.startsWith(`${name}:`))
1973
+ awayAckSent.delete(key);
1974
+ }
1708
1975
  // MCP-002: Update dispatcher's routing table and send notification to this connection only
1709
1976
  notificationDispatcher.setCurrentAgent(connectionId, name);
1710
1977
  notificationDispatcher.dispatchAgentCurrentChanged(connectionId, fromAgent, name);
@@ -2577,6 +2844,9 @@ export async function startDaemon(config) {
2577
2844
  });
2578
2845
  }
2579
2846
  }
2847
+ // M8C-CONTACT-1 (D6): "initiating a session to X adds X" — pin at the pubkey the negotiator
2848
+ // actually used (not re-resolved later).
2849
+ sessionNodeManager.addContact(agentName, counterpartyPubkey);
2580
2850
  // AC-007: the session is usable immediately upon (relay) connection — the dcutr
2581
2851
  // upgrade runs in the background and is intentionally NOT awaited here.
2582
2852
  return { ok: true, sessionId, transportMode: result.mode, correlationId };
@@ -2965,6 +3235,14 @@ export async function startDaemon(config) {
2965
3235
  const { messages, undecryptable } = sessionNodeManager.readTranscript(connState.currentAgent, sessionId);
2966
3236
  // undecryptable > 0 means some rows failed GCM auth (tamper / wrong key) — surfaced, not hidden,
2967
3237
  // so the reader can tell a real gap from an empty transcript.
3238
+ // M8C-CURSOR-1: this is the ONLY reader that covers BOTH directions (sent + received), so it's
3239
+ // the correct general catch-up path — e.g. a second attended connection on the same agent
3240
+ // catching up on a message a DIFFERENT local connection sent (since_seq is received-only and
3241
+ // would never surface it). Route through safeCursorAdvance rather than trusting the raw max —
3242
+ // reviewer HIGH finding (aa5928e2/a9099571): recordTranscriptMessage swallows a DB write
3243
+ // failure without rolling back the tree's leaf count, so a hole in `messages` is possible in
3244
+ // principle; the contiguous-run walk refuses to vault the cursor past such a hole even here.
3245
+ safeCursorAdvance(connectionId, sessionId, new Set(messages.map((m) => m.sequence)));
2968
3246
  return { ok: true, session_id: sessionId, messages, undecryptable };
2969
3247
  });
2970
3248
  // cello_list_sessions: the discovery surface — every persisted session for the
@@ -3183,13 +3461,25 @@ export async function startDaemon(config) {
3183
3461
  if (env.structure1Cbor && env.structure2Cbor) {
3184
3462
  sessionNodeManager.recordOrderingRecord(recipientAgent.name, e.sessionIdHex, env.structure1Cbor, env.structure2Cbor, contentHashBytes);
3185
3463
  }
3186
- const ingest = sessionNodeManager.ingestReceivedContent(recipientAgent.name, e.sessionIdHex, env.content, contentHashBytes);
3464
+ const ingest = await sessionNodeManager.ingestReceivedContent(recipientAgent.name, e.sessionIdHex, env.content, contentHashBytes);
3187
3465
  if (ingest.ok && ingest.held) {
3188
3466
  // DOD-MSG-4 (review finding #4): a held entry is NOT yet an appended leaf — its sequence is
3189
3467
  // the FUTURE canonical index, not a completed recovery. Do not count it as recovered; log it
3190
3468
  // distinctly so the tally reflects leaves actually written, not content still queued in memory.
3191
3469
  logger.info("content.recover.held", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, canonicalSeq: ingest.sequenceNumber });
3192
3470
  }
3471
+ else if (ingest.ok && ingest.screenedOut) {
3472
+ // M9 (code-review LOW-3): a terminal-screened recovered entry IS durably leafed (so it must be
3473
+ // confirm-deleted, below) but was NEVER delivered to the agent — do not count it as a delivered
3474
+ // recovery, and log it distinctly so observability separates "delivered" from "leafed-but-screened".
3475
+ logger.info("content.recover.screened_out", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, sequenceNumber: ingest.sequenceNumber });
3476
+ try {
3477
+ await client.confirm(node, Buffer.from(recipientPubkey, "hex"), contentHashBytes, kp);
3478
+ }
3479
+ catch (err) {
3480
+ logger.warn("content.recover.confirm.failed", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, error: err instanceof Error ? err.message : String(err) });
3481
+ }
3482
+ }
3193
3483
  else if (ingest.ok) {
3194
3484
  // DOD-MSG-4 (review #3): count leaves ACTUALLY written — the directly-ingested leaf PLUS any
3195
3485
  // held out-of-order entries this ingest unblocked (appendedCount), not just 1.
@@ -3284,13 +3574,13 @@ export async function startDaemon(config) {
3284
3574
  const sessionPeerId = params?.sessionPeerId ?? "";
3285
3575
  const correlationId = params?.correlationId ?? "";
3286
3576
  logger.info("session.node.created", { sessionId, agentName, sessionPeerId, correlationId });
3287
- notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, "created", counterpartyPubkey);
3577
+ dispatchSessionStateChangedWithTelegram(agentName, sessionId, "created", counterpartyPubkey);
3288
3578
  }
3289
3579
  else if (type === "destroyed") {
3290
3580
  const state = params?.state ?? "interrupted";
3291
3581
  const reason = params?.reason ?? state;
3292
3582
  logger.info("session.node.destroyed", { sessionId, agentName, reason });
3293
- notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey);
3583
+ dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey);
3294
3584
  }
3295
3585
  return { ok: true };
3296
3586
  });
@@ -3305,6 +3595,15 @@ export async function startDaemon(config) {
3305
3595
  return { error: "missing_params", guidance: "Provide agentName and sessionId." };
3306
3596
  }
3307
3597
  enqueueInboundSession(agentName, { sessionIdHex, counterpartyPubkeyHex, genesisPrevRootHex: "" });
3598
+ // M8C-TTL-1: let a test backdate the just-enqueued entry's timestamp (simulating age) without
3599
+ // waiting real hours or faking global timers — enqueueInboundSession always stamps Date.now().
3600
+ const enqueuedAtOverride = params?.enqueuedAtOverride;
3601
+ if (enqueuedAtOverride !== undefined) {
3602
+ const q = inboundSessionQueues.get(agentName);
3603
+ const entry = q?.[q.length - 1];
3604
+ if (entry)
3605
+ entry.enqueuedAt = enqueuedAtOverride;
3606
+ }
3308
3607
  return { ok: true };
3309
3608
  });
3310
3609
  // M8C-INBOX-1 (reviewer F1): buffer a received message so a test can drive a live cello_receive
@@ -3461,6 +3760,9 @@ export async function startDaemon(config) {
3461
3760
  correlationId,
3462
3761
  });
3463
3762
  }
3763
+ // Expired requests move HERE (from inboundSessionQueues) rather than vanishing — visible via
3764
+ // cello_check_notifications so the operator can see what they missed, not just silence.
3765
+ const expiredSessionRequests = new Map();
3464
3766
  // Per-agent FIFO queue (events accepted while no cello_await_session is blocked) and
3465
3767
  // the per-agent list of blocked waiters. Inbound sessions are addressed to a specific
3466
3768
  // local agent (participant_b), so both are keyed by agent name.
@@ -3486,9 +3788,45 @@ export async function startDaemon(config) {
3486
3788
  return;
3487
3789
  }
3488
3790
  const q = inboundSessionQueues.get(agentName) ?? [];
3489
- q.push(event);
3791
+ q.push({ ...event, enqueuedAt: Date.now() }); // M8C-TTL-1: stamp queue entry time
3490
3792
  inboundSessionQueues.set(agentName, q);
3491
3793
  }
3794
+ // M8C-TTL-1 (reviewer HIGH fix, aed2d71f, D19): expiredSessionRequests is a lasting log, not a
3795
+ // consumed queue (unlike inboundSessionQueues, nothing ever drains it) — a whitelisted CONTACT-1
3796
+ // contact is EXEMPT from ABUSE-1's acceptance bounds ("bounded only by disk"), so they can push
3797
+ // unlimited accepted sessions the operator never claims, each becoming a permanent, unremovable
3798
+ // entry every 24h for the life of the daemon process. Capped the same way STATUS_RESUMABLE_CAP
3799
+ // bounds the interrupted-sessions list — keep only the MOST RECENT N per agent.
3800
+ const EXPIRED_SESSION_REQUESTS_CAP = 20;
3801
+ // M8C-TTL-1: move any queue entries past the TTL into expiredSessionRequests (visible via
3802
+ // INBOX), instead of leaving them to sit forever or vanish silently. Called lazily on every
3803
+ // read of the queue (cello_await_session's immediate-check, cello_check_notifications) rather
3804
+ // than on a timer — no background sweep needed, and it's always correct-as-of-the-read.
3805
+ function reapExpiredInboundSessions(agentName) {
3806
+ const q = inboundSessionQueues.get(agentName);
3807
+ if (!q || q.length === 0)
3808
+ return;
3809
+ const now = Date.now();
3810
+ const live = [];
3811
+ let expiredList = expiredSessionRequests.get(agentName);
3812
+ for (const e of q) {
3813
+ if (e.enqueuedAt !== undefined && now - e.enqueuedAt > INBOUND_SESSION_TTL_MS) {
3814
+ if (!expiredList) {
3815
+ expiredList = [];
3816
+ expiredSessionRequests.set(agentName, expiredList);
3817
+ }
3818
+ expiredList.push({ sessionIdHex: e.sessionIdHex, counterpartyPubkeyHex: e.counterpartyPubkeyHex, expiredAt: now });
3819
+ if (expiredList.length > EXPIRED_SESSION_REQUESTS_CAP) {
3820
+ expiredList.splice(0, expiredList.length - EXPIRED_SESSION_REQUESTS_CAP); // keep newest N
3821
+ }
3822
+ logger.info("session.request.expired", { agentName, sessionId: e.sessionIdHex, enqueuedAt: e.enqueuedAt });
3823
+ }
3824
+ else {
3825
+ live.push(e);
3826
+ }
3827
+ }
3828
+ inboundSessionQueues.set(agentName, live);
3829
+ }
3492
3830
  // CBOR-decoded byte fields arrive as Uint8Array or Buffer; a field may also already be
3493
3831
  // a hex string. Hex strings are lowercased so the case-sensitive agent-pubkey match
3494
3832
  // (agents store lowercase hex) cannot silently miss (review L2).
@@ -3614,6 +3952,20 @@ export async function startDaemon(config) {
3614
3952
  }
3615
3953
  async function acceptInboundAssignment(parsed, agentName, correlationId) {
3616
3954
  try {
3955
+ // M8C-ABUSE-1 (anti-drip-feed / anti-swarm): bound acceptance from unknown (non-contact)
3956
+ // senders — a per-sender cap (many sessions from ONE stranger) and a global cap (many
3957
+ // sessions across ALL strangers combined). Known contacts are exempt ("bounded only by
3958
+ // disk" — DoD). Checked FIRST, before any standing-receiver work, so a refusal is cheap.
3959
+ const bound = sessionNodeManager.checkUnknownSenderAcceptanceBound(agentName, parsed.participantAPubkeyHex);
3960
+ if (!bound.ok) {
3961
+ logger.warn("session.inbound.accept.failed", {
3962
+ sessionId: parsed.sessionIdHex,
3963
+ agentName,
3964
+ reason: bound.reason,
3965
+ correlationId,
3966
+ });
3967
+ return;
3968
+ }
3617
3969
  // M8B F14 (fix 2): KICK creation before polling — an inbound offer arriving while no
3618
3970
  // receiver exists and none is being created must trigger the ensure itself (the doc
3619
3971
  // comment's "retries on demand" made true), instead of polling a creation nobody
@@ -3682,7 +4034,17 @@ export async function startDaemon(config) {
3682
4034
  counterpartyPubkeyHex: parsed.participantAPubkeyHex,
3683
4035
  genesisPrevRootHex,
3684
4036
  });
3685
- notificationDispatcher.dispatchSessionStateChanged(agentName, parsed.sessionIdHex, "created", parsed.participantAPubkeyHex);
4037
+ dispatchSessionStateChangedWithTelegram(agentName, parsed.sessionIdHex, "created", parsed.participantAPubkeyHex);
4038
+ // M8C-TGDOOR-1: session requests ALWAYS ring (DoD) — no coalescing, unlike message-waiting.
4039
+ void sendTelegramDoorbell(agentName, parsed.sessionIdHex, "session_request", "New session request");
4040
+ // M8C-AWAY-1: an unattended agent auto-acks a fresh inbound session request. Fire-and-forget
4041
+ // (sendAwayResponse never throws) — best-effort, must not delay/block acceptance completion.
4042
+ // ORDER MATTERS: sendAwayResponse's isContact check runs synchronously (before any await) as
4043
+ // soon as it's invoked, so calling addContact on the NEXT line still lets a stranger's very
4044
+ // first contact be judged correctly unknown (M8C-CONTACT-1 D6: "accepting X's request adds X"
4045
+ // — they become known immediately after, for every subsequent interaction).
4046
+ void sendAwayResponse(agentName, parsed.sessionIdHex, "request");
4047
+ sessionNodeManager.addContact(agentName, parsed.participantAPubkeyHex);
3686
4048
  }
3687
4049
  finally {
3688
4050
  inboundInFlight.delete(parsed.sessionIdHex);
@@ -3882,6 +4244,7 @@ export async function startDaemon(config) {
3882
4244
  counterparty_pubkey: e.counterpartyPubkeyHex,
3883
4245
  genesis_prev_root: e.genesisPrevRootHex,
3884
4246
  });
4247
+ reapExpiredInboundSessions(agentName); // M8C-TTL-1: don't hand back a stale expired entry
3885
4248
  const queued = inboundSessionQueues.get(agentName);
3886
4249
  if (queued && queued.length > 0) {
3887
4250
  return toResponse(queued.shift());
@@ -4326,6 +4689,42 @@ export async function startDaemon(config) {
4326
4689
  if (record.status !== "active") {
4327
4690
  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
4691
  }
4692
+ // M8C-CURSOR-1: read-before-write gate. current_seq is the tree's highest leaf index
4693
+ // (message_count is kept in sync with leafCount on every append, both directions — DAEMON-004
4694
+ // finding #2), so it reflects EVERY message in the session regardless of which connection sent
4695
+ // or received it. If this connection hasn't read up to current_seq (e.g. a second attended
4696
+ // session on the same agent that hasn't polled since the other connection's last send), refuse
4697
+ // rather than let it send blind — the WhatsApp-group-chat model. Runs BEFORE M9's
4698
+ // governance-decisions parsing below — an access-control gate should short-circuit before any
4699
+ // unrelated prep work for a send that may not even be allowed to proceed.
4700
+ const currentSeq = record.message_count - 1;
4701
+ const lastReadSeq = getConnectionCursor(connectionId, sessionId);
4702
+ if (lastReadSeq < currentSeq) {
4703
+ // M8C-CURSOR-1 (reviewer MEDIUM fix): every sibling rejection in this handler logs; this
4704
+ // gate must too — a security-relevant control-flow path with no observability is a gap.
4705
+ logger.warn("session.send.blocked", { sessionId, currentSeq, lastReadSeq, connectionId });
4706
+ return {
4707
+ ok: false,
4708
+ reason: "session_not_current",
4709
+ current_seq: currentSeq,
4710
+ last_read_seq: lastReadSeq,
4711
+ 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.`,
4712
+ };
4713
+ }
4714
+ // M9-FEED-001 §6: the agent's governance re-send decisions, keyed by the flagId a prior `warn`
4715
+ // returned. Optional; validated shape only (the gateway re-scans + applies them, INV-4). A
4716
+ // malformed map is ignored rather than failing the send (the gateway will just re-warn).
4717
+ const rawDecisions = params?.governance_decisions;
4718
+ let governanceDecisions;
4719
+ if (rawDecisions && typeof rawDecisions === "object" && !Array.isArray(rawDecisions)) {
4720
+ const valid = {};
4721
+ for (const [k, v] of Object.entries(rawDecisions)) {
4722
+ if (v === "redact" || v === "allow_once" || v === "allow_always")
4723
+ valid[k] = v;
4724
+ }
4725
+ if (Object.keys(valid).length > 0)
4726
+ governanceDecisions = valid;
4727
+ }
4329
4728
  const correlationId = randomUUID();
4330
4729
  const contentBytes = new TextEncoder().encode(contentStr);
4331
4730
  // CELLO-M7-MSG-001 (AC-013/AC-018/AC-021): enforce the 1 MB application content cap
@@ -4346,17 +4745,74 @@ export async function startDaemon(config) {
4346
4745
  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
4746
  };
4348
4747
  }
4349
- const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
4350
- const contentHashHex = Buffer.from(contentHash).toString("hex");
4351
4748
  const recipientPubkey = record.counterparty_pubkey;
4352
- const sendResult = await sessionNodeManager.sendContent(record.agent_name, sessionId, contentBytes, new Uint8Array(contentHash), correlationId);
4749
+ // M9 outbound screening seam (INV-5/SI-001). Screen BEFORE anything reaches the wire. The
4750
+ // gateway verdict drives the four cello_send outcomes (M9-FEED-001): block / warn → NOT sent;
4751
+ // allow → sent as-is; redact → sent in ALTERED form. A configured-but-unreachable gateway fails
4752
+ // closed (block, gateway_unavailable), so a screening outage can never let content out ungated.
4753
+ const outboundVerdict = await securityGateway.screenOutbound(contentBytes, {
4754
+ direction: "outbound",
4755
+ agentName: record.agent_name,
4756
+ sessionId,
4757
+ correlationId,
4758
+ ...(governanceDecisions !== undefined ? { governanceDecisions } : {}),
4759
+ });
4760
+ if (outboundVerdict.disposition === "block") {
4761
+ if (outboundVerdict.reason === GOVERNANCE_TIMEOUT) {
4762
+ logger.error("security.gateway.timeout", { sessionId, reason: outboundVerdict.reason, correlationId });
4763
+ }
4764
+ else if (outboundVerdict.reason === GATEWAY_UNAVAILABLE) {
4765
+ logger.error("security.gateway.unavailable", { direction: "outbound", reason: outboundVerdict.reason, correlationId });
4766
+ }
4767
+ else {
4768
+ logger.info("security.verdict.returned", { disposition: "block", sessionId, reason: outboundVerdict.reason, correlationId });
4769
+ }
4770
+ return {
4771
+ ok: false,
4772
+ reason: outboundVerdict.reason ?? "blocked_by_governance",
4773
+ guidance: outboundVerdict.guidance ??
4774
+ "This message was blocked by the security gateway and was NOT sent. The session is still active.",
4775
+ blocks: (outboundVerdict.events ?? []).filter((e) => e.disposition === "block"),
4776
+ };
4777
+ }
4778
+ if (outboundVerdict.disposition === "warn") {
4779
+ logger.info("security.verdict.returned", { disposition: "warn", sessionId, correlationId });
4780
+ return {
4781
+ ok: false,
4782
+ reason: "governance_warn",
4783
+ guidance: outboundVerdict.guidance ??
4784
+ "This message was held for a governance decision and was NOT sent. Re-send the same content with a " +
4785
+ "governance_decisions map ({flagId: redact | allow_once | allow_always}) to resolve each flagged item.",
4786
+ flags: (outboundVerdict.events ?? []).filter((e) => e.disposition === "warn"),
4787
+ };
4788
+ }
4789
+ // FAIL-CLOSED (code-review MED): a `redact` verdict MUST carry the redacted content. If it ever
4790
+ // arrives without it, sending the original `contentBytes` would leak the pre-redaction draft — the
4791
+ // one place M9 could fail OPEN. Treat it as a block, never an allow-original. (Unreachable today:
4792
+ // the gateway always includes content on redact; this is the defensive floor.)
4793
+ if (outboundVerdict.disposition === "redact" && outboundVerdict.content === undefined) {
4794
+ logger.error("security.verdict.redact_without_content", { sessionId, correlationId });
4795
+ return {
4796
+ ok: false,
4797
+ reason: "redact_without_content",
4798
+ guidance: "The security gateway returned a redact verdict without the redacted content. To avoid " +
4799
+ "leaking the original, nothing was sent. This is a gateway fault — check the gateway logs and retry.",
4800
+ };
4801
+ }
4802
+ // allow or redact → send. On redact the ALTERED bytes are what go on the wire AND what the leaf
4803
+ // hash binds — the transcript records what was actually sent, not the pre-redaction draft.
4804
+ const modified = outboundVerdict.disposition === "redact" && outboundVerdict.content !== undefined;
4805
+ const sendBytes = modified ? new Uint8Array(outboundVerdict.content) : contentBytes;
4806
+ const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(sendBytes).digest();
4807
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
4808
+ const sendResult = await sessionNodeManager.sendContent(record.agent_name, sessionId, sendBytes, new Uint8Array(contentHash), correlationId);
4353
4809
  if (!sendResult.ok) {
4354
4810
  // DB-001 / dead-channel contract: never silently drop, never desync. Preserve
4355
4811
  // the content in the durable retry_queue so it is retried on reconnect, and
4356
4812
  // surface a named, diagnosable failure.
4357
4813
  const nonce = randomUUID();
4358
4814
  try {
4359
- retryQueue.enqueue(sessionId, new TextEncoder().encode(nonce), contentBytes);
4815
+ retryQueue.enqueue(sessionId, new TextEncoder().encode(nonce), sendBytes);
4360
4816
  }
4361
4817
  catch (err) {
4362
4818
  logger.error("session.content.queue.failed", {
@@ -4378,11 +4834,46 @@ export async function startDaemon(config) {
4378
4834
  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
4835
  };
4380
4836
  }
4381
- // Delivered append the message leaf to the daemon-owned tree (advances root).
4837
+ // Delivered directly OR dispatched to relay (DOD-LEAVEMSG-1) — either way the content is now
4838
+ // part of the daemon-owned tree: the relay witness (R1) already assigned it a sequence before
4839
+ // direct delivery was even attempted, so a parked message occupies the SAME leaf position it
4840
+ // would have taken if delivered live. Append once, for both outcomes.
4382
4841
  const { leafIndex, newRootHex } = sessionNodeManager.appendSessionLeaf(record.agent_name, sessionId, "msg", contentHashHex, correlationId);
4383
4842
  // DOD-LOG-1: persist the readable SENT plaintext to the durable transcript, keyed by the
4384
4843
  // canonical leaf sequence so it joins the committed hash chain (survives restart).
4385
- sessionNodeManager.recordTranscriptMessage(record.agent_name, sessionId, leafIndex, "sent", contentBytes, correlationId);
4844
+ // M9 merge fix: use sendBytes (the ALTERED bytes on a redact verdict), never the pre-redaction
4845
+ // contentBytes — the leaf hash above already binds sendBytes; the transcript must match what
4846
+ // actually went on the wire, not the pre-redaction draft (M9's own stated seam invariant).
4847
+ sessionNodeManager.recordTranscriptMessage(record.agent_name, sessionId, leafIndex, "sent", sendBytes, correlationId);
4848
+ // M8C-CURSOR-1: the sender authored this leaf — advance ITS OWN cursor so it doesn't get
4849
+ // blocked by session_not_current on its own just-sent message.
4850
+ advanceConnectionCursor(connectionId, sessionId, leafIndex);
4851
+ void newRootHex;
4852
+ if (modified) {
4853
+ logger.info("security.verdict.returned", { disposition: "redact", sessionId, sequenceNumber: leafIndex, correlationId });
4854
+ }
4855
+ if (!sendResult.delivered) {
4856
+ // DOD-LEAVEMSG-1 (sender half): direct delivery failed but the sealed, hashed content was
4857
+ // successfully deposited at the relay (pickup_queue) — this is a SUCCESS outcome, not a
4858
+ // failure. The recipient's daemon pulls it via RELAYWAKE on next reconnect. Reporting this
4859
+ // as ok:false (the pre-LEAVEMSG-1 behavior) misrepresented an in-flight message as lost.
4860
+ logger.info("session.content.dispatched_to_relay", {
4861
+ sessionId,
4862
+ recipientPubkey,
4863
+ contentHashHex,
4864
+ sequenceNumber: leafIndex,
4865
+ correlationId,
4866
+ });
4867
+ return {
4868
+ ok: true,
4869
+ sequence_number: leafIndex,
4870
+ delivered: false,
4871
+ reason: "dispatched_to_relay",
4872
+ modified,
4873
+ 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.",
4874
+ ...(modified ? { transformations: (outboundVerdict.events ?? []).filter((e) => e.disposition === "redact") } : {}),
4875
+ };
4876
+ }
4386
4877
  logger.info("session.content.sent", {
4387
4878
  sessionId,
4388
4879
  recipientPubkey,
@@ -4390,8 +4881,14 @@ export async function startDaemon(config) {
4390
4881
  sequenceNumber: leafIndex,
4391
4882
  correlationId,
4392
4883
  });
4393
- void newRootHex;
4394
- return { ok: true, sequence_number: leafIndex };
4884
+ return {
4885
+ ok: true,
4886
+ sequence_number: leafIndex,
4887
+ delivered: true,
4888
+ modified,
4889
+ // On a redact, tell the agent exactly what was transformed (the §6 sender-side surface).
4890
+ ...(modified ? { transformations: (outboundVerdict.events ?? []).filter((e) => e.disposition === "redact") } : {}),
4891
+ };
4395
4892
  });
4396
4893
  // ─── CELLO-M7-DAEMON-004 / F1-a: cello_receive (BLOCKING, session-scoped) ────────
4397
4894
  // F1-a fix: the daemon port had dropped the blocking receive (the handler was a
@@ -4432,8 +4929,14 @@ export async function startDaemon(config) {
4432
4929
  const received = messages.filter((m) => m.direction === "received" && m.sequence > sinceSeq);
4433
4930
  if (received.length > 0) {
4434
4931
  // readTranscript is ordered by sequence ASC → the last is the max.
4435
- sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, received[received.length - 1].sequence);
4932
+ const maxSeq = received[received.length - 1].sequence;
4933
+ sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, maxSeq);
4934
+ clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
4436
4935
  }
4936
+ // M8C-CURSOR-1 (reviewer HIGH fix): only advance through the CONTIGUOUS run this batch
4937
+ // actually delivered — if a sent leaf from another local connection sits in a gap, this
4938
+ // correctly refuses to advance past it (cello_get_transcript is still required to catch up).
4939
+ safeCursorAdvance(connectionId, sessionId, new Set(received.map((m) => m.sequence)));
4437
4940
  logger.info("session.receive.since_seq", { sessionId, agentName, since_seq: sinceSeq, count: received.length });
4438
4941
  return {
4439
4942
  ok: true,
@@ -4454,6 +4957,11 @@ export async function startDaemon(config) {
4454
4957
  // M8C-INBOX-1 (N3): delivery marks read — advance the persisted read watermark so this
4455
4958
  // message no longer counts as unread in cello_check_notifications. Monotonic (never lowers).
4456
4959
  sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, entry.sequenceNumber);
4960
+ clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
4961
+ // M8C-CURSOR-1 (reviewer HIGH fix): a single delivered message only proves THIS sequence
4962
+ // was read — safeCursorAdvance refuses to vault past a gap (e.g. an unread sent leaf from
4963
+ // another local connection) even though this specific sequence number is now known.
4964
+ safeCursorAdvance(connectionId, sessionId, new Set([entry.sequenceNumber]));
4457
4965
  return {
4458
4966
  ok: true,
4459
4967
  content: Buffer.from(entry.contentHex, "hex").toString("utf8"),
@@ -4527,19 +5035,91 @@ export async function startDaemon(config) {
4527
5035
  agentNames = [current];
4528
5036
  }
4529
5037
  const agents = agentNames.map((agent) => {
5038
+ reapExpiredInboundSessions(agent); // M8C-TTL-1: expired ones surface below, not as "pending"
4530
5039
  const pending = (inboundSessionQueues.get(agent) ?? []).map((e) => ({
4531
5040
  session_id: e.sessionIdHex,
4532
5041
  from: e.counterpartyPubkeyHex,
4533
5042
  }));
5043
+ // M8C-TTL-1: expired requests stay VISIBLE (not silently dropped) — the operator can see
5044
+ // what they missed rather than a request just vanishing from the pending list.
5045
+ const expired = (expiredSessionRequests.get(agent) ?? []).map((e) => ({
5046
+ session_id: e.sessionIdHex,
5047
+ from: e.counterpartyPubkeyHex,
5048
+ expired_at: e.expiredAt,
5049
+ }));
4534
5050
  const unread = sessionNodeManager.getUnreadSummary(agent);
4535
5051
  const total_unread = unread.reduce((sum, u) => sum + u.unread_count, 0);
4536
- return { agent, pending_session_requests: pending, unread, total_unread };
5052
+ return { agent, pending_session_requests: pending, expired_session_requests: expired, unread, total_unread };
4537
5053
  });
4538
5054
  const totalUnread = agents.reduce((sum, a) => sum + a.total_unread, 0);
4539
5055
  const totalPending = agents.reduce((sum, a) => sum + a.pending_session_requests.length, 0);
4540
- logger.info("inbox.checked", { connectionId, scope, agentCount: agents.length, totalUnread, totalPending });
5056
+ // M8C-TTL-1 (reviewer finding, D19): surface expired-log size so unbounded growth (were the
5057
+ // cap ever removed or misconfigured) would be visible here, not just in an internal Map.
5058
+ const totalExpired = agents.reduce((sum, a) => sum + a.expired_session_requests.length, 0);
5059
+ logger.info("inbox.checked", { connectionId, scope, agentCount: agents.length, totalUnread, totalPending, totalExpired });
4541
5060
  return { ok: true, scope, agents };
4542
5061
  });
5062
+ // M8C-CONTACT-1: cello contact add/remove/list [--agent <name>]. All three resolve the target
5063
+ // agent the same way — an explicit params.agent, else this connection's current/sole-online
5064
+ // agent (F18) — so a CLI/AI operator gets the same no_current_agent guidance INBOX already uses.
5065
+ function resolveContactAgent(connState, params) {
5066
+ const explicit = typeof params?.agent === "string" ? params.agent : undefined;
5067
+ if (explicit)
5068
+ return { ok: true, agent: explicit };
5069
+ const current = resolveCurrentAgent(connState);
5070
+ if (!current) {
5071
+ return {
5072
+ ok: false,
5073
+ reason: "no_current_agent",
5074
+ guidance: "No current agent for this connection. Pass --agent <name>, or call cello_use_agent to select one first.",
5075
+ };
5076
+ }
5077
+ return { ok: true, agent: current };
5078
+ }
5079
+ handlers.set("cello_contact_add", async (params, connectionId) => {
5080
+ const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5081
+ if (!pubkey) {
5082
+ return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to add." };
5083
+ }
5084
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5085
+ if (!resolved.ok)
5086
+ return resolved;
5087
+ sessionNodeManager.addContact(resolved.agent, pubkey);
5088
+ logger.info("contact.added", { agent: resolved.agent, pubkey });
5089
+ return { ok: true, agent: resolved.agent, pubkey };
5090
+ });
5091
+ handlers.set("cello_contact_remove", async (params, connectionId) => {
5092
+ const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5093
+ if (!pubkey) {
5094
+ return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to remove." };
5095
+ }
5096
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5097
+ if (!resolved.ok)
5098
+ return resolved;
5099
+ const removed = sessionNodeManager.removeContact(resolved.agent, pubkey);
5100
+ logger.info("contact.removed", { agent: resolved.agent, pubkey, removed });
5101
+ return { ok: true, agent: resolved.agent, pubkey, removed };
5102
+ });
5103
+ handlers.set("cello_contact_list", async (params, connectionId) => {
5104
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5105
+ if (!resolved.ok)
5106
+ return resolved;
5107
+ const contacts = sessionNodeManager.listContacts(resolved.agent);
5108
+ return { ok: true, agent: resolved.agent, contacts };
5109
+ });
5110
+ // M8C-TGDOOR-1: cello_telegram_set_token — persist the daemon-wide bot token + allowlisted
5111
+ // operator chat ID, then start the poller immediately (no restart needed).
5112
+ handlers.set("cello_telegram_set_token", async (params, _connectionId) => {
5113
+ const botToken = typeof params?.bot_token === "string" ? params.bot_token : undefined;
5114
+ const chatId = typeof params?.allowlisted_chat_id === "string" ? params.allowlisted_chat_id : undefined;
5115
+ if (!botToken || !chatId) {
5116
+ return { ok: false, reason: "missing_params", guidance: "Provide 'bot_token' and 'allowlisted_chat_id'." };
5117
+ }
5118
+ sessionNodeManager.setTelegramSettings(botToken, chatId);
5119
+ startTelegramPollerIfConfigured(); // always bumps the generation — restarts even if already running
5120
+ logger.info("telegram.settings.updated", {});
5121
+ return { ok: true };
5122
+ });
4543
5123
  let shutdownPromise = null;
4544
5124
  handlers.set("shutdown", async (_params, _connectionId) => {
4545
5125
  if (!shutdownPromise) {
@@ -4573,18 +5153,23 @@ export async function startDaemon(config) {
4573
5153
  // used because the dispatcher is constructed AFTER the SessionNodeManager
4574
5154
  // (it depends on the IPC server), so constructor injection would be circular.
4575
5155
  sessionNodeManager.setOnSessionStateChanged((agentName, sessionId, state, counterpartyPubkey) => {
4576
- notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey);
5156
+ dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey);
4577
5157
  });
4578
5158
  // M8C-MSGWAKE-1 (channel stage 2): per-message wake — a verified inbound message fires a
4579
5159
  // content-free `cello_message` doorbell to the current-agent connection(s). The shim's generic
4580
5160
  // bridge (WAKE) forwards it to a live --channels session as notifications/claude/channel.
4581
5161
  sessionNodeManager.setOnContentArrived((agentName, sessionId, senderPubkey) => {
4582
5162
  notificationDispatcher.dispatchCelloMessage(agentName, sessionId, senderPubkey);
5163
+ // M8C-AWAY-1: an unattended agent auto-acks an inbound message on an existing session.
5164
+ void sendAwayResponse(agentName, sessionId, "message");
5165
+ // M8C-TGDOOR-1: message-waiting — coalesced (ring-once-until-read) inside sendTelegramDoorbell.
5166
+ void sendTelegramDoorbell(agentName, sessionId, "message_waiting", "New message waiting");
4583
5167
  });
4584
5168
  // MCP-001: Clean up per-connection state when a connection disconnects
4585
5169
  // MCP-002: Also unregister from notification dispatcher
4586
5170
  ipcServer.onDisconnect((connectionId) => {
4587
5171
  perConnectionState.delete(connectionId);
5172
+ connectionCursors.delete(connectionId); // M8C-CURSOR-1: cursor is connection-scoped, dies with it
4588
5173
  notificationDispatcher.unregisterConnection(connectionId);
4589
5174
  // Seam 2 (review H2): evict any cello_await_session waiters owned by this connection.
4590
5175
  // Otherwise enqueueInboundSession would hand the next inbound session to a closed
@@ -4622,6 +5207,9 @@ export async function startDaemon(config) {
4622
5207
  // lifecycle is the daemon's, not any agent connection's — so it polls even with zero agents.
4623
5208
  // Graceful shutdown
4624
5209
  async function stop(reason) {
5210
+ // M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
5211
+ // the generation so the running loop's while-condition fails on its next check.
5212
+ telegramPollerGeneration += 1;
4625
5213
  // CELLO-M7-CONN-001: stop the HTTP manifest poll (sets the stopped flag so an in-flight
4626
5214
  // tick cannot re-arm, and cancels the scheduler). Belt-and-suspenders cancel for the
4627
5215
  // no-poll case (scheduler present but poll not started).
@@ -4648,6 +5236,9 @@ export async function startDaemon(config) {
4648
5236
  function getAutoNatService() {
4649
5237
  return autoNatService;
4650
5238
  }
5239
+ // M8C-TGDOOR-1: cold-capable — start the poller if a token was already configured from a
5240
+ // prior run, without waiting for any agent to come online or any client to attach.
5241
+ startTelegramPollerIfConfigured();
4651
5242
  return { stop, getStatus, getSessionNodeManager, getDirectoryNode, getTransportSelector, getAutoNatService };
4652
5243
  }
4653
5244
  //# sourceMappingURL=daemon.js.map