@cello-protocol/daemon 0.0.36 → 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.
Files changed (38) hide show
  1. package/dist/daemon.d.ts.map +1 -1
  2. package/dist/daemon.js +187 -13
  3. package/dist/daemon.js.map +1 -1
  4. package/dist/db-identity-store.d.ts +15 -0
  5. package/dist/db-identity-store.d.ts.map +1 -1
  6. package/dist/db-identity-store.js +51 -4
  7. package/dist/db-identity-store.js.map +1 -1
  8. package/dist/network-directory-node.d.ts +6 -1
  9. package/dist/network-directory-node.d.ts.map +1 -1
  10. package/dist/network-directory-node.js +40 -5
  11. package/dist/network-directory-node.js.map +1 -1
  12. package/dist/notification-dispatcher.d.ts +8 -2
  13. package/dist/notification-dispatcher.d.ts.map +1 -1
  14. package/dist/notification-dispatcher.js +9 -2
  15. package/dist/notification-dispatcher.js.map +1 -1
  16. package/dist/registration-manager.d.ts.map +1 -1
  17. package/dist/registration-manager.js +1 -0
  18. package/dist/registration-manager.js.map +1 -1
  19. package/dist/session-assignment-parser.d.ts +21 -0
  20. package/dist/session-assignment-parser.d.ts.map +1 -1
  21. package/dist/session-assignment-parser.js +37 -0
  22. package/dist/session-assignment-parser.js.map +1 -1
  23. package/dist/session-ceremony.d.ts +6 -0
  24. package/dist/session-ceremony.d.ts.map +1 -1
  25. package/dist/session-ceremony.js +2 -1
  26. package/dist/session-ceremony.js.map +1 -1
  27. package/dist/session-node-manager.d.ts +15 -3
  28. package/dist/session-node-manager.d.ts.map +1 -1
  29. package/dist/session-node-manager.js +59 -6
  30. package/dist/session-node-manager.js.map +1 -1
  31. package/dist/types.d.ts +3 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/dist/who-label.d.ts +28 -0
  35. package/dist/who-label.d.ts.map +1 -0
  36. package/dist/who-label.js +31 -0
  37. package/dist/who-label.js.map +1 -0
  38. package/package.json +4 -4
