@cello-protocol/daemon 0.0.37 → 0.0.38

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;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,CAw9K7E"}
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;AAQ/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,CAwoL7E"}
package/dist/daemon.js CHANGED
@@ -50,10 +50,11 @@ import { attemptSealUpgrade as attemptSealUpgradeImpl, verifyUpgradeConfirmedCer
50
50
  import { upgradeAbsentToRecovered, hasAbsentParticipant } from "./seal-receipt-upgrade.js";
51
51
  // CELLO-M7-MSG-001 (AC-013/AC-018): the single application content-size cap, enforced
52
52
  // at the send point here (the receive point lives in the transport content decode).
53
- import { MAX_CONTENT_BYTES, computeGenesisPrevRoot, buildAgentRevocationTbs } from "@cello-protocol/protocol-types";
53
+ import { MAX_CONTENT_BYTES, computeGenesisPrevRoot, buildAgentRevocationTbs, MONIKER_RE, validateMoniker } from "@cello-protocol/protocol-types";
54
54
  import { resolveCelloEnv, createTransportSelector, isProductionVariant, } from "./transport-composition.js";
55
55
  import { selectAdvertisedAddress } from "./transport-selector.js";
56
- import { parseSessionAssignment, sessionRequestErrorReason, parseDiscoveryLookupResult, discoveryLookupErrorReason } from "./session-assignment-parser.js";
56
+ import { parseSessionAssignment, sessionRequestErrorReason, parseDiscoveryLookupResult, discoveryLookupErrorReason, extractOfferedMoniker, resolveOutboundMoniker } from "./session-assignment-parser.js";
57
+ import { whoLabel } from "./who-label.js";
57
58
  import { classifyOnlineResult } from "./cross-node-negotiation.js";
58
59
  import { wireSessionCeremonyHandler, wireSessionOfferHandler, wireSealCeremonyHandler, verifyUnilateralCertificate, verifyBilateralSealCertificate, runAgentRefresh } from "./session-ceremony.js";
59
60
  import { reDeriveFrontiers, findInflatedFrontier, checkUnilateralFrontier } from "./seal-frontier-verify.js";
@@ -900,8 +901,27 @@ export async function startDaemon(config) {
900
901
  // Wraps notificationDispatcher.dispatchSessionStateChanged so every call site gets the
901
902
  // Telegram state-change doorbell for free (DoD: state changes ALWAYS ring, never coalesced) —
902
903
  // one wrapper rather than hooking each of the several existing call sites individually.
904
+ // MONIKER-4 AC2: resolve the counterparty's display label — local pet name (MONIKER-3) ??
905
+ // offered name for this session (MONIKER-2) ?? fingerprint. Total: any failure inside
906
+ // resolution degrades to fingerprint via whoLabel's own tiers; a label can never block a
907
+ // doorbell (spec §8).
908
+ function resolveWho(agentName, pubkeyHex, sessionIdHex) {
909
+ let localMoniker = null;
910
+ try {
911
+ localMoniker = sessionNodeManager.getContactMoniker(agentName, pubkeyHex);
912
+ }
913
+ catch (err) {
914
+ logger.warn("moniker.local.read_failed", { agentName, pubkey: pubkeyHex, reason: err instanceof Error ? err.message : String(err) });
915
+ }
916
+ const resolved = whoLabel({ localMoniker, offeredMoniker: offeredMonikers.get(sessionIdHex) ?? null, pubkeyHex });
917
+ logger.debug("moniker.resolved", { agentName, pubkey: pubkeyHex, source: resolved.source });
918
+ return { who: resolved.who, whoKnown: resolved.whoKnown };
919
+ }
903
920
  function dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey) {
904
- notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey);
921
+ // MONIKER-4 AC2: stamp who/whoKnown on the counterparty-bearing frame. Resolved BEFORE the
922
+ // offered-name drop below so a terminal state's own doorbell still shows the name.
923
+ const who = counterpartyPubkey ? resolveWho(agentName, counterpartyPubkey, sessionId) : undefined;
924
+ notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey, who);
905
925
  void sendTelegramDoorbell(agentName, sessionId, "state_change", `Session ${state}`);
906
926
  // Reviewer HIGH fix (a60d68ed): telegramRungUnread had NO cleanup at all — a session that
907
927
  // rings once and is never read via cello_receive/since_seq (e.g. the operator only ever uses
