@cello-protocol/daemon 0.0.29 → 0.0.31

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.
@@ -1 +1 @@
1
- {"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EASrB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAM/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAsB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAO/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AA0InF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;;;;;OAQG;IACH,gBAAgB,IAAI,SAAS,GAAG,IAAI,CAAC;IACrC;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;CACtC;AAyCD,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAs/I7E"}
1
+ {"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EASrB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAM/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAsB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAO/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AA0InF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;;;;;OAQG;IACH,gBAAgB,IAAI,SAAS,GAAG,IAAI,CAAC;IACrC;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;CACtC;AAyCD,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA0uJ7E"}
package/dist/daemon.js CHANGED
@@ -859,11 +859,16 @@ export async function startDaemon(config) {
859
859
  }
860
860
  }
861
861
  /**
862
- * Open a transient VISITING signaling connection into a specific directory node (the target's home)
863
- * and wire ONLY what a cross-node initiator needs there: the delegated-signer ceremony handler (the
864
- * broker asks the initiator to co-sign the assignment over THIS connection). Seal + inbound handlers
865
- * are NOT wired — the seal is client-coordinated over the agent's own roster, and this connection is
866
- * initiator-only and transient. Caller MUST stop() it after the assignment arrives.
862
+ * Open a transient VISITING signaling connection into a specific directory node (the target's home /
863
+ * broker) and wire what a cross-node initiator needs there:
864
+ * - the delegated-signer ceremony handler (the broker asks the initiator to co-sign the assignment
865
+ * over THIS connection); and
866
+ * - Fix #1 (cross-node seal-liveness): the seal ceremony handler + session_sealed/unilateral
867
+ * listeners, because for a cross-node CLOSE the broker ALSO pushes seal_verified then session_sealed
868
+ * over the connection the initiator holds to it. The seal FROST still runs over the agent's OWN
869
+ * roster (getNode: () => nodeRef) — only the control frames traverse this stream.
870
+ * Transient and initiator-only. The caller MUST stop() it after the assignment (setup path) or after
871
+ * the seal reaches a terminal outcome (close path).
867
872
  */
868
873
  function openVisitingConnection(agentName, agentKeyProvider, agentPubkeyHex, endpoint, correlationId, nodeId) {
869
874
  let nodeRef = null;
@@ -888,6 +893,24 @@ export async function startDaemon(config) {
888
893
  signaling: mgr,
889
894
  logger,
890
895
  });
896
+ // Fix #1 (cross-node seal-liveness): a visiting connection must ALSO be able to complete the SEAL.
897
+ // The broker (the node this visiting connection targets) pushes seal_verified then session_sealed to
898
+ // whichever stream the initiator holds to it — for a cross-node close that is THIS visiting stream.
899
+ // Run the seal FROST ceremony over the agent's own node (getNode: () => nodeRef, exactly as the
900
+ // session ceremony above) and reply/resolve on this stream. Harmless on a setup-only visiting
901
+ // connection: these frames never arrive before it is released after handoff.
902
+ wireSealCeremonyHandler({
903
+ agentName,
904
+ persistence: getPersistence(agentName),
905
+ agentPubkeyHex,
906
+ getNode: () => nodeRef,
907
+ getDirectoryEndpoint: getFailoverEndpoint,
908
+ getConsortiumEndpoints: resolveConsortiumRoster,
909
+ signaling: mgr,
910
+ logger,
911
+ });
912
+ registerSessionSealedListener(mgr, agentName, agentPubkeyHex);
913
+ registerUnilateralConfirmedListener(mgr, agentName, agentPubkeyHex);
891
914
  logger.info("signaling.visiting.connected", { agentName, node: nodeId, correlationId });
892
915
  return {
893
916
  mgr,
@@ -897,6 +920,16 @@ export async function startDaemon(config) {
897
920
  },
898
921
  };
899
922
  }