@@ -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,CAi9K7E"}
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";
@@ -559,6 +560,7 @@ export async function startDaemon(config) {
559
560
  agentName,
560
561
  persistence: getPersistence(agentName),
561
562
  agentPubkeyHex,
563
+ keyProvider: agentKeyProvider,
562
564
  getNode: entry.getNode,
563
565
  getDirectoryEndpoint: getFailoverEndpoint,
564
566
  getConsortiumEndpoints: resolveConsortiumRoster,
@@ -570,6 +572,7 @@ export async function startDaemon(config) {
570
572
  agentName,
571
573
  persistence: getPersistence(agentName),
572
574
  agentPubkeyHex,
575
+ keyProvider: agentKeyProvider,
573
576
  getNode: entry.getNode,
574
577
  getDirectoryEndpoint: getFailoverEndpoint,
575
578
  getConsortiumEndpoints: resolveConsortiumRoster,
@@ -637,6 +640,7 @@ export async function startDaemon(config) {
637
640
  agentName: agent.name,
638
641
  persistence: getPersistence(agent.name),
639
642
  agentPubkeyHex: agent.pubkey,
643
+ keyProvider: agent.keyProvider,
640
644
  getNode: getDirectoryNode,
641
645
  getDirectoryEndpoint: getFailoverEndpoint,
642
646
  getConsortiumEndpoints: resolveConsortiumRoster,
@@ -647,6 +651,7 @@ export async function startDaemon(config) {
647
651
  agentName: agent.name,
648
652
  persistence: getPersistence(agent.name),
649
653
  agentPubkeyHex: agent.pubkey,
654
+ keyProvider: agent.keyProvider,
650
655
  getNode: getDirectoryNode,
651
656
  getDirectoryEndpoint: getFailoverEndpoint,
652
657
  getConsortiumEndpoints: resolveConsortiumRoster,
@@ -896,8 +901,27 @@ export async function startDaemon(config) {
896
901
  // Wraps notificationDispatcher.dispatchSessionStateChanged so every call site gets the
897
902
  // Telegram state-change doorbell for free (DoD: state changes ALWAYS ring, never coalesced) —
898
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
+ }
899
920
  function dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey) {
900
- 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);
901
925
  void sendTelegramDoorbell(agentName, sessionId, "state_change", `Session ${state}`);
902
926
  // Reviewer HIGH fix (a60d68ed): telegramRungUnread had NO cleanup at all — a session that
903
927
  // rings once and is never read via cello_receive/since_seq (e.g. the operator only ever uses
@@ -907,6 +931,14 @@ export async function startDaemon(config) {
907
931
  // preferable to an unbounded leak (the exact class of bug fixed for TTL-1's expired-log at
908
932
  // af8a701 in this same milestone).
909
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
+ }
910
942
  }
911
943
  async function handleInboundTelegramUpdate(update, client) {
912
944
  const chatId = update.message?.chat?.id !== undefined ? String(update.message.chat.id) : undefined;
@@ -1080,6 +1112,28 @@ export async function startDaemon(config) {
1080
1112
  if (t === "session_assignment" || t === "session_request_error")
1081
1113
  resolveFrame(frame);
1082
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
+ }
1083
1137
  try {
1084
1138
  const sent = await signaling.sendRaw({
1085
1139
  type: "session_request",
@@ -1087,6 +1141,7 @@ export async function startDaemon(config) {
1087
1141
  initiator_session_peer_id: sr.peerId,
1088
1142
  initiator_session_addrs: sr.addrs,
1089
1143
  wants_session_offer: true,
1144
+ ...(moniker !== undefined ? { moniker } : {}),
1090
1145
  });
1091
1146
  if (!sent.ok) {
1092
1147
  return { ok: false, reason: sent.reason ?? "directory_unreachable", guidance: sent.guidance ?? "Could not send session_request over the directory signaling stream." };
@@ -1143,6 +1198,7 @@ export async function startDaemon(config) {
1143
1198
  agentName,
1144
1199
  persistence: getPersistence(agentName),
1145
1200
  agentPubkeyHex,
1201
+ keyProvider: agentKeyProvider,
1146
1202
  getNode: () => nodeRef,
1147
1203
  getDirectoryEndpoint: getFailoverEndpoint,
1148
1204
  getConsortiumEndpoints: resolveConsortiumRoster,
@@ -1159,6 +1215,7 @@ export async function startDaemon(config) {
1159
1215
  agentName,
1160
1216
  persistence: getPersistence(agentName),
1161
1217
  agentPubkeyHex,
1218
+ keyProvider: agentKeyProvider,
1162
1219
  getNode: () => nodeRef,
1163
1220
  getDirectoryEndpoint: getFailoverEndpoint,
1164
1221
  getConsortiumEndpoints: resolveConsortiumRoster,
@@ -1738,9 +1795,9 @@ export async function startDaemon(config) {
1738
1795
  // the encrypted DB (NO key file), and wire the agent into the live daemon so it can be registered
1739
1796
  // and used WITHOUT a restart. Creation is explicit — cello_start_agent never auto-creates on a typo.
1740
1797
  handlers.set("cello_create_agent", async (params, _connectionId) => {
1741
- const name = params?.name;
1742
- if (!name || typeof name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
1743
- 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.` };
1744
1801
  }
1745
1802
  const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
1746
1803
  if (store.hasActiveAgent(name) || agents.some((a) => a.name === name)) {
@@ -1845,9 +1902,9 @@ export async function startDaemon(config) {
1845
1902
  }
1846
1903
  }
1847
1904
  handlers.set("cello_remove_agent", async (params, _connectionId) => {
1848
- const name = params?.name;
1849
- if (!name || typeof name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
1850
- 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}).` };
1851
1908
  }
1852
1909
  const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
1853
1910
  // The active row (a fresh removal) OR the most-recent retired row (a DB-001 re-push). Captured BEFORE
@@ -2720,6 +2777,7 @@ export async function startDaemon(config) {
2720
2777
  agentName,
2721
2778
  persistence: getPersistence(agentName),
2722
2779
  agentPubkeyHex: loaded.pubkey,
2780
+ keyProvider: loaded.keyProvider,
2723
2781
  getNode: entry.getNode,
2724
2782
  getDirectoryEndpoint: getFailoverEndpoint,
2725
2783
  getConsortiumEndpoints: resolveConsortiumRoster,
@@ -3374,6 +3432,8 @@ export async function startDaemon(config) {
3374
3432
  sessionId: row.session_id,
3375
3433
  agentName: row.agent_name,
3376
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),
3377
3437
  status: row.status,
3378
3438
  category,
3379
3439
  messageCount,
@@ -3861,6 +3921,10 @@ export async function startDaemon(config) {
3861
3921
  correlationId,
3862
3922
  });
3863
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();
3864
3928
  // Expired requests move HERE (from inboundSessionQueues) rather than vanishing — visible via
3865
3929
  // cello_check_notifications so the operator can see what they missed, not just silence.
3866
3930
  const expiredSessionRequests = new Map();
@@ -3921,6 +3985,10 @@ export async function startDaemon(config) {
3921
3985
  expiredList.splice(0, expiredList.length - EXPIRED_SESSION_REQUESTS_CAP); // keep newest N
3922
3986
  }
3923
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
+ }
3924
3992
  }
3925
3993
  else {
3926
3994
  live.push(e);
@@ -4017,6 +4085,8 @@ export async function startDaemon(config) {
4017
4085
  const relayAddrs = relayEndpoint && Array.isArray(relayEndpoint["multiaddrs"])
4018
4086
  ? relayEndpoint["multiaddrs"].filter((m) => typeof m === "string")
4019
4087
  : [];
4088
+ // MONIKER-2 AC2: validate the offered name ONCE, at this wire boundary.
4089
+ const monikerResult = extractOfferedMoniker(a);
4020
4090
  return {
4021
4091
  sessionIdHex,
4022
4092
  participantAPubkeyHex,
@@ -4031,6 +4101,9 @@ export async function startDaemon(config) {
4031
4101
  signerPubkeyHex: a["signer_pubkey"] !== undefined ? frameValueToHex(a["signer_pubkey"]) : null,
4032
4102
  relayPeerId,
4033
4103
  relayAddrs,
4104
+ offeredMoniker: monikerResult.offeredMoniker,
4105
+ monikerRejected: monikerResult.rejected,
4106
+ monikerRejectReason: monikerResult.reason ?? null,
4034
4107
  };
4035
4108
  }
4036
4109
  // Wait (bounded) for THIS AGENT's standing receiver to be ready. acceptSession consumes the
@@ -4135,10 +4208,28 @@ export async function startDaemon(config) {
4135
4208
  sessionPeerId: result.peerId,
4136
4209
  correlationId,
4137
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
+ }
4138
4228
  enqueueInboundSession(agentName, {
4139
4229
  sessionIdHex: parsed.sessionIdHex,
4140
4230
  counterpartyPubkeyHex: parsed.participantAPubkeyHex,
4141
4231
  genesisPrevRootHex,
4232
+ offeredMoniker: parsed.offeredMoniker,
4142
4233
  });
4143
4234
  dispatchSessionStateChangedWithTelegram(agentName, parsed.sessionIdHex, "created", parsed.participantAPubkeyHex);
4144
4235
  // M8C-TGDOOR-1: session requests ALWAYS ring (DoD) — no coalescing, unlike message-waiting.
@@ -4355,6 +4446,8 @@ export async function startDaemon(config) {
4355
4446
  session_id: e.sessionIdHex,
4356
4447
  counterparty_pubkey: e.counterpartyPubkeyHex,
4357
4448
  genesis_prev_root: e.genesisPrevRootHex,
4449
+ // MONIKER-2: validated-at-boundary display hint; null = absent or rejected.
4450
+ offered_moniker: e.offeredMoniker ?? null,
4358
4451
  });
4359
4452
  reapExpiredInboundSessions(agentName); // M8C-TTL-1: don't hand back a stale expired entry
4360
4453
  const queued = inboundSessionQueues.get(agentName);
@@ -5207,9 +5300,54 @@ export async function startDaemon(config) {
5207
5300
  const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5208
5301
  if (!resolved.ok)
5209
5302
  return resolved;
5210
- 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);
5211
5317
  logger.info("contact.added", { agent: resolved.agent, pubkey });
5212
- 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 };
5213
5351
  });
5214
5352
  handlers.set("cello_contact_remove", async (params, connectionId) => {
5215
5353
  const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
@@ -5227,9 +5365,44 @@ export async function startDaemon(config) {
5227
5365
  const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5228
5366
  if (!resolved.ok)
5229
5367
  return resolved;
5230
- 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
+ }));
5231
5374
  return { ok: true, agent: resolved.agent, contacts };
5232
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
+ });
5233
5406
  // M8C-TGDOOR-1: cello_telegram_set_token — persist the daemon-wide bot token + allowlisted
5234
5407
  // operator chat ID, then start the poller immediately (no restart needed).
5235
5408
  handlers.set("cello_telegram_set_token", async (params, _connectionId) => {
@@ -5282,7 +5455,8 @@ export async function startDaemon(config) {
5282
5455
  // content-free `cello_message` doorbell to the current-agent connection(s). The shim's generic
5283
5456
  // bridge (WAKE) forwards it to a live --channels session as notifications/claude/channel.
5284
5457
  sessionNodeManager.setOnContentArrived((agentName, sessionId, senderPubkey) => {
5285
- 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));
5286
5460
  // M8C-AWAY-1: an unattended agent auto-acks an inbound message on an existing session.
5287
5461
  void sendAwayResponse(agentName, sessionId, "message");
5288
5462
  // M8C-TGDOOR-1: message-waiting — coalesced (ring-once-until-read) inside sendTelegramDoorbell.