@cello-protocol/daemon 0.0.37 → 0.0.39

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,CAmpL7E"}
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,28 @@ 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
+ // DOD-MONIKER-6: read only the box written FOR this agent — never a co-resident agent's.
917
+ const resolved = whoLabel({ localMoniker, offeredMoniker: offeredMonikers.get(offerKey(agentName, sessionIdHex)) ?? null, pubkeyHex });
918
+ logger.debug("moniker.resolved", { agentName, pubkey: pubkeyHex, source: resolved.source });
919
+ return { who: resolved.who, whoKnown: resolved.whoKnown };
920
+ }
903
921
  function dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey) {
904
- notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey);
922
+ // MONIKER-4 AC2: stamp who/whoKnown on the counterparty-bearing frame. Resolved BEFORE the
923
+ // offered-name drop below so a terminal state's own doorbell still shows the name.
924
+ const who = counterpartyPubkey ? resolveWho(agentName, counterpartyPubkey, sessionId) : undefined;
925
+ notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey, who);
905
926
  void sendTelegramDoorbell(agentName, sessionId, "state_change", `Session ${state}`);
906
927
  // Reviewer HIGH fix (a60d68ed): telegramRungUnread had NO cleanup at all — a session that
907
928
  // rings once and is never read via cello_receive/since_seq (e.g. the operator only ever uses
@@ -911,6 +932,16 @@ export async function startDaemon(config) {
911
932
  // preferable to an unbounded leak (the exact class of bug fixed for TTL-1's expired-log at
912
933
  // af8a701 in this same milestone).
913
934
  clearTelegramRung(agentName, sessionId);
935
+ // MONIKER-2 AC2b (review F1): the offered name is display material for the session's
936
+ // lifetime only. Production emits "created", "interrupted", "counterparty_closing" through
937
+ // this wrapper — a terminal-only check was dead code and left the map growing for the
938
+ // daemon's lifetime (remote-fed). Drop on ANY state past "created": a prematurely dropped
939
+ // label degrades to fingerprint, which the spec sanctions; an unbounded map does not.
940
+ // DOD-MONIKER-6 AC3: drop only THIS agent's box — a co-resident agent's session moving on
941
+ // must never cost this agent the caller's name.
942
+ if (state !== "created" && offeredMonikers.delete(offerKey(agentName, sessionId))) {
943
+ logger.debug("moniker.offer.dropped", { agentName, sessionId, state });
944
+ }
914
945
  }
915
946
  async function handleInboundTelegramUpdate(update, client) {
916
947
  const chatId = update.message?.chat?.id !== undefined ? String(update.message.chat.id) : undefined;
@@ -1084,6 +1115,28 @@ export async function startDaemon(config) {
1084
1115
  if (t === "session_assignment" || t === "session_request_error")
1085
1116
  resolveFrame(frame);
1086
1117
  });
