@cello-protocol/daemon 0.0.34 → 0.0.35

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;AAQ/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;AA2CD,eAAO,MAAM,sBAAsB,QAAsB,CAAC;AAE1D,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA01K7E"}
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;AAQ/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;AA2CD,eAAO,MAAM,sBAAsB,QAAsB,CAAC;AAE1D,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA+7K7E"}
package/dist/daemon.js CHANGED
@@ -463,8 +463,6 @@ export async function startDaemon(config) {
463
463
  assignment: carry,
464
464
  };
465
465
  };
466
- // Stub: all connections marked as 'unverified' until connection validation is wired
467
- const connections = [];
468
466
  // M7-SIGNAL-001: Instantiate SignalingManager — owns directory signaling stream lifecycle.
469
467
  const defaultConnect = async () => {
470
468
  throw new Error("directory_signaling_not_configured");
@@ -818,10 +816,12 @@ export async function startDaemon(config) {
818
816
  const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
819
817
  if (!record || record.status !== "active")
820
818
  return;
821
- // M8C-CONTACT-1: this check MUST run synchronously (before any await below) the caller of
822
- // sendAwayResponse for a fresh inbound request fires this THEN calls addContact right after
823
- // (D6: "accepting X's request adds X"), relying on this line executing first so a stranger's
824
- // VERY FIRST contact is correctly judged unknown, not already-added.
819
+ // M8C-CONTACT-1 / CC-1: select the ack wording by whether the sender is a known contact
820
+ // STRANGER_TEXT ("Dispatched.") for unknown, AWAY_TEXT[kind] for known. Post-CC-1 the inbound
821
+ // accept path no longer auto-adds the sender, so an unattended stranger STAYS unknown across
822
+ // every inbound interaction (promotion now requires operator engagement — an outbound initiate,
823
+ // a cello_send reply, or an explicit contact add). No subsequent add is coupled to this line's
824
+ // ordering anymore; it is a plain read of current contact state.
825
825
  const isKnown = sessionNodeManager.isContact(agentName, record.counterparty_pubkey);
826
826
  awayAckSent.add(dedupKey); // guard BEFORE the async send — concurrent arrivals must not double-ack
827
827
  try {
@@ -1555,11 +1555,39 @@ export async function startDaemon(config) {
1555
1555
  interruptedAt: row.interrupted_at ?? new Date(row.updated_at).toISOString(),
1556
1556
  }));
1557
1557
  }
1558
+ // CC-5/F21: reap dead half-open sessions on READ (compute-on-read, like reapExpiredInboundSessions —
1559
+ // no background timer). A session the standing receiver opened from an inbound offer the initiator
1560
+ // ABANDONED stays "active" forever (the counterparty never joins), clutters the open/active lists, and
1561
+ // its normal close fires an unsealable bilateral seal. Mark such a session terminal ("abandoned") once
1562
+ // it is PROVABLY dead: status active + counterparty never established (liveness != "alive" AND 0
1563
+ // RECEIVED messages — message_count alone counts our own auto-"Dispatched." ack, so it is NOT the
1564
+ // signal) + age past the grace TTL (a genuinely fresh session still setting up must survive).
1565
+ const HALF_OPEN_TTL_MS = Number(process.env["CELLO_HALF_OPEN_TTL_MS"]) || 5 * 60 * 1000;
1566
+ function reapDeadHalfOpenSessions(agentName) {
1567
+ const now = Date.now();
1568
+ for (const row of sessionNodeManager.getSessionsByStatus("active")) {
1569
+ if (agentName !== undefined && row.agent_name !== agentName)
1570
+ continue;
1571
+ if (now - row.created_at <= HALF_OPEN_TTL_MS)
1572
+ continue; // too young — may just be setting up
1573
+ if (sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id) === "alive")
1574
+ continue; // live
1575
+ if (sessionNodeManager.countReceivedMessages(row.agent_name, row.session_id) > 0)
1576
+ continue; // counterparty spoke
1577
+ // Non-awaited: abandonSession flips the DB status synchronously (before its first await), so THIS
1578
+ // read reflects it; the async node teardown finishes in the background.
1579
+ void sessionNodeManager.abandonSession(row.agent_name, row.session_id).catch((err) => {
1580
+ logger.warn("session.half_open.reap.failed", { agentName: row.agent_name, sessionId: row.session_id, reason: err instanceof Error ? err.message : String(err) });
1581
+ });
1582
+ logger.info("session.half_open.reaped", { agentName: row.agent_name, sessionId: row.session_id, ageMs: now - row.created_at });
1583
+ }
1584
+ }
1558
1585
  // M8B F16: per-session liveness for ACTIVE sessions, shared by both status surfaces
