@cello-protocol/daemon 0.0.131 → 0.0.133

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.
Files changed (113) hide show
  1. package/dist/content-park.d.ts.map +1 -1
  2. package/dist/content-park.js +29 -11
  3. package/dist/content-park.js.map +1 -1
  4. package/dist/daemon.d.ts +11 -0
  5. package/dist/daemon.d.ts.map +1 -1
  6. package/dist/daemon.js +398 -18
  7. package/dist/daemon.js.map +1 -1
  8. package/dist/document-ack-inbound.d.ts +57 -0
  9. package/dist/document-ack-inbound.d.ts.map +1 -0
  10. package/dist/document-ack-inbound.js +174 -0
  11. package/dist/document-ack-inbound.js.map +1 -0
  12. package/dist/document-control-notifier.d.ts +61 -0
  13. package/dist/document-control-notifier.d.ts.map +1 -0
  14. package/dist/document-control-notifier.js +70 -0
  15. package/dist/document-control-notifier.js.map +1 -0
  16. package/dist/document-delivery-transport.d.ts +94 -0
  17. package/dist/document-delivery-transport.d.ts.map +1 -0
  18. package/dist/document-delivery-transport.js +179 -0
  19. package/dist/document-delivery-transport.js.map +1 -0
  20. package/dist/document-delivery.d.ts +181 -0
  21. package/dist/document-delivery.d.ts.map +1 -0
  22. package/dist/document-delivery.js +289 -0
  23. package/dist/document-delivery.js.map +1 -0
  24. package/dist/document-frame-router.d.ts +210 -0
  25. package/dist/document-frame-router.d.ts.map +1 -0
  26. package/dist/document-frame-router.js +396 -0
  27. package/dist/document-frame-router.js.map +1 -0
  28. package/dist/document-handlers.d.ts +47 -0
  29. package/dist/document-handlers.d.ts.map +1 -0
  30. package/dist/document-handlers.js +657 -0
  31. package/dist/document-handlers.js.map +1 -0
  32. package/dist/document-handshake.d.ts +156 -0
  33. package/dist/document-handshake.d.ts.map +1 -0
  34. package/dist/document-handshake.js +398 -0
  35. package/dist/document-handshake.js.map +1 -0
  36. package/dist/document-inbound.d.ts +91 -0
  37. package/dist/document-inbound.d.ts.map +1 -0
  38. package/dist/document-inbound.js +290 -0
  39. package/dist/document-inbound.js.map +1 -0
  40. package/dist/document-layer.d.ts +137 -0
  41. package/dist/document-layer.d.ts.map +1 -0
  42. package/dist/document-layer.js +255 -0
  43. package/dist/document-layer.js.map +1 -0
  44. package/dist/document-lifecycle.d.ts +125 -0
  45. package/dist/document-lifecycle.d.ts.map +1 -0
  46. package/dist/document-lifecycle.js +433 -0
  47. package/dist/document-lifecycle.js.map +1 -0
  48. package/dist/document-live-docs.d.ts +58 -0
  49. package/dist/document-live-docs.d.ts.map +1 -0
  50. package/dist/document-live-docs.js +126 -0
  51. package/dist/document-live-docs.js.map +1 -0
  52. package/dist/document-notify.d.ts +173 -0
  53. package/dist/document-notify.d.ts.map +1 -0
  54. package/dist/document-notify.js +438 -0
  55. package/dist/document-notify.js.map +1 -0
  56. package/dist/document-publish.d.ts +67 -0
  57. package/dist/document-publish.d.ts.map +1 -0
  58. package/dist/document-publish.js +149 -0
  59. package/dist/document-publish.js.map +1 -0
  60. package/dist/document-reachability.d.ts +42 -0
  61. package/dist/document-reachability.d.ts.map +1 -0
  62. package/dist/document-reachability.js +80 -0
  63. package/dist/document-reachability.js.map +1 -0
  64. package/dist/document-rejection.d.ts +240 -0
  65. package/dist/document-rejection.d.ts.map +1 -0
  66. package/dist/document-rejection.js +407 -0
  67. package/dist/document-rejection.js.map +1 -0
  68. package/dist/document-store.d.ts +154 -8
  69. package/dist/document-store.d.ts.map +1 -1
  70. package/dist/document-store.js +462 -4
  71. package/dist/document-store.js.map +1 -1
  72. package/dist/document-write-path.d.ts.map +1 -1
  73. package/dist/document-write-path.js +10 -43
  74. package/dist/document-write-path.js.map +1 -1
  75. package/dist/inbound-sessions.d.ts +25 -0
  76. package/dist/inbound-sessions.d.ts.map +1 -1
  77. package/dist/inbound-sessions.js +60 -0
  78. package/dist/inbound-sessions.js.map +1 -1
  79. package/dist/initiate-session-handler.d.ts +24 -1
  80. package/dist/initiate-session-handler.d.ts.map +1 -1
  81. package/dist/initiate-session-handler.js +35 -9
  82. package/dist/initiate-session-handler.js.map +1 -1
  83. package/dist/ipc-server.d.ts +11 -1
  84. package/dist/ipc-server.d.ts.map +1 -1
  85. package/dist/ipc-server.js +7 -1
  86. package/dist/ipc-server.js.map +1 -1
  87. package/dist/line-lcs.d.ts +51 -0
  88. package/dist/line-lcs.d.ts.map +1 -0
  89. package/dist/line-lcs.js +71 -0
  90. package/dist/line-lcs.js.map +1 -0
  91. package/dist/notification-handlers.d.ts +2 -1
  92. package/dist/notification-handlers.d.ts.map +1 -1
  93. package/dist/notification-handlers.js +11 -2
  94. package/dist/notification-handlers.js.map +1 -1
  95. package/dist/outbound-sessions.d.ts +2 -0
  96. package/dist/outbound-sessions.d.ts.map +1 -1
  97. package/dist/outbound-sessions.js +23 -2
  98. package/dist/outbound-sessions.js.map +1 -1
  99. package/dist/session-content-handlers.d.ts.map +1 -1
  100. package/dist/session-content-handlers.js +3 -2
  101. package/dist/session-content-handlers.js.map +1 -1
  102. package/dist/session-node-manager.d.ts +15 -0
  103. package/dist/session-node-manager.d.ts.map +1 -1
  104. package/dist/session-node-manager.js +188 -9
  105. package/dist/session-node-manager.js.map +1 -1
  106. package/dist/vocabulary.d.ts.map +1 -1
  107. package/dist/vocabulary.js +16 -0
  108. package/dist/vocabulary.js.map +1 -1
  109. package/dist/wire-content-hash.d.ts +27 -0
  110. package/dist/wire-content-hash.d.ts.map +1 -0
  111. package/dist/wire-content-hash.js +37 -0
  112. package/dist/wire-content-hash.js.map +1 -0
  113. package/package.json +5 -5
