@cello-protocol/daemon 0.0.20 → 0.0.22

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,CAi9H7E"}
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
@@ -1697,7 +1724,14 @@ export async function startDaemon(config) {
1697
1724
  return;
1698
1725
  if (ftype === "seal_unilateral_too_early") {
1699
1726
  pendingUnilateralWaiters.delete(sidHex);
1700
- 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
+ });
1701
1735
  return;
1702
1736
  }
1703
1737
  void (async () => {
@@ -1877,6 +1911,8 @@ export async function startDaemon(config) {
1877
1911
  // M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
1878
1912
  // the daemon-wide getStatus() surfaces them.
1879
1913
  interrupted_sessions: buildInterruptedSessions(),
1914
+ // M8B F16: per-session liveness on the MCP surface too.
1915
+ active_sessions: buildActiveSessions(),
1880
1916
  };
1881
1917
  });
1882
1918
  // ─── MCP-001: no_current_agent guard for session tools ───
@@ -1945,6 +1981,25 @@ export async function startDaemon(config) {
1945
1981
  // SI-001: the selector consumes the assignment's signed transport_mode as the
1946
1982
  // sole dial authority — never inferred from address format.
1947
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
+ }
1948
2003
  const result = await transportSelector.dial(assignment, { correlationId });
1949
2004
  const sessionId = Buffer.from(assignment.session_id).toString("hex");
1950
2005
  if (!result.ok) {
@@ -2155,12 +2210,23 @@ export async function startDaemon(config) {
2155
2210
  // per-party frontiers, attestation modes, and final_message.answered.
2156
2211
  return { ok: true, sealed_root: sealedCompletion.rootHex, legibility: sealedCompletion.legibility };
2157
2212
  }
2158
- // M7-UPGRADE-002: if THIS close fell through via the auto-ack 'already submitted' path, we
2159
- // hold no local reported_root to escalate with and we should not need to: the
2160
- // counterparty's SEAL ctrl leaf is what triggered our auto-ack, so its seal is already on
2161
- // the relay and the bilateral seal should finalize. A timeout here is unexpected; report it
2162
- // as pending rather than escalating to a unilateral seal with no root.
2163
- 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) {
2164
2230
  return {
2165
2231
  ok: false,
2166
2232
  reason: "seal_pending_bilateral",
@@ -2193,8 +2259,8 @@ export async function startDaemon(config) {
2193
2259
  const sent = await sendOver(record.agent_name, {
2194
2260
  type: "seal_unilateral",
2195
2261
  session_id: new Uint8Array(Buffer.from(sessionId, "hex")),
2196
- reported_root: new Uint8Array(Buffer.from(submit.reportedRootHex, "hex")),
2197
- reported_seq: submit.sequenceNumber,
2262
+ reported_root: new Uint8Array(Buffer.from(escalation.reportedRootHex, "hex")),
2263
+ reported_seq: escalation.sequenceNumber,
2198
2264
  seal_leaves,
2199
2265
  });
2200
2266
  if (!sent.ok) {
@@ -2217,10 +2283,14 @@ export async function startDaemon(config) {
2217
2283
  return { ok: true, sealed_root: uniResult.sealedRootHex, seal_type: "unilateral" };
2218
2284
  }
2219
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
+ : "";
2220
2290
  return {
2221
2291
  ok: false,
2222
2292
  reason: "seal_counterparty_pending",
2223
- 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.`,
2224
2294
  };
2225
2295
  }
2226
2296
  return {
@@ -2915,6 +2985,17 @@ export async function startDaemon(config) {
2915
2985
  }
2916
2986
  async function acceptInboundAssignment(parsed, agentName, correlationId) {
2917
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
+ });
2918
2999
  // M2: do not drop the session if this agent's standing receiver is mid-rebuild.
2919
3000
  const ready = await waitForStandingReceiver(agentName);
2920
3001
  if (!ready) {
@@ -3038,11 +3119,15 @@ export async function startDaemon(config) {
3038
3119
  // is NOT yet verified here. The old client's receiveSessionAssignment performed that
3039
3120
  // check; it must be re-homed natively before this path faces a real (untrusted)
3040
3121
  // directory. Until then we accept directory-pushed assignments on trust — the in-process
3041
- // seam tests inject trusted frames. This is logged loudly, never silent.
3042
- 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", {
3043
3128
  sessionId: parsed.sessionIdHex,
3044
3129
  agentName: localAgent.name,
3045
- 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)",
3046
3131
  correlationId,
3047
3132
  });
3048
3133
  inboundInFlight.add(parsed.sessionIdHex);
@@ -3741,6 +3826,18 @@ export async function startDaemon(config) {
3741
3826
  // 3) Out of time — non-blocking-equivalent empty answer.
3742
3827
  const remaining = deadline - Date.now();
3743
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
+ }
3744
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." };
3745
3842
  }
3746
3843
  await new Promise((r) => setTimeout(r, Math.min(20, remaining)));