1559
1586
  // ("status" for the CLI, "cello_status" for MCP). The signal (session.liveness.changed,
1560
1587
  // tracked in the node manager) existed but nothing consumed it — a dead counterparty
1561
1588
  // was invisible to the operator.
1562
1589
  function buildActiveSessions() {
1590
+ reapDeadHalfOpenSessions(); // CC-5/F21: drop provably-dead half-open sessions before surfacing active ones
1563
1591
  return sessionNodeManager.getSessionsByStatus("active").map((row) => ({
1564
1592
  sessionId: row.session_id,
1565
1593
  agentName: row.agent_name,
@@ -1577,11 +1605,22 @@ export async function startDaemon(config) {
1577
1605
  // M8B F14 (fix 5): per-agent standing-receiver readiness, so a deaf agent (online but
1578
1606
  // no armed receiver) is visible in cello_status instead of hiding behind the ANY-agent
1579
1607
  // aggregate below (kept for backward compatibility).
1580
- agents: agents.map((a) => ({
1581
- ...a,
1582
- standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
1583
- })),
1584
- connections,
1608
+ // CC-8 (F5 parity): the CLI `cello status` surface must show online vs registered like the MCP
1609
+ // cello_status does. The stored `a.state` is stale — it stays "registered" even when the agent is
1610
+ // online, because startAgentInternal only adds to onlineAgents and never mutates the record — so
1611
+ // derive readiness from onlineAgents here, exactly as getAgentsForConnection (F5) already does for
1612
+ // the MCP surface. A load_failed agent keeps its state so a broken agent stays visible as broken.
1613
+ // (No `selected` here: this is the daemon-wide surface; selection is a per-connection concept and
1614
+ // the CLI opens an ephemeral connection that never runs cello_use_agent — see M8C-DECISIONS D24.)
1615
+ agents: agents.map((a) => {
1616
+ const online = onlineAgents.has(a.name);
1617
+ const state = a.state === "load_failed" ? "load_failed" : online ? "online" : "registered";
1618
+ return {
1619
+ ...a,
1620
+ state,
1621
+ standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
1622
+ };
1623
+ }),
1585
1624
  standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
1586
1625
  retryQueueDepth: retryQueue.getTotalDepth(),
1587
1626
  interrupted_sessions,
@@ -2127,6 +2166,23 @@ export async function startDaemon(config) {
2127
2166
  // committed at this point (RegistrationManager awaits the persist before returning success).
2128
2167
  // SI-001: never log a secret — only the agent name + PUBLIC key.
2129
2168
  logger.info("persist.identity.persisted", { agentName: name, agentPubkey: agentPubkeyHex });
2169
+ // CC-2 (2026-07-07): registration succeeded — arm this agent's standing receiver NOW so a
2170
+ // brand-new agent can receive inbound immediately. Without this the agent reports
2171
+ // standing_receiver_ready:false and cannot receive until the operator restarts (logout/login),
2172
+ // so a fresh registration looks broken. Uses the SAME idempotent path login and cello_use_agent
2173
+ // arm through (startAgentInternal → onlineAgents + directory signaling + ensureStandingReceiver +
2174
+ // agent_state_changed). A start failure must NOT fail the (already durably persisted)
2175
+ // registration — surface it as a warning and let the operator recover via login.
2176
+ const armResult = startAgentInternal(name);
2177
+ if (!armResult.ok) {
2178
+ logger.warn("registration.standing_receiver.arm_failed", { agentName: name, reason: armResult.reason });
2179
+ }
2180
+ else {
2181
+ // arm_INITIATED, not armed: startAgentInternal returns ok once the agent is online + signaling
2182
+ // is up, but ensureStandingReceiverForAgent runs fire-and-forget (its own failure emits
2183
+ // session.standing_receiver.ensure.failed) — so this event marks the start, not readiness.
2184
+ logger.info("registration.standing_receiver.arm_initiated", { agentName: name });
2185
+ }
2130
2186
  // Capture-now-or-lose-it: persist the agent→user link (using it is future
2131
2187
  // trust-layer work). L1: the agent is already registered at this point —
2132
2188
  // a link-write failure must NOT be reported as a registration failure.
@@ -2690,7 +2746,6 @@ export async function startDaemon(config) {
2690
2746
  daemon: "running",
2691
2747
  directory_signaling: directorySignalingStatus(),
2692
2748
  agents: getAgentsForConnection(connectionId),
2693
- connections,
2694
2749
  // M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
2695
2750
  // the daemon-wide getStatus() surfaces them.
2696
2751
  interrupted_sessions: buildInterruptedSessions(),
@@ -2739,10 +2794,13 @@ export async function startDaemon(config) {
2739
2794
  // to the MCP response.
2740
2795
  handlers.set("cello_initiate_session", async (params, connectionId) => {
2741
2796
  const connState = perConnectionState.get(connectionId);
2742
- if (!connState || !connState.currentAgent) {
2797
+ // CC-3 / M8C-AUTOSTART-1 F18: resolve the target agent — explicit { name } wins, else this
2798
+ // connection's current agent, else the sole online agent (removes the no_current_agent papercut
2799
+ // after a /mcp reconnect when exactly one agent is online). 2+ online with none selected → null.
2800
+ const agentName = resolveCurrentAgent(connState, params?.name);
2801
+ if (!agentName) {
2743
2802
  return NO_CURRENT_AGENT_RESPONSE;
2744
2803
  }
2745
- const agentName = connState.currentAgent;
2746
2804
  const correlationId = randomUUID();
2747
2805
  // AC-004/AC-019: the advertised address is chosen from the standing receiver's
2748
2806
  // current dialability. Not dialable (or AutoNAT unavailable) → relay circuit.
@@ -2861,7 +2919,9 @@ export async function startDaemon(config) {
2861
2919
  // SI-001: no auto-seal on session_interrupted receipt; operator must call explicitly
2862
2920
  handlers.set("cello_close_session", async (params, connectionId) => {
2863
2921
  const connState = perConnectionState.get(connectionId);
2864
- if (!connState || !connState.currentAgent) {
2922
+ // CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
2923
+ const agentName = resolveCurrentAgent(connState, params?.name);
2924
+ if (!agentName) {
2865
2925
  return NO_CURRENT_AGENT_RESPONSE;
2866
2926
  }
2867
2927
  // round-2 BLOCKING: the public IPC contract field is snake_case `session_id`
@@ -2881,7 +2941,7 @@ export async function startDaemon(config) {
2881
2941
  // the ownership scope. A session_id owned only by a DIFFERENT agent does not exist in this
2882
2942
  // agent's namespace (returns null → session_not_found), which is correct for loopback (two
2883
2943
  // agents can hold the same session_id on one daemon).
2884
- const record = sessionNodeManager.getSessionRecord(connState.currentAgent, sessionId);
2944
+ const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
2885
2945
  if (!record) {
2886
2946
  return {
2887
2947
  ok: false,
@@ -2891,7 +2951,7 @@ export async function startDaemon(config) {
2891
2951
  }
2892
2952
  // Ownership: redundant now that the lookup is agent-scoped (record.agent_name === currentAgent),
2893
2953
  // kept as a defensive invariant.
2894
- if (record.agent_name !== connState.currentAgent) {
2954
+ if (record.agent_name !== agentName) {
2895
2955
  return {
2896
2956
  ok: false,
2897
2957
  reason: "session_not_owned",
@@ -2906,6 +2966,26 @@ export async function startDaemon(config) {
2906
2966
  guidance: "This session is already sealed. No further action is needed — check cello_list_sessions to view its sealed record and the FROST notarization.",
2907
2967
  };
2908
2968
  }
2969
+ // CC-5/F21 terminal-escape: force-abandon a session that can never be bilaterally sealed — a
2970
+ // half-open handshake the counterparty never joined, whose normal close fires a seal the absent/
2971
+ // rejecting counterparty can't complete (seal_interrupted_rejected_by_counterparty / timeout). Marks
2972
+ // it locally-terminal ("abandoned") with NO seal, so it leaves the open list. Additive: placed BEFORE
2973
+ // the seal branches, it never touches the seal flow. Owner-scoped (the lookup above is agent-scoped)
2974
+ // and idempotent. NOT for a healthy session — a normal close (no force) still seals so both parties
2975
+ // get a notarized receipt; force is the escape hatch for a provably unsealable ghost.
2976
+ if (params?.force === true) {
2977
+ if (record.status === "abandoned") {
2978
+ return { ok: true, status: "abandoned", reason: "already_abandoned", guidance: "This session was already force-abandoned." };
2979
+ }
2980
+ await sessionNodeManager.abandonSession(record.agent_name, sessionId);
2981
+ logger.info("session.force_abandoned", { agentName: record.agent_name, sessionId, priorStatus: record.status });
2982
+ return {
2983
+ ok: true,
2984
+ status: "abandoned",
2985
+ reason: "force_abandoned",
2986
+ guidance: `Session ${sessionId} was force-abandoned — marked terminal locally with no bilateral seal. Use force only for a half-open session that cannot be sealed; a normal close (no force) still attempts the seal so both parties get a notarized receipt.`,
2987
+ };
2988
+ }
2909
2989
  // AC-011: seal-interrupted already in progress
2910
2990
  if (sealInterruptedInProgress.has(sealKey(record.agent_name, sessionId))) {
2911
2991
  return {
@@ -3174,10 +3254,13 @@ export async function startDaemon(config) {
3174
3254
  }
3175
3255
  // DOD-LOOP-1: the certificate is keyed by (agent, session_id) — read the current agent's.
3176
3256
  const connState = perConnectionState.get(connectionId);
3177
- if (!connState || !connState.currentAgent) {
3257
+ // CC-3 / M8C-AUTOSTART-1 F18: resolve the target agent — explicit { name } wins, else this
3258
+ // connection's current agent, else the sole online agent (removes the no_current_agent papercut
3259
+ // after a /mcp reconnect when exactly one agent is online). 2+ online with none selected → null.
3260
+ const agentName = resolveCurrentAgent(connState, params?.name);
3261
+ if (!agentName) {
3178
3262
  return NO_CURRENT_AGENT_RESPONSE;
3179
3263
  }
3180
- const agentName = connState.currentAgent;
3181
3264
  const cert = sessionNodeManager.getSealCertificate(agentName, sessionId);
3182
3265
  if (cert) {
3183
3266
  return { ok: true, session_id: sessionId, sealed_root: cert.sealed_root, legibility: cert.legibility };
@@ -3229,10 +3312,12 @@ export async function startDaemon(config) {
3229
3312
  return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) whose transcript to read. See cello_list_sessions." };
3230
3313
  }
3231
3314
  const connState = perConnectionState.get(connectionId);
3232
- if (!connState || !connState.currentAgent) {
3315
+ // CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
3316
+ const agentName = resolveCurrentAgent(connState, params?.name);
3317
+ if (!agentName) {
3233
3318
  return NO_CURRENT_AGENT_RESPONSE;
3234
3319
  }
3235
- const { messages, undecryptable } = sessionNodeManager.readTranscript(connState.currentAgent, sessionId);
3320
+ const { messages, undecryptable } = sessionNodeManager.readTranscript(agentName, sessionId);
3236
3321
  // undecryptable > 0 means some rows failed GCM auth (tamper / wrong key) — surfaced, not hidden,
3237
3322
  // so the reader can tell a real gap from an empty transcript.
3238
3323
  // M8C-CURSOR-1: this is the ONLY reader that covers BOTH directions (sent + received), so it's
@@ -3293,10 +3378,13 @@ export async function startDaemon(config) {
3293
3378
  // the live ones. Read from persisted SQLite, so it survives a daemon restart / fresh connection.
3294
3379
  handlers.set("cello_list_sessions", async (params, connectionId) => {
3295
3380
  const connState = perConnectionState.get(connectionId);
3296
- if (!connState || !connState.currentAgent) {
3381
+ // CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
3382
+ const agentName = resolveCurrentAgent(connState, params?.name);
3383
+ if (!agentName) {
3297
3384
  return NO_CURRENT_AGENT_RESPONSE;
3298
3385
  }
3299
- return selectSessions(sessionNodeManager.getSessionsForAgent(connState.currentAgent), params);
3386
+ reapDeadHalfOpenSessions(agentName); // CC-5/F21: drop provably-dead half-open sessions before listing
3387
+ return selectSessions(sessionNodeManager.getSessionsForAgent(agentName), params);
3300
3388
  });
3301
3389
  // list_sessions (daemon-wide, for the `cello sessions` CLI which has no current agent): same
3302
3390
  // filter/limit semantics, across ALL agents.
@@ -4039,12 +4127,15 @@ export async function startDaemon(config) {
4039
4127
  void sendTelegramDoorbell(agentName, parsed.sessionIdHex, "session_request", "New session request");
4040
4128
  // M8C-AWAY-1: an unattended agent auto-acks a fresh inbound session request. Fire-and-forget
4041
4129
  // (sendAwayResponse never throws) — best-effort, must not delay/block acceptance completion.
4042
- // ORDER MATTERS: sendAwayResponse's isContact check runs synchronously (before any await) as
4043
- // soon as it's invoked, so calling addContact on the NEXT line still lets a stranger's very
4044
- // first contact be judged correctly unknown (M8C-CONTACT-1 D6: "accepting X's request adds X"
4045
- // — they become known immediately after, for every subsequent interaction).
4046
4130
  void sendAwayResponse(agentName, parsed.sessionIdHex, "request");
4047
- sessionNodeManager.addContact(agentName, parsed.participantAPubkeyHex);
4131
+ // CC-1 (2026-07-07): do NOT auto-add the requester here. Accepting the *connection* must not
4132
+ // grant *trust*. The old auto-add promoted any stranger who knocked once to "known" (Level-4
4133
+ // fast-track), which defeated BOTH the screening layer AND the ABUSE-1 acceptance caps
4134
+ // (checkUnknownSenderAcceptanceBound exempts contacts, so sessions 2+ bypassed the per-sender
4135
+ // cap — confirmed live 2026-07-07). Promotion to "known" now requires operator engagement only:
4136
+ // an outbound cello_initiate_session (below, ~3137), the operator replying INTO the session via
4137
+ // cello_send, or an explicit cello_contact_add. An unattended stranger is never auto-whitelisted.
4138
+ // See docs/planning/.../2026-07-07_1700_four-level-screening-policy.md (D21).
4048
4139
  }
4049
4140
  finally {
4050
4141
  inboundInFlight.delete(parsed.sessionIdHex);
@@ -4233,10 +4324,13 @@ export async function startDaemon(config) {
4233
4324
  // contract (core/adapter-claude-code/src/server.ts) so the E2E fixture migration is drop-in.
4234
4325
  handlers.set("cello_await_session", async (params, connectionId) => {
4235
4326
  const connState = perConnectionState.get(connectionId);
4236
- if (!connState || !connState.currentAgent) {
4327
+ // CC-3 / M8C-AUTOSTART-1 F18: resolve the target agent — explicit { name } wins, else this
4328
+ // connection's current agent, else the sole online agent (removes the no_current_agent papercut
4329
+ // after a /mcp reconnect when exactly one agent is online). 2+ online with none selected → null.
4330
+ const agentName = resolveCurrentAgent(connState, params?.name);
4331
+ if (!agentName) {
4237
4332
  return NO_CURRENT_AGENT_RESPONSE;
4238
4333
  }
4239
- const agentName = connState.currentAgent;
4240
4334
  const timeoutMs = typeof params?.["timeout_ms"] === "number" ? params["timeout_ms"] : 30_000;
4241
4335
  const toResponse = (e) => ({
4242
4336
  type: "new_session",
@@ -4670,7 +4764,9 @@ export async function startDaemon(config) {
4670
4764
  // ─── CELLO-M7-DAEMON-004: cello_send (live send + daemon-owned tree append) ──
4671
4765
  handlers.set("cello_send", async (params, connectionId) => {
4672
4766
  const connState = perConnectionState.get(connectionId);
4673
- if (!connState || !connState.currentAgent)
4767
+ // CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > current > sole online agent.
4768
+ const agentName = resolveCurrentAgent(connState, params?.name);
4769
+ if (!agentName)
4674
4770
  return NO_CURRENT_AGENT_RESPONSE;
4675
4771
  // round-2 BLOCKING: read the snake_case public field cello-mcp.ts actually sends.
4676
4772
  const sessionId = params?.session_id;
@@ -4679,11 +4775,11 @@ export async function startDaemon(config) {
4679
4775
  return { ok: false, reason: "missing_params", guidance: "Provide 'session_id' (hex) and 'content' (string) parameters." };
4680
4776
  }
4681
4777
  // DOD-LOOP-1: the (agent, session_id) lookup is itself the ownership scope.
4682
- const record = sessionNodeManager.getSessionRecord(connState.currentAgent, sessionId);
4778
+ const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
4683
4779
  if (!record) {
4684
4780
  return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check cello_list_sessions for active sessions." };
4685
4781
  }
4686
- if (record.agent_name !== connState.currentAgent) {
4782
+ if (record.agent_name !== agentName) {
4687
4783
  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." };
4688
4784
  }
4689
4785
  if (record.status !== "active") {
@@ -4849,6 +4945,14 @@ export async function startDaemon(config) {
4849
4945
  // blocked by session_not_current on its own just-sent message.
4850
4946
  advanceConnectionCursor(connectionId, sessionId, leafIndex);
4851
4947
  void newRootHex;
4948
+ // CC-1 (2026-07-07): operator engagement promotes the counterparty to a known contact. A
4949
+ // committed reply — past the read-before-write gate, content now on the wire AND in the tree —
4950
+ // IS the operator choosing to trust this sender; the inbound-accept path deliberately no longer
4951
+ // auto-adds (that defeated screening + anti-spam). For an OUTBOUND session the counterparty is
4952
+ // already a contact (cello_initiate_session added it), so this is an idempotent no-op there; it
4953
+ // matters for inbound-originated sessions, where the reply is the trust signal. addContact is
4954
+ // INSERT OR IGNORE — it never refreshes added_at.
4955
+ sessionNodeManager.addContact(record.agent_name, recipientPubkey);
4852
4956
  if (modified) {
4853
4957
  logger.info("security.verdict.returned", { disposition: "redact", sessionId, sequenceNumber: leafIndex, correlationId });
4854
4958
  }
@@ -4900,9 +5004,10 @@ export async function startDaemon(config) {
4900
5004
  const RECEIVE_DEFAULT_TIMEOUT_MS = 30000; // matches the cello-mcp shim's documented default
4901
5005
  const handleReceive = async (params, connectionId) => {
4902
5006
  const connState = perConnectionState.get(connectionId);
4903
- if (!connState || !connState.currentAgent)
5007
+ // CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > current > sole online agent.
5008
+ const agentName = resolveCurrentAgent(connState, params?.name);
5009
+ if (!agentName)
4904
5010
  return NO_CURRENT_AGENT_RESPONSE;
4905
- const agentName = connState.currentAgent;
4906
5011
  // Read the snake_case public field cello-mcp.ts actually sends.
4907
5012
  const sessionId = params?.session_id;
4908
5013
  if (!sessionId) {