package/dist/daemon.js CHANGED
@@ -26,7 +26,7 @@
26
26
  * f. Exit 0
27
27
  */
28
28
  import { mkdir } from "node:fs/promises";
29
- import { randomUUID, createHash } from "node:crypto";
29
+ import { randomUUID } from "node:crypto";
30
30
  import { dirname, join } from "node:path";
31
31
  import { loadAgents } from "./agent-loader.js";
32
32
  import { acquireLock, removeLockIfOwned } from "./lock-file.js";
@@ -65,6 +65,14 @@ import { registerContactHandlers } from "./contact-handlers.js";
65
65
  import { createSealCoordinator } from "./seal-coordinator.js";
66
66
  import { createTelegramDoorbell } from "./telegram-doorbell.js";
67
67
  import { registerSessionContentHandlers } from "./session-content-handlers.js";
68
+ import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
69
+ import { registerDocumentHandlers } from "./document-handlers.js";
70
+ import { createDocumentControlNotifier } from "./document-control-notifier.js";
71
+ import { wireContentHash } from "./wire-content-hash.js";
72
+ import { DocumentPublish } from "./document-publish.js";
73
+ import { createDocumentDeliveryTransport } from "./document-delivery-transport.js";
74
+ import { DocumentDelivery } from "./document-delivery.js";
75
+ import { encodeDocumentUpdateEnvelope, DOCUMENT_UPDATE_ENCODING_V1 } from "@cello-protocol/protocol-types";
68
76
  import { createSealFlows } from "./seal-flows.js";
69
77
  import { registerCloseSessionHandler } from "./close-session-handler.js";
70
78
  import { createInboundSessions } from "./inbound-sessions.js";
@@ -910,7 +918,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
910
918
  // is anchored on (a counterparty daemon's end-anchored detector must see this close).
911
919
  const rejectText = "This inbox only accepts one message per visit. Closing. [[WRAP]]";
912
920
  const rejectBytes = new TextEncoder().encode(rejectText);
913
- const rejectHash = createHash("sha256").update(new Uint8Array([0x00])).update(rejectBytes).digest();
921
+ const rejectHash = wireContentHash(rejectBytes);
914
922
  // Best-effort: a send failure still triggers the seal — we are closing regardless.
915
923
  const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(rejectHash), randomUUID());
916
924
  // M12-P13: commit the leaf when the rejection went out OR when it is durably queued —
@@ -1078,7 +1086,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1078
1086
  const contentBytes = awayVerdict.disposition === "redact" && awayVerdict.content !== undefined
1079
1087
  ? new Uint8Array(awayVerdict.content)
1080
1088
  : draftBytes;
1081
- const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
1089
+ const contentHash = wireContentHash(contentBytes);
1082
1090
  const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