923
+ // Fix #1 (cross-node seal-liveness): the broker node (the counterparty's home) for each cross-node
924
+ // session we initiated, keyed `${agentName}:${sessionIdHex}`. Set when runCrossNodeSetup establishes
925
+ // the session; read by cello_close_session so it can re-open a visiting connection to the broker and
926
+ // complete the seal there. The broker pushes seal_verified/session_sealed, but the initiator RELEASES
927
+ // its visiting connection after setup — so without reconnecting, the close times out
928
+ // (seal_unilateral_timeout) and the seal only completes whenever the daemon next happens to reconnect.
929
+ // Presence of an entry means the session is cross-node (same-node sessions never go through
930
+ // runCrossNodeSetup). In-memory only: a close in the SAME process (the common case) is covered; a close
931
+ // after a daemon restart falls back to the pre-fix behavior (deferred hardening: persist on the row).
932
+ const crossNodeBrokerBySession = new Map();
900
933
  /**
901
934
  * Resolve `owningNodeId` through the signed manifest and run the session_request over a transient
902
935
  * visiting connection there. A node id that doesn't resolve in the roster is a hard error
@@ -926,6 +959,9 @@ export async function startDaemon(config) {
926
959
  const result = await runSessionRequestOverSignaling(visiting.mgr, targetHex, sr, correlationId, agentName);
927
960
  if (result.ok) {
928
961
  releaseReason = "handoff-complete";
962
+ // Fix #1: remember the broker for this session so cello_close_session can reconnect to complete the seal.
963
+ const brokerSessionIdHex = Buffer.from(result.assignment.session_id).toString("hex");
964
+ crossNodeBrokerBySession.set(`${agentName}:${brokerSessionIdHex}`, owningNodeId);
929
965
  logger.info("session.crossnode.established", { agentName, brokerNode: owningNodeId, correlationId });
930
966
  }
931
967
  else {
@@ -1218,19 +1254,16 @@ export async function startDaemon(config) {
1218
1254
  return agents
1219
1255
  .filter((a) => a.state !== "load_failed")
1220
1256
  .map((a) => {
1221
- let state;
1222
- if (a.name === currentAgent && onlineAgents.has(a.name)) {
1223
- state = "current";
1224
- }
1225
- else if (onlineAgents.has(a.name)) {
1226
- state = "online";
1227
- }
1228
- else {
1229
- state = "registered";
1230
- }
1257
+ // M8C-AUTOSTART-1 (F5): `state` reports readiness only (online vs registered); selection
1258
+ // is a SEPARATE `selected` flag. Previously `state = "current"` overloaded the current
1259
+ // agent, making it read as a different readiness level than a second healthy online agent.
1260
+ const online = onlineAgents.has(a.name);
1261
+ const state = online ? "online" : "registered";
1262
+ const selected = online && a.name === currentAgent;
1231
1263
  return {
1232
1264
  name: a.name,
1233
1265
  state,
1266
+ selected,
1234
1267
  pubkey: a.pubkey,
1235
1268
  // M8B F14 (fix 5): per-agent standing-receiver readiness on the MCP surface
1236
1269
  // (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
@@ -1312,11 +1345,13 @@ export async function startDaemon(config) {
1312
1345
  return { connectionId };
1313
1346
  });
1314
1347
  // ─── MCP-001: cello_start_agent handler ───
1315
- handlers.set("cello_start_agent", async (params, _connectionId) => {
1316
- const name = params?.name;
1317
- if (!name) {
1318
- return { ok: false, reason: "missing_params", guidance: "Provide 'name' parameter with the agent name to start." };
1319
- }
1348
+ // M8C-AUTOSTART-1 (A2): the shared start path. Extracted from cello_start_agent so cello_use_agent
1349
+ // can AUTO-START an offline agent through the exact same code (idempotent, same signaling +
1350
+ // standing-receiver setup, same agent_state_changed event) — never a divergent shim-side retry.
1351
+ // Permissive by design (D12): an agent that exists goes online regardless of directory
1352
+ // registration state (online-without-registration is an established contract). Returns a
1353
+ // structured failure so callers can surface agent_start_failed with a real reason + guidance.
1354
+ function startAgentInternal(name) {
1320
1355
  const agent = agents.find((a) => a.name === name);
1321
1356
  if (!agent || agent.state === "load_failed") {
1322
1357
  return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Run 'cello login' to register agents, or check agent names with cello_list_agents.` };
@@ -1361,6 +1396,29 @@ export async function startDaemon(config) {
1361
1396
  // MCP-002: Broadcast agent_state_changed to ALL connections
1362
1397
  notificationDispatcher.dispatchAgentStateChanged(name, "online", "started");
1363
1398
  return { ok: true };
1399
+ }
1400
+ // M8C-AUTOSTART-1 (F18): resolve which agent a name-defaulting tool should act on for this
1401
+ // connection: an explicit name wins; else the connection's current agent; else — when EXACTLY one
1402
+ // agent is online daemon-wide — that sole agent (removes the "why did it forget my agent" moment
1403
+ // after a /mcp reconnect). Two-or-more online with none selected stays ambiguous → null (the
1404
+ // caller returns no_current_agent), because guessing between peers would misroute.
1405
+ function resolveCurrentAgent(connState, explicitName) {
1406
+ if (explicitName)
1407
+ return explicitName;
1408
+ if (connState?.currentAgent)
1409
+ return connState.currentAgent;
1410
+ if (onlineAgents.size === 1)
1411
+ return [...onlineAgents][0];
1412
+ return null;
1413
+ }
1414
+ // ─── MCP-001: cello_start_agent handler ───
1415
+ // Bring a registered agent online WITHOUT claiming it as this connection's current agent.
1416
+ handlers.set("cello_start_agent", async (params, _connectionId) => {
1417
+ const name = params?.name;
1418
+ if (!name) {
1419
+ return { ok: false, reason: "missing_params", guidance: "Provide 'name' parameter with the agent name to start." };
1420
+ }
1421
+ return startAgentInternal(name);
1364
1422
  });
1365
1423
  // ─── PERSIST-002 (AC-004): cello_create_agent handler ───
1366
1424
  // The explicit agent-creation path: generate a fresh K_local seed, write it as an `agents` row in
@@ -1612,15 +1670,36 @@ export async function startDaemon(config) {
1612
1670
  }
1613
1671
  const agent = agents.find((a) => a.name === name);
1614
1672
  if (!agent || agent.state === "load_failed") {
1615
- return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Check agent names with cello_list_agents.` };
1616
- }
1617
- if (!onlineAgents.has(name)) {
1618
- return { ok: false, reason: "agent_not_online", guidance: `Agent '${name}' exists but is not online. Call cello_start_agent('${name}') first to bring it online, then retry cello_use_agent.` };
1673
+ return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Create it with 'cello create-agent ${name}', register it, then retry — or check names with cello_list_agents.` };
1619
1674
  }
1620
1675
  const connState = perConnectionState.get(connectionId);
1621
1676
  if (!connState) {
1622
1677
  return { ok: false, reason: "connection_not_registered", guidance: "Send ipc.connect frame before calling agent tools." };
1623
1678
  }
1679
+ // M8C-AUTOSTART-1 (A1): auto-start the agent if it is not online, so the incantation collapses
1680
+ // to `login → use_agent` (no separate cello_start_agent). On a start failure, return a
1681
+ // structured agent_start_failed and leave the current selection UNCHANGED — no half-selected
1682
+ // state (A3). cello_start_agent stays available for bring-online-without-claiming.
1683
+ if (!onlineAgents.has(name)) {
1684
+ logger.info("agent.autostart.attempted", { connectionId, agentName: name });
1685
+ const startRes = startAgentInternal(name);
1686
+ if (!startRes.ok) {
1687
+ // Structured failure envelope (D6). Today `startAgentInternal` is permissive (D12) and its
1688
+ // only failure — agent_not_found — is already caught by the existence pre-check above, so
1689
+ // this branch is currently unreachable. It is the RESERVED structured-failure surface for
1690
+ // when auto-start gains a synchronous failure mode (e.g. D12's reverse: a bounded
1691
+ // waitForSignalingConnected making `directory_unreachable` a real start failure). Kept so
1692
+ // that extension surfaces the reason + guidance here with the selection left unchanged.
1693
+ // See M8C-DECISIONS D12 + BUILD-JOURNAL Entry 8.
1694
+ logger.warn("agent.autostart.failed", { connectionId, agentName: name, reason: startRes.reason });
1695
+ return {
1696
+ ok: false,
1697
+ reason: "agent_start_failed",
1698
+ start_reason: startRes.reason,
1699
+ guidance: `Could not start agent '${name}' to select it: ${startRes.guidance} Your current agent is unchanged.`,
1700
+ };
1701
+ }
1702
+ }
1624
1703
  if (connState.currentAgent === name) {
1625
1704
  return { ok: false, reason: "agent_already_current", guidance: `Agent '${name}' is already the current agent for this connection. No action needed — you can proceed with session operations.` };
1626
1705
  }
@@ -1630,7 +1709,26 @@ export async function startDaemon(config) {
1630
1709
  notificationDispatcher.setCurrentAgent(connectionId, name);
1631
1710
  notificationDispatcher.dispatchAgentCurrentChanged(connectionId, fromAgent, name);
1632
1711
  logger.info("agent.current.switched", { connectionId, fromAgent, toAgent: name });
1633
- return { ok: true };
1712
+ // M8C-AUTOSTART-1 (A3, D12): not_registered is a NON-BLOCKING warning — the agent is selected
1713
+ // and usable locally, but it cannot establish directory sessions until registered. Surface the
1714
+ // next step (ONBOARD-NEXTSTEP style) without stranding the selection. One-row read.
1715
+ const result = { ok: true };
1716
+ try {
1717
+ const reg = await new DbRegistrationPersistence({ db: sessionNodeManager.getDb(), agentName: name, logger }).loadRegistrationState();
1718
+ if (!reg || reg.status !== "active") {
1719
+ result["warning"] = "not_registered";
1720
+ result["warning_guidance"] = `Agent '${name}' is now selected but is not registered with the directory — run 'cello register ${name}' to enable sessions with peers. Run 'cello status' to watch registration complete.`;
1721
+ }
1722
+ }
1723
+ catch (err) {
1724
+ // A failed registration read must not break selection (the agent IS selected). Log the real
1725
+ // reason, and surface a softer `registration_unknown` warning so the operator's surface is NOT
1726
+ // falsely clean — we could not confirm registration, so don't imply it is fine (Finding 3).
1727
+ logger.warn("agent.registration.read.failed", { agentName: name, reason: err instanceof Error ? err.message : String(err) });
1728
+ result["warning"] = "registration_unknown";
1729
+ result["warning_guidance"] = `Agent '${name}' is selected, but its registration status could not be read — run 'cello status' to check whether it is registered with the directory.`;
1730
+ }
1731
+ return result;
1634
1732
  });
1635
1733
  // ─── MCP-001: cello_list_agents handler ───
1636
1734
  handlers.set("cello_list_agents", async (_params, connectionId) => {
@@ -2268,7 +2366,7 @@ export async function startDaemon(config) {
2268
2366
  // ─── M8B DOD-REFRESH-1: cello_refresh_shares — proactive share refresh / epoch rollover ───
2269
2367
  handlers.set("cello_refresh_shares", async (params, connectionId) => {
2270
2368
  const connState = perConnectionState.get(connectionId);
2271
- const agentName = params?.name ?? connState?.currentAgent;
2369
+ const agentName = resolveCurrentAgent(connState, params?.name); // M8C-AUTOSTART-1 F18: sole-online fallback
2272
2370
  if (!agentName) {
2273
2371
  return { ok: false, reason: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass { name }." };
2274
2372
  }
@@ -2300,7 +2398,7 @@ export async function startDaemon(config) {
2300
2398
  // ─── M8B DOD-RELAYSIG-1: cello_get_relay_receipts — the agent's stored relay ordering receipts ───
2301
2399
  handlers.set("cello_get_relay_receipts", async (params, connectionId) => {
2302
2400
  const connState = perConnectionState.get(connectionId);
2303
- const agentName = params?.name ?? connState?.currentAgent;
2401
+ const agentName = resolveCurrentAgent(connState, params?.name); // M8C-AUTOSTART-1 F18: sole-online fallback
2304
2402
  if (!agentName) {
2305
2403
  return { ok: false, reason: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass { name }." };
2306
2404
  }
@@ -2584,7 +2682,43 @@ export async function startDaemon(config) {
2584
2682
  if (record.status === "active") {
2585
2683
  sealInterruptedInProgress.add(sealKey(record.agent_name, sessionId));
2586
2684
  const correlationId = randomUUID();
2685
+ // Fix #1 (cross-node seal-liveness): if this session was brokered by another node, the seal_verified
2686
+ // + session_sealed frames are pushed by that BROKER — but the initiator released its visiting
2687
+ // connection after setup, so on the home stream they never arrive and close times out. Re-open a
2688
+ // transient visiting connection to the broker (seal-capable — openVisitingConnection now wires the
2689
+ // seal handlers) for the duration of the seal, then release it in finally. Same-node sessions have
2690
+ // no entry here and use the home stream unchanged.
2691
+ let sealBrokerConn = null;
2587
2692
  try {
2693
+ const brokerNodeForSeal = crossNodeBrokerBySession.get(`${record.agent_name}:${sessionId}`);
2694
+ if (brokerNodeForSeal) {
2695
+ const sealKp = keyProviders.get(record.agent_name);
2696
+ if (!sealKp) {
2697
+ // Fallback-finder #1: never skip the cross-node reconnect silently — without a key provider
2698
+ // we cannot open the broker connection, so the seal will revert to the pre-fix timeout. Log
2699
+ // WHY so it is not indistinguishable from a normal counterparty-didn't-close timeout.
2700
+ logger.warn("session.seal.broker.no_keyprovider", { agentName: record.agent_name, brokerNode: brokerNodeForSeal, correlationId });
2701
+ }
2702
+ else {
2703
+ const sealPubHex = Buffer.from(await sealKp.getPublicKey()).toString("hex");
2704
+ const roster = await resolveConsortiumRoster();
2705
+ const brokerTarget = roster?.find((e) => e.nodeId === brokerNodeForSeal) ?? null;
2706
+ if (!brokerTarget) {
2707
+ logger.warn("session.seal.broker.unresolved", { agentName: record.agent_name, brokerNode: brokerNodeForSeal, correlationId });
2708
+ }
2709
+ else {
2710
+ const conn = openVisitingConnection(record.agent_name, sealKp, sealPubHex, { peerId: brokerTarget.peerId, multiaddr: brokerTarget.multiaddr }, correlationId, brokerNodeForSeal);
2711
+ if (await waitForSignalingConnected(conn.mgr, 10_000)) {
2712
+ sealBrokerConn = conn;
2713
+ logger.info("session.seal.broker.reconnected", { agentName: record.agent_name, brokerNode: brokerNodeForSeal, correlationId });
2714
+ }
2715
+ else {
2716
+ await conn.stop("seal-broker-unreachable");
2717
+ logger.warn("session.seal.broker.unreachable", { agentName: record.agent_name, brokerNode: brokerNodeForSeal, correlationId });
2718
+ }
2719
+ }
2720
+ }
2721
+ }
2588
2722
  // M7 DOD-SPINE-7: relay-mediated bilateral seal. Submit our SEAL ctrl leaf to the
2589
2723
  // relay witness; when the counterparty ALSO closes, the relay's #maybeProcessSeal
2590
2724
  // fires → directory processSeal rebuilds + verifies the signed chain → FROST
@@ -2623,6 +2757,7 @@ export async function startDaemon(config) {
2623
2757
  pendingSealWaiters.delete(sealKey(record.agent_name, sessionId));
2624
2758
  if (sealedCompletion !== null) {
2625
2759
  logger.info("session.seal.completed", { sessionId, sealedRoot: sealedCompletion.rootHex, role: "bilateral", correlationId });
2760
+ crossNodeBrokerBySession.delete(`${record.agent_name}:${sessionId}`); // Fix #1 review: evict on terminal seal success (a FAILED close keeps the entry so a retry can still reconnect).
2626
2761
  // M7-SESSION-004 (AC-006): return the legibility certificate on the seal completion so
2627
2762
  // a reader gets it on the same surface that proves the seal — receipt-not-assent,
2628
2763
  // per-party frontiers, attestation modes, and final_message.answered.
@@ -2698,6 +2833,7 @@ export async function startDaemon(config) {
2698
2833
  pendingUnilateralWaiters.delete(sessionId);
2699
2834
  if (uniResult.ok) {
2700
2835
  logger.info("session.seal.completed", { sessionId, sealedRoot: uniResult.sealedRootHex, role: "unilateral", correlationId });
2836
+ crossNodeBrokerBySession.delete(`${record.agent_name}:${sessionId}`); // Fix #1 review: evict on terminal seal success (a FAILED close keeps the entry so a retry can still reconnect).
2701
2837
  // M8B FINDING-3 (cascade-2): return the legibility certificate inline — same shape as the
2702
2838
  // bilateral close (AC-006) — so the operator gets the receipt on the surface that proves
2703
2839
  // the seal, not just a bare root. It is also persisted (above) for cello_get_sealed_receipt.
@@ -2726,6 +2862,15 @@ export async function startDaemon(config) {
2726
2862
  };
2727
2863
  }
2728
2864
  finally {
2865
+ // Fix #1: release the transient broker seal-connection (best-effort; the seal result stands).
2866
+ if (sealBrokerConn) {
2867
+ try {
2868
+ await sealBrokerConn.stop("seal-complete");
2869
+ }
2870
+ catch (err) {
2871
+ logger.warn("session.seal.broker.release_failed", { sessionId, reason: err instanceof Error ? err.message : String(err) });
2872
+ }
2873
+ }
2729
2874
  sealInterruptedInProgress.delete(sealKey(record.agent_name, sessionId));
2730
2875
  }
2731
2876
  }
@@ -2762,15 +2907,47 @@ export async function startDaemon(config) {
2762
2907
  if (!connState || !connState.currentAgent) {
2763
2908
  return NO_CURRENT_AGENT_RESPONSE;
2764
2909
  }
2765
- const cert = sessionNodeManager.getSealCertificate(connState.currentAgent, sessionId);
2766
- if (!cert) {
2910
+ const agentName = connState.currentAgent;
2911
+ const cert = sessionNodeManager.getSealCertificate(agentName, sessionId);
2912
+ if (cert) {
2913
+ return { ok: true, session_id: sessionId, sealed_root: cert.sealed_root, legibility: cert.legibility };
2914
+ }
2915
+ // M8C-INBOX-1 (F4): the single `sealed_receipt_not_found` conflated four distinct causes, so a
2916
+ // caller could not tell a typo from a not-sealed-yet session from a wrong-agent selection.
2917
+ // Split them (full session ids on cello_list_sessions / cello_status already let a pasted id match).
2918
+ if (sessionNodeManager.getSessionRecord(agentName, sessionId)) {
2919
+ // The session is THIS agent's — it simply has no seal certificate yet.
2920
+ return {
2921
+ ok: false,
2922
+ reason: "not_sealed_yet",
2923
+ guidance: "This session exists but is not sealed yet. Close it with cello_close_session and confirm it reports sealed (or wait for the counterparty to co-seal), then retry.",
2924
+ };
2925
+ }
2926
+ // Owned by a DIFFERENT loaded agent → the caller has the wrong current agent selected.
2927
+ const owner = loadedAgents.find((a) => a.name !== agentName && sessionNodeManager.getSessionRecord(a.name, sessionId));
2928
+ if (owner) {
2929
+ return {
2930
+ ok: false,
2931
+ reason: "wrong_agent",
2932
+ guidance: `This session belongs to agent '${owner.name}', not the current agent '${agentName}'. Call cello_use_agent('${owner.name}') then retry cello_get_sealed_receipt.`,
2933
+ };
2934
+ }
2935
+ // A truncated paste: the id is a strict prefix of one of this agent's real session ids.
2936
+ const truncated = sessionNodeManager
2937
+ .getSessionsForAgent(agentName)
2938
+ .some((s) => s.session_id.startsWith(sessionId) && s.session_id.length > sessionId.length);
2939
+ if (truncated) {
2767
2940
  return {
2768
2941
  ok: false,
2769
- reason: "sealed_receipt_not_found",
2770
- guidance: "No sealed certificate is recorded for this session. It may not be sealed yet, or the session_id is wrong close it with cello_close_session and confirm it reports sealed, then retry.",
2942
+ reason: "session_id_too_short",
2943
+ guidance: "That looks like a truncated session id. cello_list_sessions and cello status show the FULL idcopy the complete id and retry.",
2771
2944
  };
2772
2945
  }
2773
- return { ok: true, session_id: sessionId, sealed_root: cert.sealed_root, legibility: cert.legibility };
2946
+ return {
2947
+ ok: false,
2948
+ reason: "unknown_session",
2949
+ guidance: "No session with this id belongs to the current agent. Run cello_list_sessions to see the full ids of this agent's sessions.",
2950
+ };
2774
2951
  });
2775
2952
  // DOD-LOG-1 (PERSIST-LOG-001): read the durable, decrypted conversation transcript for a session —
2776
2953
  // the readable sent+received messages in canonical-sequence order, recovered AFTER a daemon restart
@@ -3117,6 +3294,32 @@ export async function startDaemon(config) {
3117
3294
  }
3118
3295
  return { ok: true };
3119
3296
  });
3297
+ // M8C-INBOX-1: test hook to enqueue a pending inbound session request (mirrors the real inbound
3298
+ // flow's enqueueInboundSession) so cello_check_notifications' pending_session_requests is testable
3299
+ // without standing up the full libp2p inbound path.
3300
+ handlers.set("__test_enqueue_inbound_session", async (params, _connectionId) => {
3301
+ const agentName = params?.agentName;
3302
+ const sessionIdHex = params?.sessionId;
3303
+ const counterpartyPubkeyHex = params?.counterpartyPubkey ?? "";
3304
+ if (!agentName || !sessionIdHex) {
3305
+ return { error: "missing_params", guidance: "Provide agentName and sessionId." };
3306
+ }
3307
+ enqueueInboundSession(agentName, { sessionIdHex, counterpartyPubkeyHex, genesisPrevRootHex: "" });
3308
+ return { ok: true };
3309
+ });
3310
+ // M8C-INBOX-1 (reviewer F1): buffer a received message so a test can drive a live cello_receive
3311
+ // and assert the watermark advances (the N3 delivery-marks-read coupling), without a session tree.
3312
+ handlers.set("__test_buffer_received", async (params, _connectionId) => {
3313
+ const agentName = params?.agentName;
3314
+ const sessionId = params?.sessionId;
3315
+ const seq = params?.seq;
3316
+ const content = params?.content ?? "hello";
3317
+ if (!agentName || !sessionId || typeof seq !== "number") {
3318
+ return { error: "missing_params", guidance: "Provide agentName, sessionId, seq." };
3319
+ }
3320
+ sessionNodeManager.pushReceivedContentForTest(agentName, sessionId, seq, content, params?.senderPubkey ?? "cp");
3321
+ return { ok: true };
3322
+ });
3120
3323
  } // end CELLO_ENV=test guard
3121
3324
  // ─── M7-SESSION-001 (H-1): seal-interrupted bilateral RESPONDER ────────────
3122
3325
  //
@@ -4224,6 +4427,9 @@ export async function startDaemon(config) {
4224
4427
  // 1) Deliverable content wins — drain one buffered message per call (FIFO).
4225
4428
  const entry = sessionNodeManager.takeReceivedContent(agentName, sessionId);
4226
4429
  if (entry) {
4430
+ // M8C-INBOX-1 (N3): delivery marks read — advance the persisted read watermark so this
4431
+ // message no longer counts as unread in cello_check_notifications. Monotonic (never lowers).
4432
+ sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, entry.sequenceNumber);
4227
4433
  return {
4228
4434
  ok: true,
4229
4435
  content: Buffer.from(entry.contentHex, "hex").toString("utf8"),
@@ -4271,6 +4477,45 @@ export async function startDaemon(config) {
4271
4477
  };
4272
4478
  handlers.set("cello_receive", handleReceive);
4273
4479
  handlers.set("cello_receive_session", handleReceive);
4480
+ // ─── M8C-INBOX-1 (N1/N4): cello_check_notifications — push-loss reconciler + poll-only inbox ───
4481
+ // Notifications are fire-and-forget (no ack, no redelivery); this is how a client discovers what
4482
+ // it missed while its shim was down/busy, and the primary inbox for poll-only clients (Bedrock,
4483
+ // cron). Content-free: pending session requests (from the in-memory queue, READ non-destructively —
4484
+ // cello_await_session owns draining) + unread message counts (derived from the persisted read
4485
+ // watermark, N2). No separate notification store; no ack verb (N4).
4486
+ handlers.set("cello_check_notifications", async (params, connectionId) => {
4487
+ const connState = perConnectionState.get(connectionId);
4488
+ const scope = params?.scope === "all" ? "all" : "current";
4489
+ let agentNames;
4490
+ if (scope === "all") {
4491
+ agentNames = loadedAgents.map((a) => a.name);
4492
+ }
4493
+ else {
4494
+ // F18: an explicit current agent, else the sole-online agent; ambiguous → no_current_agent.
4495
+ const current = resolveCurrentAgent(connState);
4496
+ if (!current) {
4497
+ return {
4498
+ ok: false,
4499
+ reason: "no_current_agent",
4500
+ guidance: "No current agent for this connection. Call cello_use_agent to select one, or use scope:\"all\" to check every loaded agent.",
4501
+ };
4502
+ }
4503
+ agentNames = [current];
4504
+ }
4505
+ const agents = agentNames.map((agent) => {
4506
+ const pending = (inboundSessionQueues.get(agent) ?? []).map((e) => ({
4507
+ session_id: e.sessionIdHex,
4508
+ from: e.counterpartyPubkeyHex,
4509
+ }));
4510
+ const unread = sessionNodeManager.getUnreadSummary(agent);
4511
+ const total_unread = unread.reduce((sum, u) => sum + u.unread_count, 0);
4512
+ return { agent, pending_session_requests: pending, unread, total_unread };
4513
+ });
4514
+ const totalUnread = agents.reduce((sum, a) => sum + a.total_unread, 0);
4515
+ const totalPending = agents.reduce((sum, a) => sum + a.pending_session_requests.length, 0);
4516
+ logger.info("inbox.checked", { connectionId, scope, agentCount: agents.length, totalUnread, totalPending });
4517
+ return { ok: true, scope, agents };
4518
+ });
4274
4519
  let shutdownPromise = null;
4275
4520
  handlers.set("shutdown", async (_params, _connectionId) => {
4276
4521
  if (!shutdownPromise) {