@cello-protocol/daemon 0.0.19 → 0.0.21

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,EAQrB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAM/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAqB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAM/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAqInF,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,CAg6H7E"}
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;AAqB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAM/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAqInF,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,CAwjI7E"}
package/dist/daemon.js CHANGED
@@ -983,7 +983,14 @@ export async function startDaemon(config) {
983
983
  else {
984
984
  state = "registered";
985
985
  }
986
- return { name: a.name, state, pubkey: a.pubkey };
986
+ return {
987
+ name: a.name,
988
+ state,
989
+ pubkey: a.pubkey,
990
+ // M8B F14 (fix 5): per-agent standing-receiver readiness on the MCP surface
991
+ // (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
992
+ standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
993
+ };
987
994
  });
988
995
  }
989
996
  // M7-SESSION-001 AC-006/AC-007 (and M-1 PULL): build the interrupted_sessions
@@ -1009,6 +1016,18 @@ export async function startDaemon(config) {
1009
1016
  interruptedAt: row.interrupted_at ?? new Date(row.updated_at).toISOString(),
1010
1017
  }));
1011
1018
  }
1019
+ // M8B F16: per-session liveness for ACTIVE sessions, shared by both status surfaces
1020
+ // ("status" for the CLI, "cello_status" for MCP). The signal (session.liveness.changed,
1021
+ // tracked in the node manager) existed but nothing consumed it — a dead counterparty
1022
+ // was invisible to the operator.
1023
+ function buildActiveSessions() {
1024
+ return sessionNodeManager.getSessionsByStatus("active").map((row) => ({
1025
+ sessionId: row.session_id,
1026
+ agentName: row.agent_name,
1027
+ counterpartyPubkey: row.counterparty_pubkey,
1028
+ liveness: sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id),
1029
+ }));
1030
+ }
1012
1031
  // Build status response factory
1013
1032
  function getStatus() {
1014
1033
  // M7-SESSION-001 AC-006/AC-007: surface interrupted sessions
@@ -1016,11 +1035,19 @@ export async function startDaemon(config) {
1016
1035
  return {
1017
1036
  daemon: "running",
1018
1037
  directory_signaling: directorySignalingStatus(),
1019
- agents,
1038
+ // M8B F14 (fix 5): per-agent standing-receiver readiness, so a deaf agent (online but
1039
+ // no armed receiver) is visible in cello_status instead of hiding behind the ANY-agent
1040
+ // aggregate below (kept for backward compatibility).
1041
+ agents: agents.map((a) => ({
1042
+ ...a,
1043
+ standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
1044
+ })),
1020
1045
  connections,
1021
1046
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
1022
1047
  retryQueueDepth: retryQueue.getTotalDepth(),
1023
1048
  interrupted_sessions,
1049
+ // M8B F16: per-session liveness so a counterparty-gone session is visible.
1050
+ active_sessions: buildActiveSessions(),
1024
1051
  };
1025
1052
  }
1026
1053
  // Register IPC handlers
@@ -1576,7 +1603,14 @@ export async function startDaemon(config) {
1576
1603
  logger.error("session.sealed.signature.invalid", { sessionId: sidHex, reason: verdict.reason });
1577
1604
  return;
1578
1605
  }
1579
- logger.info("session.sealed.signature.checked", { sessionId: sidHex, verified: verdict.verified });
1606
+ // F2-a: on verified:false, surface WHY (signer_key_not_held / no_frost_share / …) so this
1607
+ // event can never be mistaken for a tolerated failed check. A real failure took the early
1608
+ // return above (session.sealed.signature.invalid) and never reaches here.
1609
+ logger.info("session.sealed.signature.checked", {
1610
+ sessionId: sidHex,
1611
+ verified: verdict.verified,
1612
+ ...(verdict.verified ? {} : { reason: verdict.reason }),
1613
+ });
1580
1614
  }
1581
1615
  // M7-SESSION-004 (AC-005): normalise the wire legibility (Uint8Array pubkeys → hex) into a
1582
1616
  // JSON-safe certificate and persist it with the sealed record so it survives a restart and