@@ -911,6 +931,14 @@ export async function startDaemon(config) {
911
931
  // preferable to an unbounded leak (the exact class of bug fixed for TTL-1's expired-log at
912
932
  // af8a701 in this same milestone).
913
933
  clearTelegramRung(agentName, sessionId);
934
+ // MONIKER-2 AC2b (review F1): the offered name is display material for the session's
935
+ // lifetime only. Production emits "created", "interrupted", "counterparty_closing" through
936
+ // this wrapper — a terminal-only check was dead code and left the map growing for the
937
+ // daemon's lifetime (remote-fed). Drop on ANY state past "created": a prematurely dropped
938
+ // label degrades to fingerprint, which the spec sanctions; an unbounded map does not.
939
+ if (state !== "created" && offeredMonikers.delete(sessionId)) {
940
+ logger.debug("moniker.offer.dropped", { sessionId, state });
941
+ }
914
942
  }
915
943
  async function handleInboundTelegramUpdate(update, client) {
916
944
  const chatId = update.message?.chat?.id !== undefined ? String(update.message.chat.id) : undefined;
@@ -1084,6 +1112,28 @@ export async function startDaemon(config) {
1084
1112
  if (t === "session_assignment" || t === "session_request_error")
1085
1113
  resolveFrame(frame);
1086
1114
  });
