@cello-protocol/daemon 0.0.30 → 0.0.32

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,CAmkJ7E"}
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,CA0wJ7E"}
package/dist/daemon.js CHANGED
@@ -1254,19 +1254,16 @@ export async function startDaemon(config) {
1254
1254
  return agents
1255
1255
  .filter((a) => a.state !== "load_failed")
1256
1256
  .map((a) => {
1257
- let state;
1258
- if (a.name === currentAgent && onlineAgents.has(a.name)) {
1259
- state = "current";
1260
- }
1261
- else if (onlineAgents.has(a.name)) {
1262
- state = "online";
1263
- }
1264
- else {
1265
- state = "registered";
1266
- }
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;
1267
1263
  return {
1268
1264
  name: a.name,
1269
1265
  state,
1266
+ selected,
1270
1267
  pubkey: a.pubkey,
1271
1268
  // M8B F14 (fix 5): per-agent standing-receiver readiness on the MCP surface
1272
1269
  // (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
@@ -1348,11 +1345,13 @@ export async function startDaemon(config) {
1348
1345
  return { connectionId };
1349
1346
  });
1350
1347
  // ─── MCP-001: cello_start_agent handler ───
1351
- handlers.set("cello_start_agent", async (params, _connectionId) => {
1352
- const name = params?.name;
1353
- if (!name) {
1354
- return { ok: false, reason: "missing_params", guidance: "Provide 'name' parameter with the agent name to start." };
1355
- }
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) {
1356
1355
  const agent = agents.find((a) => a.name === name);
1357
1356
  if (!agent || agent.state === "load_failed") {
1358
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.` };
@@ -1397,6 +1396,29 @@ export async function startDaemon(config) {
1397
1396
  // MCP-002: Broadcast agent_state_changed to ALL connections
1398
1397
  notificationDispatcher.dispatchAgentStateChanged(name, "online", "started");
1399
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);
1400
1422
  });
1401
1423
  // ─── PERSIST-002 (AC-004): cello_create_agent handler ───
1402
1424
  // The explicit agent-creation path: generate a fresh K_local seed, write it as an `agents` row in
@@ -1648,15 +1670,36 @@ export async function startDaemon(config) {
1648
1670
  }
1649
1671
  const agent = agents.find((a) => a.name === name);
1650
1672
  if (!agent || agent.state === "load_failed") {
1651
- return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Check agent names with cello_list_agents.` };
1652
- }
1653
- if (!onlineAgents.has(name)) {
1654
- 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.` };
1655
1674
  }
1656
1675
  const connState = perConnectionState.get(connectionId);
1657
1676
  if (!connState) {
1658
1677
  return { ok: false, reason: "connection_not_registered", guidance: "Send ipc.connect frame before calling agent tools." };
1659
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
+ }
1660
1703
  if (connState.currentAgent === name) {
1661
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.` };
1662
1705
  }
@@ -1666,7 +1709,26 @@ export async function startDaemon(config) {
1666
1709
  notificationDispatcher.setCurrentAgent(connectionId, name);
1667
1710
  notificationDispatcher.dispatchAgentCurrentChanged(connectionId, fromAgent, name);
1668
1711
  logger.info("agent.current.switched", { connectionId, fromAgent, toAgent: name });
1669
- 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;
1670
1732
  });
1671
1733
  // ─── MCP-001: cello_list_agents handler ───
1672
1734
  handlers.set("cello_list_agents", async (_params, connectionId) => {
@@ -2304,7 +2366,7 @@ export async function startDaemon(config) {
2304
2366
  // ─── M8B DOD-REFRESH-1: cello_refresh_shares — proactive share refresh / epoch rollover ───
2305
2367
  handlers.set("cello_refresh_shares", async (params, connectionId) => {
2306
2368
  const connState = perConnectionState.get(connectionId);
2307
- const agentName = params?.name ?? connState?.currentAgent;
2369
+ const agentName = resolveCurrentAgent(connState, params?.name); // M8C-AUTOSTART-1 F18: sole-online fallback
2308
2370
  if (!agentName) {
2309
2371
  return { ok: false, reason: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass { name }." };
2310
2372
  }
@@ -2336,7 +2398,7 @@ export async function startDaemon(config) {
2336
2398
  // ─── M8B DOD-RELAYSIG-1: cello_get_relay_receipts — the agent's stored relay ordering receipts ───
2337
2399
  handlers.set("cello_get_relay_receipts", async (params, connectionId) => {
2338
2400
  const connState = perConnectionState.get(connectionId);
2339
- const agentName = params?.name ?? connState?.currentAgent;
2401
+ const agentName = resolveCurrentAgent(connState, params?.name); // M8C-AUTOSTART-1 F18: sole-online fallback
2340
2402
  if (!agentName) {
2341
2403
  return { ok: false, reason: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass { name }." };
2342
2404
  }
@@ -2845,15 +2907,47 @@ export async function startDaemon(config) {
2845
2907
  if (!connState || !connState.currentAgent) {
2846
2908
  return NO_CURRENT_AGENT_RESPONSE;
2847
2909
  }
2848
- const cert = sessionNodeManager.getSealCertificate(connState.currentAgent, sessionId);
2849
- 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.
2850
2920
  return {
2851
2921
  ok: false,
2852
- reason: "sealed_receipt_not_found",
2853
- 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.",
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.",
2854
2924
  };
2855
2925
  }
2856
- return { ok: true, session_id: sessionId, sealed_root: cert.sealed_root, legibility: cert.legibility };
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) {
2940
+ return {
2941
+ ok: false,
2942
+ reason: "session_id_too_short",
2943
+ guidance: "That looks like a truncated session id. cello_list_sessions and cello status show the FULL id — copy the complete id and retry.",
2944
+ };
2945
+ }
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
+ };
2857
2951
  });
2858
2952
  // DOD-LOG-1 (PERSIST-LOG-001): read the durable, decrypted conversation transcript for a session —
2859
2953
  // the readable sent+received messages in canonical-sequence order, recovered AFTER a daemon restart
@@ -3200,6 +3294,32 @@ export async function startDaemon(config) {
3200
3294
  }
3201
3295
  return { ok: true };
3202
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
+ });
3203
3323
  } // end CELLO_ENV=test guard
3204
3324
  // ─── M7-SESSION-001 (H-1): seal-interrupted bilateral RESPONDER ────────────
3205
3325
  //
@@ -4298,6 +4418,30 @@ export async function startDaemon(config) {
4298
4418
  if (record.agent_name !== agentName) {
4299
4419
  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." };
4300
4420
  }
4421
+ // M8C-SINCESEQ-1: stateless catch-up. When since_seq is provided, return a BATCH of received
4422
+ // transcript messages with sequence > since_seq (durable transcript, not the ephemeral buffer —
4423
+ // so concurrent arrivals don't shift what a given since_seq returns; no replay race). Replaces
4424
+ // the cello_get_transcript workaround for away-then-return. Received-direction only (the messages
4425
+ // you'd have gotten live). Advances the read watermark (delivery marks read — clears INBOX
4426
+ // unread). A distinct early branch: the plain (no since_seq) receive is entirely unchanged.
4427
+ const rawSince = params?.since_seq;
4428
+ if (typeof rawSince === "number" && Number.isFinite(rawSince)) {
4429
+ const sinceSeq = rawSince;
4430
+ const from = record.counterparty_pubkey;
4431
+ const { messages } = sessionNodeManager.readTranscript(agentName, sessionId);
4432
+ const received = messages.filter((m) => m.direction === "received" && m.sequence > sinceSeq);
4433
+ if (received.length > 0) {
4434
+ // readTranscript is ordered by sequence ASC → the last is the max.
4435
+ sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, received[received.length - 1].sequence);
4436
+ }
4437
+ logger.info("session.receive.since_seq", { sessionId, agentName, since_seq: sinceSeq, count: received.length });
4438
+ return {
4439
+ ok: true,
4440
+ since_seq: sinceSeq,
4441
+ count: received.length,
4442
+ messages: received.map((m) => ({ sequence: m.sequence, content: m.text, from })),
4443
+ };
4444
+ }
4301
4445
  const rawTimeout = params?.timeout_ms;
4302
4446
  const timeoutMs = typeof rawTimeout === "number" && Number.isFinite(rawTimeout) && rawTimeout >= 0
4303
4447
  ? rawTimeout
@@ -4307,6 +4451,9 @@ export async function startDaemon(config) {
4307
4451
  // 1) Deliverable content wins — drain one buffered message per call (FIFO).
4308
4452
  const entry = sessionNodeManager.takeReceivedContent(agentName, sessionId);
4309
4453
  if (entry) {
4454
+ // M8C-INBOX-1 (N3): delivery marks read — advance the persisted read watermark so this
4455
+ // message no longer counts as unread in cello_check_notifications. Monotonic (never lowers).
4456
+ sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, entry.sequenceNumber);
4310
4457
  return {
4311
4458
  ok: true,
4312
4459
  content: Buffer.from(entry.contentHex, "hex").toString("utf8"),
@@ -4354,6 +4501,45 @@ export async function startDaemon(config) {
4354
4501
  };
4355
4502
  handlers.set("cello_receive", handleReceive);
4356
4503
  handlers.set("cello_receive_session", handleReceive);
4504
+ // ─── M8C-INBOX-1 (N1/N4): cello_check_notifications — push-loss reconciler + poll-only inbox ───
4505
+ // Notifications are fire-and-forget (no ack, no redelivery); this is how a client discovers what
4506
+ // it missed while its shim was down/busy, and the primary inbox for poll-only clients (Bedrock,
4507
+ // cron). Content-free: pending session requests (from the in-memory queue, READ non-destructively —
4508
+ // cello_await_session owns draining) + unread message counts (derived from the persisted read
4509
+ // watermark, N2). No separate notification store; no ack verb (N4).
4510
+ handlers.set("cello_check_notifications", async (params, connectionId) => {
4511
+ const connState = perConnectionState.get(connectionId);
4512
+ const scope = params?.scope === "all" ? "all" : "current";
4513
+ let agentNames;
4514
+ if (scope === "all") {
4515
+ agentNames = loadedAgents.map((a) => a.name);
4516
+ }
4517
+ else {
4518
+ // F18: an explicit current agent, else the sole-online agent; ambiguous → no_current_agent.
4519
+ const current = resolveCurrentAgent(connState);
4520
+ if (!current) {
4521
+ return {
4522
+ ok: false,
4523
+ reason: "no_current_agent",
4524
+ guidance: "No current agent for this connection. Call cello_use_agent to select one, or use scope:\"all\" to check every loaded agent.",
4525
+ };
4526
+ }
4527
+ agentNames = [current];
4528
+ }
4529
+ const agents = agentNames.map((agent) => {
4530
+ const pending = (inboundSessionQueues.get(agent) ?? []).map((e) => ({
4531
+ session_id: e.sessionIdHex,
4532
+ from: e.counterpartyPubkeyHex,
4533
+ }));
4534
+ const unread = sessionNodeManager.getUnreadSummary(agent);
4535
+ const total_unread = unread.reduce((sum, u) => sum + u.unread_count, 0);
4536
+ return { agent, pending_session_requests: pending, unread, total_unread };
4537
+ });
4538
+ const totalUnread = agents.reduce((sum, a) => sum + a.total_unread, 0);
4539
+ const totalPending = agents.reduce((sum, a) => sum + a.pending_session_requests.length, 0);
4540
+ logger.info("inbox.checked", { connectionId, scope, agentCount: agents.length, totalUnread, totalPending });
4541
+ return { ok: true, scope, agents };
4542
+ });
4357
4543
  let shutdownPromise = null;
4358
4544
  handlers.set("shutdown", async (_params, _connectionId) => {
4359
4545
  if (!shutdownPromise) {
@@ -4389,6 +4575,12 @@ export async function startDaemon(config) {
4389
4575
  sessionNodeManager.setOnSessionStateChanged((agentName, sessionId, state, counterpartyPubkey) => {
4390
4576
  notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey);
4391
4577
  });
4578
+ // M8C-MSGWAKE-1 (channel stage 2): per-message wake — a verified inbound message fires a
4579
+ // content-free `cello_message` doorbell to the current-agent connection(s). The shim's generic
4580
+ // bridge (WAKE) forwards it to a live --channels session as notifications/claude/channel.
4581
+ sessionNodeManager.setOnContentArrived((agentName, sessionId, senderPubkey) => {
4582
+ notificationDispatcher.dispatchCelloMessage(agentName, sessionId, senderPubkey);
4583
+ });
4392
4584
  // MCP-001: Clean up per-connection state when a connection disconnects
4393
4585
  // MCP-002: Also unregister from notification dispatcher
4394
4586
  ipcServer.onDisconnect((connectionId) => {