@@ -1690,7 +1724,14 @@ export async function startDaemon(config) {
1690
1724
  return;
1691
1725
  if (ftype === "seal_unilateral_too_early") {
1692
1726
  pendingUnilateralWaiters.delete(sidHex);
1693
- waiter({ ok: false, reason: "seal_unilateral_too_early" });
1727
+ // F20: thread the directory's remaining_seconds through so the close guidance can
1728
+ // say when the unilateral seal becomes available.
1729
+ const rs = frame["remaining_seconds"];
1730
+ waiter({
1731
+ ok: false,
1732
+ reason: "seal_unilateral_too_early",
1733
+ ...(typeof rs === "number" ? { remainingSeconds: rs } : {}),
1734
+ });
1694
1735
  return;
1695
1736
  }
1696
1737
  void (async () => {
@@ -1870,6 +1911,8 @@ export async function startDaemon(config) {
1870
1911
  // M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
1871
1912
  // the daemon-wide getStatus() surfaces them.
1872
1913
  interrupted_sessions: buildInterruptedSessions(),
1914
+ // M8B F16: per-session liveness on the MCP surface too.
1915
+ active_sessions: buildActiveSessions(),
1873
1916
  };
1874
1917
  });
1875
1918
  // ─── MCP-001: no_current_agent guard for session tools ───
@@ -1878,9 +1921,10 @@ export async function startDaemon(config) {
1878
1921
  // NOTE: cello_await_session is NOT in this stub list — Seam 2 registers a real
1879
1922
  // handler for it below (inbound session establishment), with its own inline
1880
1923
  // no_current_agent guard.
1881
- const SESSION_TOOLS_REQUIRING_AGENT = [
1882
- "cello_receive_session",
1883
- ];
1924
+ // F1-a2: cello_receive_session is NO LONGER stubbed here — it is registered below as a true
1925
+ // alias of the real (blocking) cello_receive handler. Kept as an (empty) extension point for
1926
+ // future tools that need the plain no_current_agent guard without their own handler.
1927
+ const SESSION_TOOLS_REQUIRING_AGENT = [];
1884
1928
  const NO_CURRENT_AGENT_RESPONSE = {
1885
1929
  ok: false,
1886
1930
  reason: "no_current_agent",
@@ -1937,6 +1981,25 @@ export async function startDaemon(config) {
1937
1981
  // SI-001: the selector consumes the assignment's signed transport_mode as the
1938
1982
  // sole dial authority — never inferred from address format.
1939
1983
  const assignment = negotiation.assignment;
1984
+ // M8B F13: validate-what-you-receive at the trust boundary. When the responder cannot
1985
+ // proceed it aborts SILENTLY (session-ceremony.ts wireSessionOfferHandler sends nothing)
1986
+ // and the directory folds an EMPTY counterparty endpoint into the FROST-signed
1987
+ // assignment. Accepting that assignment produced a false ok:true + a phantom session
1988
+ // whose failure only surfaced on the first cello_send. Reject it HERE — before any
1989
+ // local session state exists — covering every cause of a missing accept (abort,
1990
+ // offline, crash, timeout) without needing a directory change.
1991
+ if (!assignment.counterparty_session_peer_id) {
1992
+ logger.warn("session.initiate.counterparty_unavailable", {
1993
+ sessionId: Buffer.from(assignment.session_id).toString("hex"),
1994
+ agentName,
1995
+ correlationId,
1996
+ });
1997
+ return {
1998
+ ok: false,
1999
+ reason: "counterparty_unavailable",
2000
+ guidance: "The counterparty did not accept the session offer (it may be offline or unable to receive). No session was established. Verify the counterparty agent is online (its operator can check cello_status), then retry cello_initiate_session.",
2001
+ };
2002
+ }
1940
2003
  const result = await transportSelector.dial(assignment, { correlationId });
1941
2004
  const sessionId = Buffer.from(assignment.session_id).toString("hex");
1942
2005
  if (!result.ok) {
@@ -2147,12 +2210,23 @@ export async function startDaemon(config) {
2147
2210
  // per-party frontiers, attestation modes, and final_message.answered.
2148
2211
  return { ok: true, sealed_root: sealedCompletion.rootHex, legibility: sealedCompletion.legibility };
2149
2212
  }
2150
- // M7-UPGRADE-002: if THIS close fell through via the auto-ack 'already submitted' path, we
2151
- // hold no local reported_root to escalate with and we should not need to: the
2152
- // counterparty's SEAL ctrl leaf is what triggered our auto-ack, so its seal is already on
2153
- // the relay and the bilateral seal should finalize. A timeout here is unexpected; report it
2154
- // as pending rather than escalating to a unilateral seal with no root.
2155
- if (!submit.ok) {
2213
+ // M8B FINDING-1: `responder_seal_already_submitted` has two producers (a) the auto-ack
2214
+ // path submitted our leaf because the COUNTERPARTY's SEAL arrived, or (b) our OWN earlier
2215
+ // close submitted it and the counterparty never co-closed. The result now carries the
2216
+ // first submit's reportedRootHex/sequenceNumber, so a retry close can still escalate to a
2217
+ // unilateral seal (case b the live-deadlock path). We deliberately do NOT distinguish
2218
+ // (a) from (b) client-side: the bilateral wait above already gave case (a) its window, and
2219
+ // the directory's grace/already-sealed gates arbitrate a redundant seal_unilateral — that
2220
+ // is the sovereign-node-correct shape. Only when the not-ok result carries NO root (the
2221
+ // first submit is still in flight) is "pending" the honest terminal answer here.
2222
+ const escalation = submit.ok
2223
+ ? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
2224
+ : submit.reason === "responder_seal_already_submitted" &&
2225
+ typeof submit.reportedRootHex === "string" &&
2226
+ typeof submit.sequenceNumber === "number"
2227
+ ? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
2228
+ : null;
2229
+ if (!escalation) {
2156
2230
  return {
2157
2231
  ok: false,
2158
2232
  reason: "seal_pending_bilateral",
@@ -2185,8 +2259,8 @@ export async function startDaemon(config) {
2185
2259
  const sent = await sendOver(record.agent_name, {
2186
2260
  type: "seal_unilateral",
2187
2261
  session_id: new Uint8Array(Buffer.from(sessionId, "hex")),
2188
- reported_root: new Uint8Array(Buffer.from(submit.reportedRootHex, "hex")),
2189
- reported_seq: submit.sequenceNumber,
2262
+ reported_root: new Uint8Array(Buffer.from(escalation.reportedRootHex, "hex")),
2263
+ reported_seq: escalation.sequenceNumber,
2190
2264
  seal_leaves,
2191
2265
  });
2192
2266
  if (!sent.ok) {
@@ -2209,10 +2283,14 @@ export async function startDaemon(config) {
2209
2283
  return { ok: true, sealed_root: uniResult.sealedRootHex, seal_type: "unilateral" };
2210
2284
  }
2211
2285
  if (uniResult.reason === "seal_unilateral_too_early") {
2286
+ // F20: tell the operator WHEN the unilateral seal becomes available, not just "later".
2287
+ const when = typeof uniResult.remainingSeconds === "number"
2288
+ ? `A unilateral seal becomes available in ~${uniResult.remainingSeconds}s. `
2289
+ : "";
2212
2290
  return {
2213
2291
  ok: false,
2214
2292
  reason: "seal_counterparty_pending",
2215
- guidance: "Your SEAL leaf is recorded, but the counterparty has not closed and the directory's delivery-grace window has not yet elapsed, so a unilateral seal is not yet allowed. Retry cello_close_session after the grace period, or once the counterparty closes.",
2293
+ guidance: `Your SEAL leaf is recorded, but the counterparty has not closed and the directory's delivery-grace window has not yet elapsed, so a unilateral seal is not yet allowed. ${when}Retry cello_close_session after the grace period, or once the counterparty closes.`,
2216
2294
  };
2217
2295
  }
2218
2296
  return {
@@ -2907,6 +2985,17 @@ export async function startDaemon(config) {
2907
2985
  }
2908
2986
  async function acceptInboundAssignment(parsed, agentName, correlationId) {
2909
2987
  try {
2988
+ // M8B F14 (fix 2): KICK creation before polling — an inbound offer arriving while no
2989
+ // receiver exists and none is being created must trigger the ensure itself (the doc
2990
+ // comment's "retries on demand" made true), instead of polling a creation nobody
2991
+ // started and dropping the offer. Fire-and-forget: the poll below observes readiness.
2992
+ void sessionNodeManager.ensureStandingReceiverForAgent(agentName).catch((err) => {
2993
+ logger.warn("session.standing_receiver.ensure.failed", {
2994
+ agentName,
2995
+ reason: err instanceof Error ? err.message : String(err),
2996
+ correlationId,
2997
+ });
2998
+ });
2910
2999
  // M2: do not drop the session if this agent's standing receiver is mid-rebuild.
2911
3000
  const ready = await waitForStandingReceiver(agentName);
2912
3001
  if (!ready) {
@@ -3030,11 +3119,15 @@ export async function startDaemon(config) {
3030
3119
  // is NOT yet verified here. The old client's receiveSessionAssignment performed that
3031
3120
  // check; it must be re-homed natively before this path faces a real (untrusted)
3032
3121
  // directory. Until then we accept directory-pushed assignments on trust — the in-process
3033
- // seam tests inject trusted frames. This is logged loudly, never silent.
3034
- logger.warn("session.inbound.assignment.unverified", {
3122
+ // seam tests inject trusted frames.
3123
+ // M8B F15: logged at DEBUG with an "expected" note — it fires on EVERY healthy inbound
3124
+ // session, and at WARN it read as a failure and actively misled live diagnosis. The
3125
+ // deferred-verification state is a known, tracked gap (SESSION-004), not a per-session
3126
+ // anomaly.
3127
+ logger.debug("session.inbound.assignment.unverified", {
3035
3128
  sessionId: parsed.sessionIdHex,
3036
3129
  agentName: localAgent.name,
3037
- note: "FROST assignment signature verification deferred to SESSION-004 re-home",
3130
+ note: "FROST assignment signature verification deferred to SESSION-004 re-home (expected on every inbound session until SESSION-004)",
3038
3131
  correlationId,
3039
3132
  });
3040
3133
  inboundInFlight.add(parsed.sessionIdHex);
@@ -3671,35 +3764,87 @@ export async function startDaemon(config) {
3671
3764
  void newRootHex;
3672
3765
  return { ok: true, sequence_number: leafIndex };
3673
3766
  });
3674
- // ─── CELLO-M7-DAEMON-004: cello_receive (returns from the daemon's own buffer) ──
3675
- handlers.set("cello_receive", async (params, connectionId) => {
3767
+ // ─── CELLO-M7-DAEMON-004 / F1-a: cello_receive (BLOCKING, session-scoped) ────────
3768
+ // F1-a fix: the daemon port had dropped the blocking receive (the handler was a
3769
+ // non-blocking buf.shift and cello_receive_session was a not_implemented stub). It now
3770
+ // BLOCKS up to timeout_ms, polling the received-content buffer — resolved by the next
3771
+ // arrival, a terminal seal answer (F1-b), or timeout. This is the "blocking receive
3772
+ // variant" the guidance names. Registered under both cello_receive and cello_receive_session
3773
+ // (F1-a2: one implementation, both names — the redundant tools are collapsed).
3774
+ const RECEIVE_DEFAULT_TIMEOUT_MS = 30000; // matches the cello-mcp shim's documented default
3775
+ const handleReceive = async (params, connectionId) => {
3676
3776
  const connState = perConnectionState.get(connectionId);
3677
3777
  if (!connState || !connState.currentAgent)
3678
3778
  return NO_CURRENT_AGENT_RESPONSE;
3679
- // round-2 BLOCKING: read the snake_case public field cello-mcp.ts actually sends.
3779
+ const agentName = connState.currentAgent;
3780
+ // Read the snake_case public field cello-mcp.ts actually sends.
3680
3781
  const sessionId = params?.session_id;
3681
3782
  if (!sessionId) {
3682
3783
  return { ok: false, reason: "missing_params", guidance: "Provide 'session_id' (hex) to receive content for a specific session." };
3683
3784
  }
3684
- const record = sessionNodeManager.getSessionRecord(connState.currentAgent, sessionId);
3785
+ const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
3685
3786
  if (!record) {
3686
3787
  return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check cello_list_sessions." };
3687
3788
  }
3688
- if (record.agent_name !== connState.currentAgent) {
3789
+ if (record.agent_name !== agentName) {
3689
3790
  return { ok: false, reason: "session_not_owned", guidance: "This session belongs to a different agent. Call cello_use_agent to switch to the agent that owns it, then retry." };
3690
3791
  }
3691
- const entry = sessionNodeManager.takeReceivedContent(connState.currentAgent, sessionId);
3692
- if (!entry) {
3693
- return { ok: true, content: null, guidance: "No content is currently buffered for this session. Call cello_receive again after the counterparty sends, or use the blocking receive variant." };
3792
+ const rawTimeout = params?.timeout_ms;
3793
+ const timeoutMs = typeof rawTimeout === "number" && Number.isFinite(rawTimeout) && rawTimeout >= 0
3794
+ ? rawTimeout
3795
+ : RECEIVE_DEFAULT_TIMEOUT_MS;
3796
+ const deadline = Date.now() + timeoutMs;
3797
+ for (;;) {
3798
+ // 1) Deliverable content wins — drain one buffered message per call (FIFO).
3799
+ const entry = sessionNodeManager.takeReceivedContent(agentName, sessionId);
3800
+ if (entry) {
3801
+ return {
3802
+ ok: true,
3803
+ content: Buffer.from(entry.contentHex, "hex").toString("utf8"),
3804
+ sessionId,
3805
+ sequence_number: entry.sequenceNumber,
3806
+ senderPubkey: entry.senderPubkey,
3807
+ };
3808
+ }
3809
+ // 2) F1-b: the session sealed while we were (or before we started) waiting — return the
3810
+ // terminal answer instead of hanging to timeout. unread_count reports messages that
3811
+ // were evicted unread (still durable — recoverable via cello_get_transcript).
3812
+ const terminal = sessionNodeManager.peekTerminalMarker(agentName, sessionId);
3813
+ if (terminal) {
3814
+ const sealedRoot = sessionNodeManager.getSealedRootHex(agentName, sessionId);
3815
+ return {
3816
+ ok: true,
3817
+ type: "session_sealed",
3818
+ session_id: sessionId,
3819
+ ...(sealedRoot ? { sealed_root: sealedRoot } : {}),
3820
+ unread_count: terminal.unreadCount,
3821
+ guidance: terminal.unreadCount > 0
3822
+ ? `The session has been sealed by both parties. ${terminal.unreadCount} message(s) arrived that were not read live — call cello_get_transcript to retrieve the full sealed history. No further actions are required on this session.`
3823
+ : "The session has been sealed by both parties. The full history is available via cello_get_transcript. No further actions are required on this session.",
3824
+ };
3825
+ }
3826
+ // 3) Out of time — non-blocking-equivalent empty answer.
3827
+ const remaining = deadline - Date.now();
3828
+ if (remaining <= 0) {
3829
+ // M8B F16: a dead session must not return the SAME null timeout as a
3830
+ // quiet-but-healthy one. The liveness signal (session.liveness.changed → gone,
3831
+ // tracked per session by the node manager) finally reaches the MCP surface here.
3832
+ if (sessionNodeManager.getSessionLiveness(agentName, sessionId) === "gone") {
3833
+ return {
3834
+ ok: true,
3835
+ content: null,
3836
+ reason: "counterparty_gone",
3837
+ liveness: "gone",
3838
+ guidance: "The counterparty's session connection has dropped (liveness: gone) — it may have crashed or gone offline. No more content will arrive on the direct path. Call cello_close_session to seal the session; if the counterparty never co-closes, a unilateral seal becomes available after the directory's delivery-grace window.",
3839
+ };
3840
+ }
3841
+ return { ok: true, content: null, guidance: "No content arrived within timeout_ms. Call cello_receive again to keep waiting, or read cello_get_transcript for the full session history." };
3842
+ }
3843
+ await new Promise((r) => setTimeout(r, Math.min(20, remaining)));
3694
3844
  }
3695
- return {
3696
- ok: true,
3697
- content: Buffer.from(entry.contentHex, "hex").toString("utf8"),
3698
- sessionId,
3699
- sequence_number: entry.sequenceNumber,
3700
- senderPubkey: entry.senderPubkey,
3701
- };
3702
- });
3845
+ };
3846
+ handlers.set("cello_receive", handleReceive);
3847
+ handlers.set("cello_receive_session", handleReceive);
3703
3848
  let shutdownPromise = null;
3704
3849
  handlers.set("shutdown", async (_params, _connectionId) => {
3705
3850
  if (!shutdownPromise) {