1115
+ // MONIKER-2 AC1: the outbound name rides the request (agent name, or the MONIKER-1
1116
+ // override), re-validated at offer construction (defense in depth) and OMITTED — never
1117
+ // an empty string — when absent or invalid. A failure reading it must never block the
1118
+ // session (spec §8: the label degrades, the session forms), but it is logged loudly.
1119
+ let moniker;
1120
+ try {
1121
+ const outboundName = new DbIdentityStore(sessionNodeManager.getDb(), logger).getOutboundName(agentName);
1122
+ moniker = resolveOutboundMoniker(outboundName);
1123
+ // Review F4: omit-not-send is the designed degradation, but an agent whose STORED name
1124
+ // fails the rule should be visible, not silently nameless on the wire.
1125
+ if (outboundName !== null && moniker === undefined) {
1126
+ logger.debug("moniker.outbound.invalid_name_omitted", { agentName, correlationId });
1127
+ }
1128
+ }
1129
+ catch (err) {
1130
+ logger.warn("moniker.outbound.read_failed", {
1131
+ agentName,
1132
+ reason: err instanceof Error ? err.message : String(err),
1133
+ correlationId,
1134
+ });
1135
+ moniker = undefined;
1136
+ }
1087
1137
  try {
1088
1138
  const sent = await signaling.sendRaw({
1089
1139
  type: "session_request",
@@ -1091,6 +1141,7 @@ export async function startDaemon(config) {
1091
1141
  initiator_session_peer_id: sr.peerId,
1092
1142
  initiator_session_addrs: sr.addrs,
1093
1143
  wants_session_offer: true,
1144
+ ...(moniker !== undefined ? { moniker } : {}),
1094
1145
  });
1095
1146
  if (!sent.ok) {
1096
1147
  return { ok: false, reason: sent.reason ?? "directory_unreachable", guidance: sent.guidance ?? "Could not send session_request over the directory signaling stream." };
@@ -1744,9 +1795,9 @@ export async function startDaemon(config) {
1744
1795
  // the encrypted DB (NO key file), and wire the agent into the live daemon so it can be registered
1745
1796
  // and used WITHOUT a restart. Creation is explicit — cello_start_agent never auto-creates on a typo.
1746
1797
  handlers.set("cello_create_agent", async (params, _connectionId) => {
1747
- const name = params?.name;
1748
- if (!name || typeof name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
1749
- return { ok: false, reason: "invalid_agent_name", guidance: "Provide a 'name' (1-64 chars: letters, digits, '-' or '_') for the new agent." };
1798
+ const name = validateMoniker(params?.name);
1799
+ if (name === null) {
1800
+ return { ok: false, reason: "invalid_agent_name", guidance: `Provide a 'name' (1-64 chars: letters, digits, '-' or '_'; regex ${MONIKER_RE.source}) for the new agent.` };
1750
1801
  }
1751
1802
  const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
1752
1803
  if (store.hasActiveAgent(name) || agents.some((a) => a.name === name)) {
@@ -1851,9 +1902,9 @@ export async function startDaemon(config) {
1851
1902
  }
1852
1903
  }
1853
1904
  handlers.set("cello_remove_agent", async (params, _connectionId) => {
1854
- const name = params?.name;
1855
- if (!name || typeof name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
1856
- return { ok: false, reason: "invalid_agent_name", guidance: "Provide the 'name' of the agent to remove (1-64 chars: letters, digits, '-' or '_')." };
1905
+ const name = validateMoniker(params?.name);
1906
+ if (name === null) {
1907
+ return { ok: false, reason: "invalid_agent_name", guidance: `Provide the 'name' of the agent to remove (1-64 chars: letters, digits, '-' or '_'; regex ${MONIKER_RE.source}).` };
1857
1908
  }
1858
1909
  const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
1859
1910
  // The active row (a fresh removal) OR the most-recent retired row (a DB-001 re-push). Captured BEFORE
@@ -3381,6 +3432,8 @@ export async function startDaemon(config) {
3381
3432
  sessionId: row.session_id,
3382
3433
  agentName: row.agent_name,
3383
3434
  counterpartyPubkey: row.counterparty_pubkey,
3435
+ // MONIKER-5 AC1: the same resolution the doorbell uses — pet name ?? offered ?? fingerprint.
3436
+ ...resolveWho(row.agent_name, row.counterparty_pubkey, row.session_id),
3384
3437
  status: row.status,
3385
3438
  category,
3386
3439
  messageCount,
@@ -3868,6 +3921,10 @@ export async function startDaemon(config) {
3868
3921
  correlationId,
3869
3922
  });
3870
3923
  }
3924
+ // MONIKER-2 AC2b: sessionIdHex → the initiator's validated offered name. Session-scoped
3925
+ // display material ONLY (spec: promotion to a stored pet name needs an explicit operator
3926
+ // action). In-memory by design — a restart degrades the label to fingerprint, never worse.
3927
+ const offeredMonikers = new Map();
3871
3928
  // Expired requests move HERE (from inboundSessionQueues) rather than vanishing — visible via
3872
3929
  // cello_check_notifications so the operator can see what they missed, not just silence.
3873
3930
  const expiredSessionRequests = new Map();
@@ -3928,6 +3985,10 @@ export async function startDaemon(config) {
3928
3985
  expiredList.splice(0, expiredList.length - EXPIRED_SESSION_REQUESTS_CAP); // keep newest N
3929
3986
  }
3930
3987
  logger.info("session.request.expired", { agentName, sessionId: e.sessionIdHex, enqueuedAt: e.enqueuedAt });
3988
+ // MONIKER-2 AC2b (review F1): the offer's display name expires with the offer.
3989
+ if (offeredMonikers.delete(e.sessionIdHex)) {
3990
+ logger.debug("moniker.offer.dropped", { sessionId: e.sessionIdHex, state: "expired" });
3991
+ }
3931
3992
  }
3932
3993
  else {
3933
3994
  live.push(e);
@@ -4024,6 +4085,8 @@ export async function startDaemon(config) {
4024
4085
  const relayAddrs = relayEndpoint && Array.isArray(relayEndpoint["multiaddrs"])
4025
4086
  ? relayEndpoint["multiaddrs"].filter((m) => typeof m === "string")
4026
4087
  : [];
4088
+ // MONIKER-2 AC2: validate the offered name ONCE, at this wire boundary.
4089
+ const monikerResult = extractOfferedMoniker(a);
4027
4090
  return {
4028
4091
  sessionIdHex,
4029
4092
  participantAPubkeyHex,
@@ -4038,6 +4101,9 @@ export async function startDaemon(config) {
4038
4101
  signerPubkeyHex: a["signer_pubkey"] !== undefined ? frameValueToHex(a["signer_pubkey"]) : null,
4039
4102
  relayPeerId,
4040
4103
  relayAddrs,
4104
+ offeredMoniker: monikerResult.offeredMoniker,
4105
+ monikerRejected: monikerResult.rejected,
4106
+ monikerRejectReason: monikerResult.reason ?? null,
4041
4107
  };
4042
4108
  }
4043
4109
  // Wait (bounded) for THIS AGENT's standing receiver to be ready. acceptSession consumes the
@@ -4142,10 +4208,28 @@ export async function startDaemon(config) {
4142
4208
  sessionPeerId: result.peerId,
4143
4209
  correlationId,
4144
4210
  });
4211
+ // MONIKER-2 AC2: a present-but-invalid moniker is a red flag (the sender runs modified
4212
+ // code) — logged with the spec'd fields and NEVER the raw value. Not grounds to refuse
4213
+ // the session (version skew is real; refusing hands strangers a DoS lever).
4214
+ if (parsed.monikerRejected) {
4215
+ logger.info("moniker.rejected", {
4216
+ agentName,
4217
+ pubkey: parsed.participantAPubkeyHex,
4218
+ reason: parsed.monikerRejectReason ?? "invalid",
4219
+ correlationId,
4220
+ });
4221
+ }
4222
+ // MONIKER-2 AC2b: display material for THIS offer/session only (never persisted, never
4223
+ // auto-written to contacts — AC3). Session-scoped: MONIKER-4's dispatcher resolves
4224
+ // whoLabel from here; entries are dropped when the session's terminal state dispatches.
4225
+ if (parsed.offeredMoniker !== null) {
4226
+ offeredMonikers.set(parsed.sessionIdHex, parsed.offeredMoniker);
4227
+ }
4145
4228
  enqueueInboundSession(agentName, {
4146
4229
  sessionIdHex: parsed.sessionIdHex,
4147
4230
  counterpartyPubkeyHex: parsed.participantAPubkeyHex,
4148
4231
  genesisPrevRootHex,
4232
+ offeredMoniker: parsed.offeredMoniker,
4149
4233
  });
4150
4234
  dispatchSessionStateChangedWithTelegram(agentName, parsed.sessionIdHex, "created", parsed.participantAPubkeyHex);
4151
4235
  // M8C-TGDOOR-1: session requests ALWAYS ring (DoD) — no coalescing, unlike message-waiting.
@@ -4362,6 +4446,8 @@ export async function startDaemon(config) {
4362
4446
  session_id: e.sessionIdHex,
4363
4447
  counterparty_pubkey: e.counterpartyPubkeyHex,
4364
4448
  genesis_prev_root: e.genesisPrevRootHex,
4449
+ // MONIKER-2: validated-at-boundary display hint; null = absent or rejected.
4450
+ offered_moniker: e.offeredMoniker ?? null,
4365
4451
  });
4366
4452
  reapExpiredInboundSessions(agentName); // M8C-TTL-1: don't hand back a stale expired entry
4367
4453
  const queued = inboundSessionQueues.get(agentName);
@@ -5214,9 +5300,54 @@ export async function startDaemon(config) {
5214
5300
  const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5215
5301
  if (!resolved.ok)
5216
5302
  return resolved;
5217
- sessionNodeManager.addContact(resolved.agent, pubkey);
5303
+ // MONIKER-3 AC3: optional pet name. Invalid → reject the request WHOLE (no contact row
5304
+ // either) — a half-applied add would store trust without the name the operator asked for.
5305
+ let moniker;
5306
+ if (params && "moniker" in params && params.moniker !== undefined && params.moniker !== null) {
5307
+ moniker = validateMoniker(params.moniker);
5308
+ if (moniker === null) {
5309
+ return {
5310
+ ok: false,
5311
+ reason: "invalid_moniker",
5312
+ guidance: `A moniker is 1-64 chars: letters, digits, '-' or '_' (regex ${MONIKER_RE.source}).`,
5313
+ };
5314
+ }
5315
+ }
5316
+ sessionNodeManager.addContact(resolved.agent, pubkey, moniker);
5218
5317
  logger.info("contact.added", { agent: resolved.agent, pubkey });
5219
- return { ok: true, agent: resolved.agent, pubkey };
5318
+ if (moniker !== undefined) {
5319
+ logger.info("contact.moniker.set", { agentName: resolved.agent, pubkey });
5320
+ }
5321
+ // Review F3: echo the moniker ONLY when one rode this request — a re-add without a moniker
5322
+ // must not report null while an earlier pet name is still stored.
5323
+ return moniker !== undefined
5324
+ ? { ok: true, agent: resolved.agent, pubkey, moniker }
5325
+ : { ok: true, agent: resolved.agent, pubkey };
5326
+ });
5327
+ // MONIKER-3 AC3: rename (string) or clear (null) an existing contact's pet name. Absence of the
5328
+ // key is NOT a clear (Entry-66-F3): a request that omits it is malformed and rejected.
5329
+ handlers.set("cello_contact_set_moniker", async (params, connectionId) => {
5330
+ const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5331
+ if (!pubkey || !params || !("moniker" in params)) {
5332
+ return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'moniker' — a string to set the pet name, or null to clear it." };
5333
+ }
5334
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5335
+ if (!resolved.ok)
5336
+ return resolved;
5337
+ const raw = params.moniker ?? null;
5338
+ const moniker = raw === null ? null : validateMoniker(raw);
5339
+ if (raw !== null && moniker === null) {
5340
+ return {
5341
+ ok: false,
5342
+ reason: "invalid_moniker",
5343
+ guidance: `A moniker is 1-64 chars: letters, digits, '-' or '_' (regex ${MONIKER_RE.source}). Pass null to clear.`,
5344
+ };
5345
+ }
5346
+ if (!sessionNodeManager.setContactMoniker(resolved.agent, pubkey, moniker)) {
5347
+ return { ok: false, reason: "contact_not_found", guidance: `No contact ${pubkey.slice(0, 16)}… for agent '${resolved.agent}'. Add it first with cello_contact_add.` };
5348
+ }
5349
+ logger.info("contact.moniker.set", { agentName: resolved.agent, pubkey, cleared: moniker === null });
5350
+ return { ok: true, agent: resolved.agent, pubkey, moniker };
5220
5351
  });
5221
5352
  handlers.set("cello_contact_remove", async (params, connectionId) => {
5222
5353
  const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
@@ -5234,9 +5365,44 @@ export async function startDaemon(config) {
5234
5365
  const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5235
5366
  if (!resolved.ok)
5236
5367
  return resolved;
5237
- const contacts = sessionNodeManager.listContacts(resolved.agent);
5368
+ // MONIKER-5 AC1: each row shows the resolved who (a contact has no session, so the offered
5369
+ // tier doesn't apply — pet name ?? fingerprint).
5370
+ const contacts = sessionNodeManager.listContacts(resolved.agent).map((c) => ({
5371
+ ...c,
5372
+ ...whoLabel({ localMoniker: c.moniker, offeredMoniker: null, pubkeyHex: c.pubkey }),
5373
+ }));
5238
5374
  return { ok: true, agent: resolved.agent, contacts };
5239
5375
  });
5376
+ // MONIKER-1 AC2/AC3: cello_set_moniker — set (or clear, via explicit null) an agent's outbound-name
5377
+ // override on the agents table. Validated at set-time with the shared MONIKER-0 rule; an invalid
5378
+ // value is rejected here AND at the store (backstop) — it can never be stored. Local-only: the
5379
+ // name is never sent to the directory (AC4).
5380
+ handlers.set("cello_set_moniker", async (params, connectionId) => {
5381
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5382
+ if (!resolved.ok)
5383
+ return resolved;
5384
+ // Absence is NOT a clear (review Finding 3): JSON preserves explicit null, so a request
5385
+ // that omits the key is malformed — rejecting it keeps a dropped field from silently
5386
+ // deleting a stored override while reporting success.
5387
+ if (!params || !("moniker" in params)) {
5388
+ return { ok: false, reason: "missing_params", guidance: "Provide 'moniker' — a string to set the outbound name, or null to clear the override." };
5389
+ }
5390
+ const raw = params.moniker ?? null;
5391
+ const moniker = raw === null ? null : validateMoniker(raw);
5392
+ if (raw !== null && moniker === null) {
5393
+ return {
5394
+ ok: false,
5395
+ reason: "invalid_moniker",
5396
+ guidance: `A moniker is 1-64 chars: letters, digits, '-' or '_' (regex ${MONIKER_RE.source}). Pass null to clear the override.`,
5397
+ };
5398
+ }
5399
+ const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
5400
+ if (!store.setMoniker(resolved.agent, moniker)) {
5401
+ return { ok: false, reason: "agent_not_found", guidance: `No active agent named '${resolved.agent}'. See cello_list_agents.` };
5402
+ }
5403
+ logger.info("agent.moniker.set", { agentName: resolved.agent, cleared: moniker === null });
5404
+ return { ok: true, agent: resolved.agent, moniker };
5405
+ });
5240
5406
  // M8C-TGDOOR-1: cello_telegram_set_token — persist the daemon-wide bot token + allowlisted
5241
5407
  // operator chat ID, then start the poller immediately (no restart needed).
5242
5408
  handlers.set("cello_telegram_set_token", async (params, _connectionId) => {
@@ -5289,7 +5455,8 @@ export async function startDaemon(config) {
5289
5455
  // content-free `cello_message` doorbell to the current-agent connection(s). The shim's generic
5290
5456
  // bridge (WAKE) forwards it to a live --channels session as notifications/claude/channel.
5291
5457
  sessionNodeManager.setOnContentArrived((agentName, sessionId, senderPubkey) => {
5292
- notificationDispatcher.dispatchCelloMessage(agentName, sessionId, senderPubkey);
5458
+ // MONIKER-4 AC2: the message doorbell names the sender the same way the session doorbell does.
5459
+ notificationDispatcher.dispatchCelloMessage(agentName, sessionId, senderPubkey, resolveWho(agentName, senderPubkey, sessionId));
5293
5460
  // M8C-AWAY-1: an unattended agent auto-acks an inbound message on an existing session.
5294
5461
  void sendAwayResponse(agentName, sessionId, "message");
5295
5462
  // M8C-TGDOOR-1: message-waiting — coalesced (ring-once-until-read) inside sendTelegramDoorbell.