1118
+ // MONIKER-2 AC1: the outbound name rides the request (agent name, or the MONIKER-1
1119
+ // override), re-validated at offer construction (defense in depth) and OMITTED — never
1120
+ // an empty string — when absent or invalid. A failure reading it must never block the
1121
+ // session (spec §8: the label degrades, the session forms), but it is logged loudly.
1122
+ let moniker;
1123
+ try {
1124
+ const outboundName = new DbIdentityStore(sessionNodeManager.getDb(), logger).getOutboundName(agentName);
1125
+ moniker = resolveOutboundMoniker(outboundName);
1126
+ // Review F4: omit-not-send is the designed degradation, but an agent whose STORED name
1127
+ // fails the rule should be visible, not silently nameless on the wire.
1128
+ if (outboundName !== null && moniker === undefined) {
1129
+ logger.debug("moniker.outbound.invalid_name_omitted", { agentName, correlationId });
1130
+ }
1131
+ }
1132
+ catch (err) {
1133
+ logger.warn("moniker.outbound.read_failed", {
1134
+ agentName,
1135
+ reason: err instanceof Error ? err.message : String(err),
1136
+ correlationId,
1137
+ });
1138
+ moniker = undefined;
1139
+ }
1087
1140
  try {
1088
1141
  const sent = await signaling.sendRaw({
1089
1142
  type: "session_request",
@@ -1091,6 +1144,7 @@ export async function startDaemon(config) {
1091
1144
  initiator_session_peer_id: sr.peerId,
1092
1145
  initiator_session_addrs: sr.addrs,
1093
1146
  wants_session_offer: true,
1147
+ ...(moniker !== undefined ? { moniker } : {}),
1094
1148
  });
1095
1149
  if (!sent.ok) {
1096
1150
  return { ok: false, reason: sent.reason ?? "directory_unreachable", guidance: sent.guidance ?? "Could not send session_request over the directory signaling stream." };
@@ -1744,9 +1798,9 @@ export async function startDaemon(config) {
1744
1798
  // the encrypted DB (NO key file), and wire the agent into the live daemon so it can be registered
1745
1799
  // and used WITHOUT a restart. Creation is explicit — cello_start_agent never auto-creates on a typo.
1746
1800
  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." };
1801
+ const name = validateMoniker(params?.name);
1802
+ if (name === null) {
1803
+ 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
1804
  }
1751
1805
  const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
1752
1806
  if (store.hasActiveAgent(name) || agents.some((a) => a.name === name)) {
@@ -1851,9 +1905,9 @@ export async function startDaemon(config) {
1851
1905
  }
1852
1906
  }
1853
1907
  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 '_')." };
1908
+ const name = validateMoniker(params?.name);
1909
+ if (name === null) {
1910
+ 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
1911
  }
1858
1912
  const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
1859
1913
  // The active row (a fresh removal) OR the most-recent retired row (a DB-001 re-push). Captured BEFORE
@@ -3381,6 +3435,8 @@ export async function startDaemon(config) {
3381
3435
  sessionId: row.session_id,
3382
3436
  agentName: row.agent_name,
3383
3437
  counterpartyPubkey: row.counterparty_pubkey,
3438
+ // MONIKER-5 AC1: the same resolution the doorbell uses — pet name ?? offered ?? fingerprint.
3439
+ ...resolveWho(row.agent_name, row.counterparty_pubkey, row.session_id),
3384
3440
  status: row.status,
3385
3441
  category,
3386
3442
  messageCount,
@@ -3868,6 +3924,17 @@ export async function startDaemon(config) {
3868
3924
  correlationId,
3869
3925
  });
3870
3926
  }
3927
+ // MONIKER-2 AC2b: the initiator's validated offered name. Session-scoped display material ONLY
3928
+ // (spec: promotion to a stored pet name needs an explicit operator action). In-memory by design —
3929
+ // a restart degrades the label to fingerprint, never worse.
3930
+ //
3931
+ // DOD-MONIKER-6 (spec §10): keyed by (agentName, sessionIdHex), NEVER sessionIdHex alone. A box
3932
+ // is written by the RECEIVING side and holds the CALLER's name, so it is meaningful only to the
3933
+ // agent it was written for. Keyed by session id alone, a daemon hosting both participants hands
3934
+ // the initiator the box filled in for her counterparty — she is told she messaged herself — and
3935
+ // either agent's state change or request expiry silently drops the other's name.
3936
+ const offeredMonikers = new Map();
3937
+ const offerKey = (agentName, sessionIdHex) => `${agentName}:${sessionIdHex}`;
3871
3938
  // Expired requests move HERE (from inboundSessionQueues) rather than vanishing — visible via
3872
3939
  // cello_check_notifications so the operator can see what they missed, not just silence.
3873
3940
  const expiredSessionRequests = new Map();
@@ -3928,6 +3995,10 @@ export async function startDaemon(config) {
3928
3995
  expiredList.splice(0, expiredList.length - EXPIRED_SESSION_REQUESTS_CAP); // keep newest N
3929
3996
  }
3930
3997
  logger.info("session.request.expired", { agentName, sessionId: e.sessionIdHex, enqueuedAt: e.enqueuedAt });
3998
+ // MONIKER-2 AC2b (review F1): the offer's display name expires with the offer.
3999
+ if (offeredMonikers.delete(offerKey(agentName, e.sessionIdHex))) {
4000
+ logger.debug("moniker.offer.dropped", { agentName, sessionId: e.sessionIdHex, state: "expired" });
4001
+ }
3931
4002
  }
3932
4003
  else {
3933
4004
  live.push(e);
@@ -4024,6 +4095,8 @@ export async function startDaemon(config) {
4024
4095
  const relayAddrs = relayEndpoint && Array.isArray(relayEndpoint["multiaddrs"])
4025
4096
  ? relayEndpoint["multiaddrs"].filter((m) => typeof m === "string")
4026
4097
  : [];
4098
+ // MONIKER-2 AC2: validate the offered name ONCE, at this wire boundary.
4099
+ const monikerResult = extractOfferedMoniker(a);
4027
4100
  return {
4028
4101
  sessionIdHex,
4029
4102
  participantAPubkeyHex,
@@ -4038,6 +4111,9 @@ export async function startDaemon(config) {
4038
4111
  signerPubkeyHex: a["signer_pubkey"] !== undefined ? frameValueToHex(a["signer_pubkey"]) : null,
4039
4112
  relayPeerId,
4040
4113
  relayAddrs,
4114
+ offeredMoniker: monikerResult.offeredMoniker,
4115
+ monikerRejected: monikerResult.rejected,
4116
+ monikerRejectReason: monikerResult.reason ?? null,
4041
4117
  };
4042
4118
  }
4043
4119
  // Wait (bounded) for THIS AGENT's standing receiver to be ready. acceptSession consumes the
@@ -4142,10 +4218,29 @@ export async function startDaemon(config) {
4142
4218
  sessionPeerId: result.peerId,
4143
4219
  correlationId,
4144
4220
  });
4221
+ // MONIKER-2 AC2: a present-but-invalid moniker is a red flag (the sender runs modified
4222
+ // code) — logged with the spec'd fields and NEVER the raw value. Not grounds to refuse
4223
+ // the session (version skew is real; refusing hands strangers a DoS lever).
4224
+ if (parsed.monikerRejected) {
4225
+ logger.info("moniker.rejected", {
4226
+ agentName,
4227
+ pubkey: parsed.participantAPubkeyHex,
4228
+ reason: parsed.monikerRejectReason ?? "invalid",
4229
+ correlationId,
4230
+ });
4231
+ }
4232
+ // MONIKER-2 AC2b: display material for THIS offer/session only (never persisted, never
4233
+ // auto-written to contacts — AC3). Session-scoped: MONIKER-4's dispatcher resolves
4234
+ // whoLabel from here; entries are dropped when the session's terminal state dispatches.
4235
+ // DOD-MONIKER-6: scoped to `agentName` — the agent this offer was received FOR.
4236
+ if (parsed.offeredMoniker !== null) {
4237
+ offeredMonikers.set(offerKey(agentName, parsed.sessionIdHex), parsed.offeredMoniker);
4238
+ }
4145
4239
  enqueueInboundSession(agentName, {
4146
4240
  sessionIdHex: parsed.sessionIdHex,
4147
4241
  counterpartyPubkeyHex: parsed.participantAPubkeyHex,
4148
4242
  genesisPrevRootHex,
4243
+ offeredMoniker: parsed.offeredMoniker,
4149
4244
  });
4150
4245
  dispatchSessionStateChangedWithTelegram(agentName, parsed.sessionIdHex, "created", parsed.participantAPubkeyHex);
4151
4246
  // M8C-TGDOOR-1: session requests ALWAYS ring (DoD) — no coalescing, unlike message-waiting.
@@ -4362,6 +4457,8 @@ export async function startDaemon(config) {
4362
4457
  session_id: e.sessionIdHex,
4363
4458
  counterparty_pubkey: e.counterpartyPubkeyHex,
4364
4459
  genesis_prev_root: e.genesisPrevRootHex,
4460
+ // MONIKER-2: validated-at-boundary display hint; null = absent or rejected.
4461
+ offered_moniker: e.offeredMoniker ?? null,
4365
4462
  });
4366
4463
  reapExpiredInboundSessions(agentName); // M8C-TTL-1: don't hand back a stale expired entry
4367
4464
  const queued = inboundSessionQueues.get(agentName);
@@ -5214,9 +5311,54 @@ export async function startDaemon(config) {
5214
5311
  const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5215
5312
  if (!resolved.ok)
5216
5313
  return resolved;
5217
- sessionNodeManager.addContact(resolved.agent, pubkey);
5314
+ // MONIKER-3 AC3: optional pet name. Invalid → reject the request WHOLE (no contact row
5315
+ // either) — a half-applied add would store trust without the name the operator asked for.
5316
+ let moniker;
5317
+ if (params && "moniker" in params && params.moniker !== undefined && params.moniker !== null) {
5318
+ moniker = validateMoniker(params.moniker);
5319
+ if (moniker === null) {
5320
+ return {
5321
+ ok: false,
5322
+ reason: "invalid_moniker",
5323
+ guidance: `A moniker is 1-64 chars: letters, digits, '-' or '_' (regex ${MONIKER_RE.source}).`,
5324
+ };
5325
+ }
5326
+ }
5327
+ sessionNodeManager.addContact(resolved.agent, pubkey, moniker);
5218
5328
  logger.info("contact.added", { agent: resolved.agent, pubkey });
5219
- return { ok: true, agent: resolved.agent, pubkey };
5329
+ if (moniker !== undefined) {
5330
+ logger.info("contact.moniker.set", { agentName: resolved.agent, pubkey });
5331
+ }
5332
+ // Review F3: echo the moniker ONLY when one rode this request — a re-add without a moniker
5333
+ // must not report null while an earlier pet name is still stored.
5334
+ return moniker !== undefined
5335
+ ? { ok: true, agent: resolved.agent, pubkey, moniker }
5336
+ : { ok: true, agent: resolved.agent, pubkey };
5337
+ });
5338
+ // MONIKER-3 AC3: rename (string) or clear (null) an existing contact's pet name. Absence of the
5339
+ // key is NOT a clear (Entry-66-F3): a request that omits it is malformed and rejected.
5340
+ handlers.set("cello_contact_set_moniker", async (params, connectionId) => {
5341
+ const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5342
+ if (!pubkey || !params || !("moniker" in params)) {
5343
+ return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'moniker' — a string to set the pet name, or null to clear it." };
5344
+ }
5345
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5346
+ if (!resolved.ok)
5347
+ return resolved;
5348
+ const raw = params.moniker ?? null;
5349
+ const moniker = raw === null ? null : validateMoniker(raw);
5350
+ if (raw !== null && moniker === null) {
5351
+ return {
5352
+ ok: false,
5353
+ reason: "invalid_moniker",
5354
+ guidance: `A moniker is 1-64 chars: letters, digits, '-' or '_' (regex ${MONIKER_RE.source}). Pass null to clear.`,
5355
+ };
5356
+ }
5357
+ if (!sessionNodeManager.setContactMoniker(resolved.agent, pubkey, moniker)) {
5358
+ 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.` };
5359
+ }
5360
+ logger.info("contact.moniker.set", { agentName: resolved.agent, pubkey, cleared: moniker === null });
5361
+ return { ok: true, agent: resolved.agent, pubkey, moniker };
5220
5362
  });
5221
5363
  handlers.set("cello_contact_remove", async (params, connectionId) => {
5222
5364
  const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
@@ -5234,9 +5376,44 @@ export async function startDaemon(config) {
5234
5376
  const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5235
5377
  if (!resolved.ok)
5236
5378
  return resolved;
5237
- const contacts = sessionNodeManager.listContacts(resolved.agent);
5379
+ // MONIKER-5 AC1: each row shows the resolved who (a contact has no session, so the offered
5380
+ // tier doesn't apply — pet name ?? fingerprint).
5381
+ const contacts = sessionNodeManager.listContacts(resolved.agent).map((c) => ({
5382
+ ...c,
5383
+ ...whoLabel({ localMoniker: c.moniker, offeredMoniker: null, pubkeyHex: c.pubkey }),
5384
+ }));
5238
5385
  return { ok: true, agent: resolved.agent, contacts };
5239
5386
  });
5387
+ // MONIKER-1 AC2/AC3: cello_set_moniker — set (or clear, via explicit null) an agent's outbound-name
5388
+ // override on the agents table. Validated at set-time with the shared MONIKER-0 rule; an invalid
5389
+ // value is rejected here AND at the store (backstop) — it can never be stored. Local-only: the
5390
+ // name is never sent to the directory (AC4).
5391
+ handlers.set("cello_set_moniker", async (params, connectionId) => {
5392
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5393
+ if (!resolved.ok)
5394
+ return resolved;
5395
+ // Absence is NOT a clear (review Finding 3): JSON preserves explicit null, so a request
5396
+ // that omits the key is malformed — rejecting it keeps a dropped field from silently
5397
+ // deleting a stored override while reporting success.
5398
+ if (!params || !("moniker" in params)) {
5399
+ return { ok: false, reason: "missing_params", guidance: "Provide 'moniker' — a string to set the outbound name, or null to clear the override." };
5400
+ }
5401
+ const raw = params.moniker ?? null;
5402
+ const moniker = raw === null ? null : validateMoniker(raw);
5403
+ if (raw !== null && moniker === null) {
5404
+ return {
5405
+ ok: false,
5406
+ reason: "invalid_moniker",
5407
+ guidance: `A moniker is 1-64 chars: letters, digits, '-' or '_' (regex ${MONIKER_RE.source}). Pass null to clear the override.`,
5408
+ };
5409
+ }
5410
+ const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
5411
+ if (!store.setMoniker(resolved.agent, moniker)) {
5412
+ return { ok: false, reason: "agent_not_found", guidance: `No active agent named '${resolved.agent}'. See cello_list_agents.` };
5413
+ }
5414
+ logger.info("agent.moniker.set", { agentName: resolved.agent, cleared: moniker === null });
5415
+ return { ok: true, agent: resolved.agent, moniker };
5416
+ });
5240
5417
  // M8C-TGDOOR-1: cello_telegram_set_token — persist the daemon-wide bot token + allowlisted
5241
5418
  // operator chat ID, then start the poller immediately (no restart needed).
5242
5419
  handlers.set("cello_telegram_set_token", async (params, _connectionId) => {
@@ -5289,7 +5466,8 @@ export async function startDaemon(config) {
5289
5466
  // content-free `cello_message` doorbell to the current-agent connection(s). The shim's generic
5290
5467
  // bridge (WAKE) forwards it to a live --channels session as notifications/claude/channel.
5291
5468
  sessionNodeManager.setOnContentArrived((agentName, sessionId, senderPubkey) => {
5292
- notificationDispatcher.dispatchCelloMessage(agentName, sessionId, senderPubkey);
5469
+ // MONIKER-4 AC2: the message doorbell names the sender the same way the session doorbell does.
5470
+ notificationDispatcher.dispatchCelloMessage(agentName, sessionId, senderPubkey, resolveWho(agentName, senderPubkey, sessionId));
5293
5471
  // M8C-AWAY-1: an unattended agent auto-acks an inbound message on an existing session.
5294
5472
  void sendAwayResponse(agentName, sessionId, "message");
5295
5473
  // M8C-TGDOOR-1: message-waiting — coalesced (ring-once-until-read) inside sendTelegramDoorbell.