1083
1091
  if (!sendResult.ok && !sendResult.durable) {
1084
1092
  // Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
@@ -1204,7 +1212,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
1204
1212
  new LocalAutoNatStub();
1205
1213
  // The OUTBOUND session path (outbound-sessions.ts): discovery, the session request, and cross-node
1206
1214
  // setup via a transient VISITING connection to the counterparty's home node.
1207
- const { openVisitingConnection, crossNodeBrokerBySession, resolvedSessionNegotiator } = createOutboundSessions({
1215
+ const { openVisitingConnection, crossNodeBrokerBySession, resolvedSessionNegotiator, runDiscoveryLookup } = createOutboundSessions({
1208
1216
  logger,
1209
1217
  sessionNodeManager,
1210
1218
  getKeyProvider: (agentName) => keyProviders.get(agentName),
@@ -1545,11 +1553,13 @@ async function startDaemonHoldingLock(config, singletonLock) {
1545
1553
  // down below the module. Construction is synchronous and its deps are all ready — the handlers
1546
1554
  // it registers are fine this early because the handler map already exists.
1547
1555
  // Seam 2: inbound session establishment — the counterparty side (inbound-sessions.ts).
1548
- const { registerHandlers: registerInboundSessionHandlers, wirePerAgentSessionInbound, handleTrustSignalPickup, enqueueInboundSession, reapExpiredInboundSessions, inboundSessionQueues, inboundSessionWaiters, expiredSessionRequests, offeredMonikers, offerKey, } = createInboundSessions({
1556
+ const { registerHandlers: registerInboundSessionHandlers, wirePerAgentSessionInbound, handleTrustSignalPickup, enqueueInboundSession, reapExpiredInboundSessions, inboundSessionQueues, inboundSessionWaiters, expiredSessionRequests, refusedSessionRequests, offeredMonikers, offerKey, } = createInboundSessions({
1549
1557
  logger,
1550
1558
  sessionNodeManager,
1551
1559
  agents,
1552
1560
  isExplicitlyOffline: (agentName) => explicitlyOfflineAgents.has(agentName),
1561
+ // M12-P18: lets the responder send a refusal reason back to a TRUSTED sender.
1562
+ sendOver,
1553
1563
  getConnState: (connectionId) => perConnectionState.get(connectionId),
1554
1564
  resolveCurrentAgent,
1555
1565
  NO_CURRENT_AGENT_RESPONSE,
@@ -2723,7 +2733,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
2723
2733
  }
2724
2734
  // cello_initiate_session (initiate-session-handler.ts). The relay witness is BEST-EFFORT: a
2725
2735
  // session with no relay still runs on the direct content path. Degraded, never blocked.
2726
- registerInitiateSessionHandler({
2736
+ const { openSessionFor } = registerInitiateSessionHandler({
2727
2737
  handlers,
2728
2738
  logger,
2729
2739
  sessionNodeManager,
@@ -3012,6 +3022,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
3012
3022
  reapExpiredInboundSessions,
3013
3023
  inboundSessionQueues,
3014
3024
  expiredSessionRequests,
3025
+ refusedSessionRequests,
3015
3026
  });
3016
3027
  // The address book — contacts, tiers, monikers, settings, the telegram token. Ten handlers, now
3017
3028
  // in contact-handlers.ts. Extracting them showed the address book closes over almost nothing: it
@@ -3050,17 +3061,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
3050
3061
  //
3051
3062
  // Wrapping the handler map is the ONE choke point that has both the response and the connection's
3052
3063
  // clientType. Doing it per-handler would mean 60+ call sites, and the 61st would forget.
3053
- const renderedHandlers = new Map();
3054
- for (const [method, handler] of handlers) {
3055
- renderedHandlers.set(method, async (params, connectionId) => {
3056
- const result = await handler(params, connectionId);
3057
- // Default to "cli": a connection that never sent ipc.connect has no recorded surface, and the
3058
- // CLI verb is the safe answer — it is at least a real command an operator can run, whereas an
3059
- // MCP tool name is useless in a terminal.
3060
- const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
3061
- return renderForSurface(result, surface);
3062
- });
3063
- }
3064
+ //
3065
+ // RESOLVED AT DISPATCH, not copied at construction. This was a `for…of` that snapshotted
3066
+ // `handlers` into a second map, which made the ORDER of registration load-bearing in a file
3067
+ // 3,500 lines long: anything registered after this line was written into a map nothing
3068
+ // dispatched from. The document surface landed 245 lines below it and every `cello_doc_*` verb
3069
+ // answered `method_not_found` — whose guidance blames version skew between the shim and the
3070
+ // daemon, so an operator would re-pin, reinstall and restart, and find both sides matching.
3071
+ //
3072
+ // A comment saying "register above this line" would have been one more rule to remember. Late
3073
+ // binding makes the ordering unrepresentable instead: the map below reads `handlers` when a
3074
+ // request arrives, so a handler registered at any point before the first request is dispatchable.
3075
+ const renderedHandlers = {
3076
+ get(method) {
3077
+ const handler = handlers.get(method);
3078
+ if (!handler)
3079
+ return undefined;
3080
+ return async (params, connectionId) => {
3081
+ const result = await handler(params, connectionId);
3082
+ // Default to "cli": a connection that never sent ipc.connect has no recorded surface, and
3083
+ // the CLI verb is the safe answer — it is at least a real command an operator can run,
3084
+ // whereas an MCP tool name is useless in a terminal.
3085
+ const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
3086
+ return renderForSurface(result, surface);
3087
+ };
3088
+ },
3089
+ };
3064
3090
  // Create and start IPC server
3065
3091
  const ipcServer = createIpcServer({ socketPath, maxConnections, logger }, renderedHandlers);
3066
3092
  try {
@@ -3096,6 +3122,337 @@ async function startDaemonHoldingLock(config, singletonLock) {
3096
3122
  // M8C-TGDOOR-1: message-waiting — coalesced (ring-once-until-read) inside sendTelegramDoorbell.
3097
3123
  void sendTelegramDoorbell(agentName, sessionId, "message_waiting", "New message waiting");
3098
3124
  });
3125
+ // M14 / DOD-DOC-INBOUND-2: the document layer, wired to the session content path.
3126
+ //
3127
+ // Built as ONE thing (see `document-layer.ts`): every half-wiring is a distinct silent failure —
3128
+ // an inbound path with no ack producer leaves the peer retrying until their document stalls, a
3129
+ // delivery worker with no inbound counterpart publishes envelopes nobody can answer.
3130
+ //
3131
+ // It shares the daemon's SQLCipher handle rather than opening its own. One encrypted database,
3132
+ // one key, one file to back up — and a second opener is a second thing that has to agree about
3133
+ // the key, which is how a store ends up plaintext.
3134
+ // THE owner key. One function, used by the inbound router and the delivery sweep alike, because
3135
+ // the two halves scoping differently is a silent-empty-query bug rather than a visible one.
3136
+ //
3137
+ // Reads `loadedAgents`, which is the live registry — `cello_create_agent` pushes into it at
3138
+ // runtime — rather than the boot-time snapshot, so an agent registered after startup resolves
3139
+ // without a restart. Lower-cased because the store compares owner keys as strings and a peer's
3140
+ // id arrives off the wire in whatever case they sent.
3141
+ const documentOwnerKeyFor = (agentName) => loadedAgents.find((a) => a.name === agentName)?.pubkey?.toLowerCase() ?? null;
3142
+ const documentLayer = createDocumentLayer({
3143
+ db: sessionNodeManager.getDb(),
3144
+ logger,
3145
+ // M14-D5: a remote agent's id IS its K_local pubkey hex, so this needs no lookup — and a lookup
3146
+ // on the critical path of every signature check is precisely what it must not have.
3147
+ publicKeyFor: agentPublicKeyFromId,
3148
+ ownerKeyFor: documentOwnerKeyFor,
3149
+ // Documents materialize as files under the operator's CELLO_DIR, alongside the database that
3150
+ // is their source of truth. Not the current working directory: a daemon serves many agents and
3151
+ // outlives any shell, so a relative root would scatter one operator's documents across
3152
+ // wherever they happened to launch it from.
3153
+ workspaceRoot: join(config.celloDir, "documents"),
3154
+ // The ack's road to the peer — the same open-or-reuse-then-seal path every other document frame
3155
+ // takes. Resolved from the owner KEY back to the agent name, because the transport is per agent.
3156
+ sendFrame: async (ownerAgentId, peerAgentId, bytes) => {
3157
+ const agentName = loadedAgents.find((a) => a.pubkey?.toLowerCase() === ownerAgentId)?.name;
3158
+ if (!agentName)
3159
+ return { ok: false, reason: "document_ack_no_agent" };
3160
+ const sent = await documentTransportFor(agentName).sendBytes({
3161
+ peerAgentId,
3162
+ documentId: "ack",
3163
+ bytes,
3164
+ correlationId: randomUUID(),
3165
+ });
3166
+ return sent.ok ? { ok: true } : { ok: false, reason: sent.reason };
3167
+ },
3168
+ // ONE implementation, shared with the two-party test. It was a closure here, and that is exactly
3169
+ // how the surface tests passed while the feature did nothing: the test wired this seam to
3170
+ // `async () => ({ ok: true })`, which reported success, sent nothing, and agreed with whatever
3171
+ // the near side did.
3172
+ notifyPeer: createDocumentControlNotifier({
3173
+ get store() {
3174
+ // Lazily, because `documentLayer` is what is being constructed. The notifier is only ever
3175
+ // called from lifecycle, long after construction returns.
3176
+ return documentLayer.store;
3177
+ },
3178
+ owners: () => loadedAgents
3179
+ .filter((a) => a.pubkey)
3180
+ .map((a) => ({ agentName: a.name, ownerAgentId: a.pubkey.toLowerCase() })),
3181
+ sign: async (agentName, tbs) => {
3182
+ const provider = keyProviders.get(agentName);
3183
+ if (!provider)
3184
+ throw new Error(`document_control_unsigned: no key provider for ${agentName}`);
3185
+ return provider.sign(tbs);
3186
+ },
3187
+ send: (agentName, input) => documentTransportFor(agentName).sendBytes(input),
3188
+ now: () => Date.now(),
3189
+ }),
3190
+ rollback: () => ({
3191
+ // Likewise: withdraw REFUSES rather than claiming a rollback that did not happen. Reporting
3192
+ // success having reverted nothing tells an operator their update was retracted while their
3193
+ // file still contains it.
3194
+ ok: false,
3195
+ reason: "document_rollback_not_wired",
3196
+ }),
3197
+ sign: async (ownerAgentId, tbs) => {
3198
+ // Signs as the OWNING agent, over the rejection's canonical preimage (DOD-DOC-REJECT-2).
3199
+ //
3200
+ // `ownerAgentId` here is the OWNER KEY — pubkey hex — because that is what the layer is
3201
+ // scoped by, and `keyProviders` is keyed by agent NAME. This did `keyProviders.get(ownerAgentId)`
3202
+ // and the comment above it asserted the two were the same thing. They are not: the lookup
3203
+ // missed on every call, so every auto-rejection threw `document_rejection_unsigned` and NO
3204
+ // rejection was ever signed or sent. A peer whose update we refused was never told why —
3205
+ // their sender retried into a gate that would refuse it every time, until the document
3206
+ // stalled for a reason neither operator could see.
3207
+ //
3208
+ // Invisible to every test: the unit and e2e fixtures alike wire `sign: (_o, tbs) => keys.sign(tbs)`,
3209
+ // discarding the agent argument, so nothing could disagree about which key was resolved.
3210
+ const agentName = loadedAgents.find((a) => a.pubkey?.toLowerCase() === ownerAgentId)?.name;
3211
+ const provider = agentName ? keyProviders.get(agentName) : undefined;
3212
+ if (!provider) {
3213
+ // Still throws rather than substituting another agent's key — an earlier version took no
3214
+ // agent at all and reached for whichever provider was first in the map, which is fabricated
3215
+ // crypto wearing a real signature.
3216
+ throw new Error(`document_rejection_unsigned: no key provider for owner ${ownerAgentId.slice(0, 16)}…, ` +
3217
+ `so its rejection cannot be signed — refusing rather than writing an unsigned leaf ` +
3218
+ `into an append-only log`);
3219
+ }
3220
+ return provider.sign(tbs);
3221
+ },
3222
+ });
3223
+ sessionNodeManager.setOnDocumentFrame(documentLayer.onDocumentFrame);
3224
+ // M14 / DOD-DOC-DELIVERY-2 — the outbound half, wired only now that INBOUND-2 is. The ordering
3225
+ // constraint on the DoD line is real and this is where it is honoured: a delivery worker with no
3226
+ // inbound counterpart publishes envelopes nobody can answer, so every document would stall at the
3227
+ // unacked ceiling.
3228
+ //
3229
+ // PER AGENT, because every capability below is: the signaling stream is authenticated as one
3230
+ // agent, sessions belong to one agent, and `openSessionFor` signs as one agent. A single worker
3231
+ // with an agent-shaped hole in it is how a delivery ends up sent under the wrong identity.
3232
+ //
3233
+ // CACHED per agent. A worker rebuilt every tick has its own re-entry guard (`#inFlight`)
3234
+ // constructed away — permanently null, and the comment claiming it protected anything was wrong.
3235
+ // The sweep's serial loop happens to cover it today; a cached worker means it is covered because
3236
+ // the guard exists, not by accident of loop shape.
3237
+ const documentDeliveryWorkers = new Map();
3238
+ // Cached separately from the worker because the OPERATOR SURFACE needs it too: a proposal is not
3239
+ // in the envelope log — there is no document yet — so it has no delivery record to schedule, and
3240
+ // it still has to travel the same open-or-reuse-then-seal path an update does.
3241
+ const documentTransports = new Map();
3242
+ const documentTransportFor = (agentName) => {
3243
+ const cached = documentTransports.get(agentName);
3244
+ if (cached)
3245
+ return cached;
3246
+ const transport = createDocumentDeliveryTransport({
3247
+ agentName,
3248
+ logger,
3249
+ // The SAME 3-state discovery answer cello_initiate_session uses, from the same closure —
3250
+ // online / offline / unknown_agent, kept distinct from "the lookup itself failed". A second
3251
+ // implementation of that distinction drifts until one of them reports a directory outage as
3252
+ // the peer being offline.
3253
+ lookupPeer: async (peerAgentId, correlationId) => {
3254
+ const entry = perAgentSignaling.get(agentName);
3255
+ if (!entry) {
3256
+ // Our OWN stream is not up. A transport fault, never the peer being away — the
3257
+ // reachability mapper turns this into a throw, which the worker logs as lookup_failed.
3258
+ return { kind: "send_failed", reason: "signaling_unavailable" };
3259
+ }
3260
+ return runDiscoveryLookup(entry.signaling, peerAgentId, 10_000, correlationId);
3261
+ },
3262
+ // Most recent LAST — §16.4 reuses the most recent active session, and getSessionsForAgent
3263
+ // returns newest first.
3264
+ activeSessionsWith: (agent, peerAgentId) => sessionNodeManager
3265
+ .getSessionsForAgent(agent)
3266
+ .filter((row) => row.status === "active" && row.counterparty_pubkey === peerAgentId)
3267
+ .map((row) => row.session_id)
3268
+ .reverse(),
3269
+ openSession: async (agent, peerAgentId, correlationId) => {
3270
+ // The same path `cello_initiate_session` takes, now callable without an IPC connection.
3271
+ const res = (await openSessionFor(agent, { targetPubkey: peerAgentId }));
3272
+ if (res.ok !== true || typeof res.sessionId !== "string") {
3273
+ return { ok: false, reason: res.reason ?? "session_open_failed", guidance: res.guidance };
3274
+ }
3275
+ logger.info("document.delivery.session_opened", { agent, peerAgentId, sessionId: res.sessionId, correlationId });
3276
+ return { ok: true, sessionId: res.sessionId };
3277
+ },
3278
+ sealSession: async (agent, sessionId, correlationId) => {
3279
+ // §16.4: the autonomous session still carries the seal — the ceremony goes to zero, the
3280
+ // seal does not. Only ever called for a session this worker OPENED; one it reused belongs
3281
+ // to whoever started that conversation.
3282
+ const close = handlers.get("cello_close_session");
3283
+ if (!close) {
3284
+ // Reported, not swallowed. The outcome of a missing handler is a live session node the
3285
+ // operator never started, with no sealed record — the exact thing the seal exists to
3286
+ // prevent, and previously indistinguishable from a clean seal.
3287
+ logger.error("document.delivery.seal_failed", { agent, sessionId, reason: "close_handler_missing", correlationId });
3288
+ return;
3289
+ }
3290
+ const sealed = (await close({ session_id: sessionId, agent }, `doc-delivery-${correlationId}`));
3291
+ if (sealed?.ok !== true) {
3292
+ // `cello_close_session` has distinct failure codes — session_already_sealed,
3293
+ // seal_interrupted_*, signaling_reconnecting — and every one of them landed nowhere.
3294
+ logger.warn("document.delivery.seal_failed", { agent, sessionId, reason: sealed?.reason ?? "unknown", correlationId });
3295
+ }
3296
+ },
3297
+ sendContent: (agent, sessionId, content, contentHash, correlationId) => sessionNodeManager.sendContent(agent, sessionId, content, contentHash, correlationId),
3298
+ // The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
3299
+ // successful send. See the comment at the call site for why this is delivery-critical and
3300
+ // not audit bookkeeping.
3301
+ appendLeaf: (agent, sessionId, contentHash, correlationId) => {
3302
+ sessionNodeManager.appendSessionLeaf(agent, sessionId, "doc", Buffer.from(contentHash).toString("hex"), correlationId);
3303
+ },
3304
+ encodeEnvelope: (envelope) => {
3305
+ const bytes = encodeDocumentUpdateEnvelope({
3306
+ type: "document_update",
3307
+ document_id: envelope.documentId,
3308
+ epoch_id: envelope.epochId,
3309
+ doc_prev_hash: envelope.docPrevHash,
3310
+ sender_agent_id: envelope.senderAgentId,
3311
+ sender_client_id: envelope.senderClientId ?? 0,
3312
+ update_encoding: DOCUMENT_UPDATE_ENCODING_V1,
3313
+ state_vector: envelope.stateVector,
3314
+ update: envelope.payload ?? new Uint8Array(0),
3315
+ signature: envelope.signature,
3316
+ });
3317
+ // Same defect as `sendBytes` had, on the UPDATE path: the receiver recomputes with the
3318
+ // `0x00` domain prefix, so a bare hash is discarded at the authenticity check.
3319
+ return { bytes, hash: wireContentHash(bytes) };
3320
+ },
3321
+ });
3322
+ documentTransports.set(agentName, transport);
3323
+ return transport;
3324
+ };
3325
+ const documentDeliveryFor = (agentName) => {
3326
+ const existing = documentDeliveryWorkers.get(agentName);
3327
+ if (existing)
3328
+ return existing;
3329
+ const worker = new DocumentDelivery(documentLayer.store, documentTransportFor(agentName), logger);
3330
+ documentDeliveryWorkers.set(agentName, worker);
3331
+ return worker;
3332
+ };
3333
+ // M14 / DOD-DOC-TOOLS-1 — the OPERATOR SURFACE. Registered last of the document wiring, because
3334
+ // it is the only part that can create a document, and everything it creates has to have somewhere
3335
+ // to go: without the inbound path a peer's answer is unroutable, and without the delivery worker a
3336
+ // published update never leaves.
3337
+ const documentPublish = new DocumentPublish({
3338
+ store: documentLayer.store,
3339
+ engine: documentLayer.engine,
3340
+ logger,
3341
+ sign: async (ownerAgentId, tbs) => {
3342
+ // `ownerAgentId` is the owner KEY here, and keyProviders is keyed by NAME — so it is resolved
3343
+ // back through the same map the owner key came from rather than guessed. A miss throws: an
3344
+ // unsigned envelope in an append-only log is worse than a failed publish.
3345
+ const agentName = loadedAgents.find((a) => a.pubkey?.toLowerCase() === ownerAgentId)?.name;
3346
+ const provider = agentName ? keyProviders.get(agentName) : undefined;
3347
+ if (!provider) {
3348
+ throw new Error(`document_publish_unsigned: no key provider for owner ${ownerAgentId.slice(0, 16)}…, ` +
3349
+ `so this update cannot be signed`);
3350
+ }
3351
+ return provider.sign(tbs);
3352
+ },
3353
+ // M14-D5: our wire sender id IS the owner key. Kept as its own callback because they are
3354
+ // different facts that happen to coincide — see DocumentStore.pendingDeliveries.
3355
+ senderIdFor: (ownerAgentId) => ownerAgentId,
3356
+ canPublish: (ownerAgentId, documentId) => documentLayer.lifecycle.canPublish(ownerAgentId, documentId),
3357
+ });
3358
+ registerDocumentHandlers({
3359
+ handlers,
3360
+ logger,
3361
+ layer: documentLayer,
3362
+ publish: documentPublish,
3363
+ transportFor: documentTransportFor,
3364
+ resolveAgent: (connectionId, explicit) => resolveCurrentAgent(perConnectionState.get(connectionId), explicit),
3365
+ ownerKeyFor: documentOwnerKeyFor,
3366
+ sign: async (agentName, tbs) => {
3367
+ const provider = keyProviders.get(agentName);
3368
+ if (!provider)
3369
+ throw new Error(`document_proposal_unsigned: no key provider for ${agentName}`);
3370
+ return provider.sign(tbs);
3371
+ },
3372
+ now: () => Date.now(),
3373
+ });
3374
+ /**
3375
+ * The delivery tick.
3376
+ *
3377
+ * Interval-driven rather than event-driven because the event we are actually waiting for — the
3378
+ * peer coming back online — is not one this daemon observes. There is no presence subscription
3379
+ * (parked, M14-P4), so the honest substitute is to ask periodically and let the per-envelope
3380
+ * backoff keep the cost down: a peer that has been away an hour is checked at the cap, not every
3381
+ * tick.
3382
+ *
3383
+ * Slow on purpose. Publish is fire-and-forget and nothing waits on this; a document that syncs a
3384
+ * minute later is working correctly, while a tight loop over every attended agent is a cost paid
3385
+ * forever for a case that is rare.
3386
+ */
3387
+ //
3388
+ // Overridable for tests, the way `CELLO_SEAL_BILATERAL_TIMEOUT_MS` already is. The live enforcers
3389
+ // spend their wall clock waiting for this tick — two of them is four minutes — and a test that
3390
+ // must sit out a production-paced timer either runs slowly or is written with a window so tight
3391
+ // that adding a second test to the file makes the first one fail. Both happened.
3392
+ //
3393
+ // Floored, so a misread env cannot turn the sweep into a busy loop against the directory.
3394
+ const tickOverride = Number(process.env["CELLO_DOCUMENT_DELIVERY_TICK_MS"]);
3395
+ const DOCUMENT_DELIVERY_TICK_MS = Number.isFinite(tickOverride) && tickOverride >= 250 ? tickOverride : 60_000;
3396
+ let documentDeliveryRunning = false;
3397
+ let documentDeliveryStopping = false;
3398
+ let documentDeliveryInFlight = null;
3399
+ const documentDeliveryTimer = setInterval(() => {
3400
+ // NO OVERLAP. A tick that dials a slow peer can outlast the interval, and a second pass would
3401
+ // re-send envelopes already in flight — the worker's own re-entry guard covers one agent, this
3402
+ // covers the sweep across all of them.
3403
+ if (documentDeliveryRunning)
3404
+ return;
3405
+ documentDeliveryRunning = true;
3406
+ documentDeliveryInFlight = (async () => {
3407
+ try {
3408
+ let agentsSwept = 0;
3409
+ let attempted = 0;
3410
+ let delivered = 0;
3411
+ let failed = 0;
3412
+ for (const agentName of perAgentSignaling.keys()) {
3413
+ // M2: a tick already running when the daemon stops must not keep dialling into a
3414
+ // half-torn-down transport. `clearInterval` stops the NEXT tick, not this one.
3415
+ if (documentDeliveryStopping)
3416
+ break;
3417
+ const ownerAgentId = documentOwnerKeyFor(agentName);
3418
+ // M14-D5: the owner key is our own pubkey hex. It is what BOTH halves scope by — the
3419
+ // inbound router resolves the same function — and it is what the wire sender id is.
3420
+ // Scoping the two halves differently returns nothing pending, reports nothing attempted,
3421
+ // and leaves a fully synced document invisible with no error on any path.
3422
+ if (ownerAgentId === null)
3423
+ continue;
3424
+ agentsSwept++;
3425
+ const peerFor = (documentId) => documentLayer.store.getDocument(ownerAgentId, documentId)?.peerAgentId ?? null;
3426
+ const result = await documentDeliveryFor(agentName).tick(ownerAgentId, peerFor, Date.now(), {
3427
+ senderAgentId: ownerAgentId,
3428
+ });
3429
+ attempted += result.attempted;
3430
+ delivered += result.delivered;
3431
+ failed += result.failed;
3432
+ }
3433
+ // H3: a sweep that delivers nothing was byte-for-byte identical to a healthy idle one, which
3434
+ // is what made every scoping bug above invisible. One line per tick, always.
3435
+ logger.debug("document.delivery.sweep", { agents: agentsSwept, attempted, delivered, failed });
3436
+ if (agentsSwept === 0 && documentLayer.store.anyDocumentExists()) {
3437
+ logger.warn("document.delivery.sweep_empty", {
3438
+ reason: "documents exist but no agent was swept — nothing will ever be delivered",
3439
+ });
3440
+ }
3441
+ }
3442
+ catch (err) {
3443
+ // Contained: one agent's delivery failure must not stop the sweep, and an unhandled
3444
+ // rejection out of a timer takes the daemon down.
3445
+ logger.warn("document.delivery.tick.failed", { reason: extractErrorMessage(err) });
3446
+ }
3447
+ finally {
3448
+ documentDeliveryRunning = false;
3449
+ documentDeliveryInFlight = null;
3450
+ }
3451
+ })();
3452
+ void documentDeliveryInFlight;
3453
+ }, DOCUMENT_DELIVERY_TICK_MS);
3454
+ // Never hold the process open on account of document delivery.
3455
+ documentDeliveryTimer.unref?.();
3099
3456
  // MCP-001: Clean up per-connection state when a connection disconnects
3100
3457
  // MCP-002: Also unregister from notification dispatcher
3101
3458
  ipcServer.onDisconnect((connectionId) => {
@@ -3150,6 +3507,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
3150
3507
  // lifecycle is the daemon's, not any agent connection's — so it polls even with zero agents.
3151
3508
  // Graceful shutdown
3152
3509
  async function stop(reason) {
3510
+ // M14: stop the document delivery sweep first — it opens sessions, and a tick landing during
3511
+ // teardown would dial into a daemon that is going away.
3512
+ clearInterval(documentDeliveryTimer);
3513
+ // ...and wait for the tick already running. `clearInterval` stops the next one, not this one,
3514
+ // which may be mid-dial into a transport that is going away.
3515
+ documentDeliveryStopping = true;
3516
+ if (documentDeliveryInFlight)
3517
+ await documentDeliveryInFlight;
3153
3518
  // M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
3154
3519
  // the generation so the running loop's while-condition fails on its next check.
3155
3520
  stopTelegramPoller(); // M8C-TGDOOR-1: invalidate the poll loop; it exits on its next generation check
@@ -3230,6 +3595,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
3230
3595
  // M8C-TGDOOR-1: cold-capable — start the poller if a token was already configured from a
3231
3596
  // prior run, without waiting for any agent to come online or any client to attach.
3232
3597
  startTelegramPollerIfConfigured();
3233
- return { stop, getStatus, getSessionNodeManager, getTransportSelector, getAutoNatService, getTypeRegistry };
3598
+ /**
3599
+ * The live handler map.
3600
+ *
3601
+ * Exposed so the LATE-BINDING property can be asserted rather than assumed: dispatch resolves
3602
+ * from this map when a request arrives, and the only way to prove that is to register something
3603
+ * after `start()` has resolved and call it. The property is not decorative — a snapshot copy here
3604
+ * once made every `cello_doc_*` verb unreachable while the whole suite stayed green.
3605
+ *
3606
+ * Sits alongside `getSessionNodeManager` and `getTypeRegistry`, which are exposed for the same
3607
+ * reason. Production code has no business mutating it after boot.
3608
+ */
3609
+ const getHandlers = () => handlers;
3610
+ return {
3611
+ stop, getStatus, getSessionNodeManager, getTransportSelector, getAutoNatService, getTypeRegistry,
3612
+ getHandlers,
3613
+ };
3234
3614
  }
3235
3615
  //# sourceMappingURL=daemon.js.map