@cello-protocol/daemon 0.0.9 → 0.0.11

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,EAKrB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAK/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAoB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAM/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAmInF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;;;;;OAQG;IACH,gBAAgB,IAAI,SAAS,GAAG,IAAI,CAAC;IACrC;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;CACtC;AAyCD,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAk5G7E"}
1
+ {"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EAKrB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAK/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAoB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAM/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAmInF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;;;;;OAQG;IACH,gBAAgB,IAAI,SAAS,GAAG,IAAI,CAAC;IACrC;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;CACtC;AAyCD,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAulH7E"}
package/dist/daemon.js CHANGED
@@ -46,7 +46,7 @@ import { verify as ed25519Verify, sealToRecipient, generateKLocalSeed, InMemoryK
46
46
  import { attemptSealUpgrade as attemptSealUpgradeImpl, verifyUpgradeConfirmedCert } from "./seal-upgrade.js";
47
47
  // CELLO-M7-MSG-001 (AC-013/AC-018): the single application content-size cap, enforced
48
48
  // at the send point here (the receive point lives in the transport content decode).
49
- import { MAX_CONTENT_BYTES, computeGenesisPrevRoot } from "@cello-protocol/protocol-types";
49
+ import { MAX_CONTENT_BYTES, computeGenesisPrevRoot, buildAgentRevocationTbs } from "@cello-protocol/protocol-types";
50
50
  import { resolveCelloEnv, createTransportSelector, isProductionVariant, } from "./transport-composition.js";
51
51
  import { selectAdvertisedAddress } from "./transport-selector.js";
52
52
  import { parseSessionAssignment, sessionRequestErrorReason } from "./session-assignment-parser.js";
@@ -348,7 +348,11 @@ export async function startDaemon(config) {
348
348
  // L4: sort by name so the "primary" agent is STABLE across restarts — readdir
349
349
  // order (agent-loader) is platform-dependent and unsorted, which would otherwise
350
350
  // let the authenticating identity change between daemon restarts.
351
- const primaryAgent = [...loadedAgents].sort((a, b) => a.name.localeCompare(b.name))[0];
351
+ // CELLO-M7-ONBOARD-001: `primaryAgent` is mutable so the FIRST agent created at runtime
352
+ // (cello_create_agent on a fresh install) can be elected as the keystone identity — otherwise the
353
+ // directory door stays `reconnecting` forever until a restart. getAuthIdentity reads it on every
354
+ // reconnect attempt, so electing it makes the already-running reconnect loop authenticate + connect.
355
+ let primaryAgent = [...loadedAgents].sort((a, b) => a.name.localeCompare(b.name))[0];
352
356
  const getAuthIdentity = () => {
353
357
  if (!primaryAgent)
354
358
  return null;
@@ -518,36 +522,63 @@ export async function startDaemon(config) {
518
522
  await entry.signaling.stop();
519
523
  logger.info("agent.signaling.dropped", { agentName });
520
524
  }
521
- // DOD-SPINE-5: the PRIMARY agent registers + initiates over the keystone signaling
522
- // stream (not a per-agent one), so wire its ceremony_request handler on the keystone
523
- // manager too — otherwise a primary-agent initiator's session ceremony would time out.
524
- if (primaryAgent) {
525
- wireSessionCeremonyHandler({
526
- agentName: primaryAgent.name,
527
- persistence: getPersistence(primaryAgent.name),
528
- agentPubkeyHex: primaryAgent.pubkey,
525
+ // DOD-SPINE-5/7: the PRIMARY agent registers + initiates + coordinates the session/seal FROST
526
+ // ceremonies over the keystone signaling stream (not a per-agent one), so its ceremony/seal/offer
527
+ // handlers must be wired on the keystone manager — otherwise a primary-agent initiator's session
528
+ // ceremony would time out. CELLO-M7-ONBOARD-001: extracted into a function so it can also be wired
529
+ // when the first agent is elected at runtime (cello_create_agent), not only at startup.
530
+ // Returns a disposer that UNREGISTERS the six keystone listeners. Each wire*/register* helper returns
531
+ // its own unregister fn; the keystone manager is shared and never stopped, so (unlike the per-agent
532
+ // managers, which unregister implicitly on stop) these MUST be unregistered explicitly when the
533
+ // primary is cleared or re-elected — otherwise removing-then-recreating the primary STACKS a second
534
+ // full set of listeners on the shared stream, double-firing every ceremony/seal frame (review HIGH).
535
+ function wireKeystonePrimary(agent) {
536
+ const unregister = [];
537
+ unregister.push(wireSessionCeremonyHandler({
538
+ agentName: agent.name,
539
+ persistence: getPersistence(agent.name),
540
+ agentPubkeyHex: agent.pubkey,
529
541
  getNode: getDirectoryNode,
530
542
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
531
543
  signaling: signalingManager,
532
544
  logger,
533
- });
534
- // DOD-SPINE-7: the primary agent also coordinates the SEAL FROST ceremony on the keystone.
535
- wireSealCeremonyHandler({
536
- agentName: primaryAgent.name,
537
- persistence: getPersistence(primaryAgent.name),
538
- agentPubkeyHex: primaryAgent.pubkey,
545
+ }));
546
+ unregister.push(wireSealCeremonyHandler({
547
+ agentName: agent.name,
548
+ persistence: getPersistence(agent.name),
549
+ agentPubkeyHex: agent.pubkey,
539
550
  getNode: getDirectoryNode,
540
551
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
541
552
  signaling: signalingManager,
542
553
  logger,
543
- });
544
- wireSessionOfferHandler({
545
- agentName: primaryAgent.name,
546
- getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(primaryAgent.name),
554
+ }));
555
+ unregister.push(wireSessionOfferHandler({
556
+ agentName: agent.name,
557
+ getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
547
558
  signaling: signalingManager,
548
559
  logger,
549
- });
560
+ }));
561
+ // The primary closes its OWN sessions over the keystone stream, so the directory routes its
562
+ // session_sealed / seal_unilateral_confirmed / seal_unilateral_notification frames there. These
563
+ // seal-completion listeners MUST be registered for the keystone primary (startup OR runtime
564
+ // election) — otherwise the close waiter never resolves and the seal silently never finalizes
565
+ // (review HIGH: previously these were a separate startup-only block, skipped on a fresh install).
566
+ // The register* functions are hoisted declarations within startDaemon, so callable here.
567
+ unregister.push(registerSessionSealedListener(signalingManager, agent.name, agent.pubkey));
568
+ unregister.push(registerUnilateralConfirmedListener(signalingManager, agent.name, agent.pubkey));
569
+ unregister.push(registerUnilateralUpgradeListener(signalingManager, agent.name, agent.pubkey));
570
+ return () => { for (const u of unregister) {
571
+ try {
572
+ u();
573
+ }
574
+ catch { /* unregister is best-effort */ }
575
+ } };
550
576
  }
577
+ // Disposer for the currently-wired keystone primary listeners (CELLO-M7-REMOVE-001): call it before
578
+ // clearing or re-electing the primary so the shared stream never carries a stale/duplicate set.
579
+ let keystoneDispose;
580
+ if (primaryAgent)
581
+ keystoneDispose = wireKeystonePrimary(primaryAgent);
551
582
  // Per-connection state: tracks which agent is "current" for each IPC connection.
552
583
  // Key = connectionId (assigned by IPC server), Value = current agent name or null.
553
584
  const perConnectionState = new Map();
@@ -935,21 +966,40 @@ export async function startDaemon(config) {
935
966
  return { ok: false, reason: "invalid_agent_name", guidance: "Provide a 'name' (1-64 chars: letters, digits, '-' or '_') for the new agent." };
936
967
  }
937
968
  const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
938
- if (store.hasAgent(name) || agents.some((a) => a.name === name)) {
969
+ if (store.hasActiveAgent(name) || agents.some((a) => a.name === name)) {
939
970
  return { ok: false, reason: "agent_already_exists", guidance: `Agent '${name}' already exists. Choose a different name, or see cello_list_agents.` };
940
971
  }
941
972
  let pubkeyHex;
973
+ let agentId;
942
974
  try {
943
975
  const seed = generateKLocalSeed();
944
976
  const keyProvider = new InMemoryKeyProvider(seed);
945
977
  pubkeyHex = Buffer.from(await keyProvider.getPublicKey()).toString("hex");
946
- // SI-001: createAgent stores the seed in the encrypted DB and logs only the pubkey.
947
- store.createAgent(name, seed, pubkeyHex);
978
+ // SI-001: createAgent stores the seed in the encrypted DB and logs only the pubkey + agent_id.
979
+ agentId = store.createAgent(name, seed, pubkeyHex);
948
980
  // Runtime-add: make the agent immediately registrable/usable (the register handler resolves
949
981
  // identity from keyProviders/loadedAgents; per-agent signaling is created lazily on register).
950
982
  keyProviders.set(name, keyProvider);
951
- loadedAgents.push({ name, pubkey: pubkeyHex, keyProvider });
983
+ const loaded = { name, pubkey: pubkeyHex, keyProvider };
984
+ loadedAgents.push(loaded);
952
985
  agents.push({ name, state: "registered", pubkey: pubkeyHex });
986
+ // CELLO-M7-ONBOARD-001: on a FRESH install the daemon started with no agents, so there was no
987
+ // keystone identity and the directory door is stuck `reconnecting`. Elect this first agent as the
988
+ // keystone primary + wire its ceremony/seal handlers; the running reconnect loop then
989
+ // authenticates with it (getAuthIdentity now returns it) and connects on its next attempt — no
990
+ // restart needed. Only the FIRST agent is elected (loadedAgents is empty here, so it is also the
991
+ // name-sort min). NOTE (accepted LOW, review): if the operator then creates a lexicographically
992
+ // SMALLER second agent before any restart, the keystone primary stays this one until the next
993
+ // restart, where it re-derives to sort[0] — benign (each agent authenticates its own stream; the
994
+ // demoted agent gets fully wired via the per-agent path on restart), and not worth runtime
995
+ // re-election (un-wire/re-wire) churn.
996
+ if (!primaryAgent) {
997
+ // Dispose any prior keystone wiring before re-electing, so listeners never stack (REMOVE-001).
998
+ keystoneDispose?.();
999
+ primaryAgent = loaded;
1000
+ keystoneDispose = wireKeystonePrimary(loaded);
1001
+ logger.info("directory.keystone.elected", { agentName: name, agentPubkey: pubkeyHex });
1002
+ }
953
1003
  }
954
1004
  catch (err) {
955
1005
  logger.error("persist.identity.persist.failed", { agentName: name, error: err instanceof Error ? err.message : String(err) });
@@ -957,8 +1007,173 @@ export async function startDaemon(config) {
957
1007
  }
958
1008
  // Creation is not an online/offline transition — the agent appears (cello_list_agents) but is
959
1009
  // not online until cello_start_agent. Just record it.
960
- logger.info("agent.created", { agentName: name, agentPubkey: pubkeyHex });
961
- return { ok: true, name, pubkey: pubkeyHex };
1010
+ logger.info("agent.created", { agentName: name, agentId, agentPubkey: pubkeyHex });
1011
+ return { ok: true, name, pubkey: pubkeyHex, agentId };
1012
+ });
1013
+ // ─── CELLO-M7-REMOVE-001 (DOD-REMOVE-1): cello_remove_agent handler ───
1014
+ // RETIRE-AND-KEEP: flip the agent's local row to state='retired' (its row, keys, and history are
1015
+ // KEPT for accountability — never hard-deleted, SI-002) and FREE the human name for reuse. Purge the
1016
+ // retired identity from the live runtime so it stops operating and the name is immediately available
1017
+ // to a NEW `cello_create_agent`. One-way. (DEC-4: the signed DIRECTORY revocation is DOD-REMOVE-2 —
1018
+ // not built here; this unit is the local record shape only.)
1019
+ // CELLO-M7-REMOVE-001 (DOD-REMOVE-2): build + self-sign an agent revocation and submit it to the
1020
+ // directory on the agent's K_local-authenticated signaling stream. Self-authorized — the directory
1021
+ // verifies the signature against the agent's registered K_local before appending. Best-effort: returns
1022
+ // { recorded:false, reason } if the directory is unreachable / rejects (DB-001 — the caller still
1023
+ // applies the one-way local retire and surfaces a distinct status).
1024
+ async function submitAgentRevocation(opts) {
1025
+ const { agentName, signer, kLocalPubkeyHex, regAgentId } = opts;
1026
+ const epochId = "";
1027
+ const reason = "voluntary";
1028
+ const revokedAt = Date.now();
1029
+ const tbs = buildAgentRevocationTbs(regAgentId, kLocalPubkeyHex, epochId, reason, revokedAt);
1030
+ const sigHex = Buffer.from(await signer.sign(tbs)).toString("hex");
1031
+ // If the agent has no live signaling (e.g. a re-push of an already-retired agent), getAgentSignaling
1032
+ // lazily creates a dedicated manager — drop it again afterwards so we don't leak a reconnect loop.
1033
+ const hadSignaling = perAgentSignaling.has(agentName) || primaryAgent?.name === agentName;
1034
+ const { signaling } = getAgentSignaling(agentName, signer, kLocalPubkeyHex);
1035
+ const createdSignaling = !hadSignaling && primaryAgent?.name !== agentName;
1036
+ try {
1037
+ const connected = await waitForSignalingConnected(signaling, 10_000);
1038
+ if (!connected)
1039
+ return { recorded: false, reason: "directory_unreachable" };
1040
+ let resolveFrame;
1041
+ const pending = new Promise((r) => { resolveFrame = r; });
1042
+ // Match the reply by agent_id so a revocation on the shared keystone is never cross-wired.
1043
+ const unregister = signaling.registerInboundHandler((frame) => {
1044
+ const t = frame["type"];
1045
+ if ((t === "agent_revocation_ack" || t === "agent_revocation_error") && frame["agent_id"] === regAgentId)
1046
+ resolveFrame(frame);
1047
+ });
1048
+ try {
1049
+ const sent = await signaling.sendRaw({ type: "revoke_agent", agent_id: regAgentId, epoch_id: epochId, reason, revoked_at: revokedAt, signature: sigHex });
1050
+ if (!sent.ok)
1051
+ return { recorded: false, reason: sent.reason ?? "directory_unreachable" };
1052
+ let timer;
1053
+ const timeoutP = new Promise((r) => { timer = setTimeout(() => r({ type: "__timeout__" }), 15_000); });
1054
+ const frame = await Promise.race([pending, timeoutP]);
1055
+ clearTimeout(timer);
1056
+ if (frame["type"] === "__timeout__")
1057
+ return { recorded: false, reason: "timeout" };
1058
+ if (frame["type"] === "agent_revocation_error")
1059
+ return { recorded: false, reason: String(frame["reason"] ?? "rejected") };
1060
+ logger.info("agent.revocation.submitted", { agentName, agentId: regAgentId });
1061
+ return { recorded: true };
1062
+ }
1063
+ finally {
1064
+ unregister();
1065
+ }
1066
+ }
1067
+ finally {
1068
+ if (createdSignaling) {
1069
+ await dropAgentSignaling(agentName).catch((err) => {
1070
+ logger.warn("agent.revocation.signaling_teardown_failed", { agentName, error: err instanceof Error ? err.message : String(err) });
1071
+ });
1072
+ }
1073
+ }
1074
+ }
1075
+ handlers.set("cello_remove_agent", async (params, _connectionId) => {
1076
+ const name = params?.name;
1077
+ if (!name || typeof name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
1078
+ return { ok: false, reason: "invalid_agent_name", guidance: "Provide the 'name' of the agent to remove (1-64 chars: letters, digits, '-' or '_')." };
1079
+ }
1080
+ const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
1081
+ // The active row (a fresh removal) OR the most-recent retired row (a DB-001 re-push). Captured BEFORE
1082
+ // any retire — the active-only accessors filter retired rows out once state is flipped.
1083
+ const target = store.getAgentForRevocation(name);
1084
+ if (!target) {
1085
+ return { ok: false, reason: "agent_not_found", guidance: `No agent named '${name}'. Check cello_list_agents.` };
1086
+ }
1087
+ const wasActive = target.state !== "retired";
1088
+ const agentId = target.localAgentId;
1089
+ // An already-retired agent that was never registered has nothing to do — no local retire (one-way,
1090
+ // already done) and no directory revocation to push. Treat a repeat removal as agent_not_found (a
1091
+ // tombstone is never re-retired). A retired agent WITH a directory id falls through to the DB-001
1092
+ // re-push below.
1093
+ if (!wasActive && !target.regAgentId) {
1094
+ return { ok: false, reason: "agent_not_found", guidance: `No active agent named '${name}'. Removal is one-way; '${name}' is already retired.` };
1095
+ }
1096
+ // DOD-REMOVE-2: build + self-sign + submit the directory revocation. Done for a fresh removal AND a
1097
+ // re-push of an already-retired agent (DB-001). Skipped only if the agent was never registered (no
1098
+ // directory-known id to revoke). The signer is re-derived from the kept K_local seed, so it works
1099
+ // even for an already-retired agent whose runtime keyProvider was purged.
1100
+ let directoryRevocation = "skipped";
1101
+ let revocationReason;
1102
+ if (target.regAgentId) {
1103
+ const signer = new InMemoryKeyProvider(target.kLocalSeed);
1104
+ const kLocalPubkeyHex = Buffer.from(await signer.getPublicKey()).toString("hex");
1105
+ const res = await submitAgentRevocation({ agentName: name, signer, kLocalPubkeyHex, regAgentId: target.regAgentId });
1106
+ directoryRevocation = res.recorded ? "recorded" : "deferred";
1107
+ revocationReason = res.reason;
1108
+ if (!res.recorded) {
1109
+ logger.error("agent.removal.failed", { agentName: name, error: res.reason ?? "directory_unreachable" });
1110
+ }
1111
+ else {
1112
+ logger.info("agent.revocation.recorded", { agentId: target.regAgentId });
1113
+ }
1114
+ }
1115
+ else if (target.state === "registered") {
1116
+ // Anomaly (fallback-finder MEDIUM): the agent is locally marked registered but has no
1117
+ // directory-known id, so the revocation cannot be pushed. Do NOT report the benign "never
1118
+ // registered" — surface it loudly so a registered-but-unrevocable agent is visible.
1119
+ revocationReason = "registered_without_directory_id";
1120
+ logger.error("agent.removal.failed", { agentName: name, error: "registered_without_directory_id" });
1121
+ }
1122
+ // Local retire (one-way) + runtime purge — only for a fresh removal. An already-retired re-push does
1123
+ // not re-retire (and has nothing loaded to purge).
1124
+ if (wasActive) {
1125
+ store.retireAgent(name);
1126
+ // Tear down the retired identity's live runtime so it can no longer receive or re-authenticate.
1127
+ // AWAITED + LOGGED on failure (review HIGH-1 / fallback-finder MEDIUM): a teardown that didn't
1128
+ // happen must be visible.
1129
+ if (onlineAgents.has(name)) {
1130
+ onlineAgents.delete(name);
1131
+ await sessionNodeManager.removeStandingReceiverForAgent(name).catch((err) => {
1132
+ logger.warn("agent.removal.receiver_teardown_failed", { agentName: name, agentId, error: err instanceof Error ? err.message : String(err) });
1133
+ });
1134
+ }
1135
+ // Stop+forget the retired agent's dedicated per-agent signaling manager (review HIGH-1).
1136
+ await dropAgentSignaling(name).catch((err) => {
1137
+ logger.warn("agent.removal.signaling_teardown_failed", { agentName: name, agentId, error: err instanceof Error ? err.message : String(err) });
1138
+ });
1139
+ keyProviders.delete(name);
1140
+ const li = loadedAgents.findIndex((a) => a.name === name);
1141
+ if (li >= 0)
1142
+ loadedAgents.splice(li, 1);
1143
+ const ai = agents.findIndex((a) => a.name === name);
1144
+ if (ai >= 0)
1145
+ agents.splice(ai, 1);
1146
+ // If the retired agent was the directory keystone, UNWIRE its keystone listeners + clear it
1147
+ // (review HIGH-2: the listeners were never unregistered → double-wire on re-election).
1148
+ if (primaryAgent && primaryAgent.name === name) {
1149
+ keystoneDispose?.();
1150
+ keystoneDispose = undefined;
1151
+ primaryAgent = undefined;
1152
+ logger.info("directory.keystone.cleared", { agentName: name, agentId });
1153
+ }
1154
+ // Drop the retired agent as any connection's current agent.
1155
+ for (const [connId, state] of perConnectionState) {
1156
+ if (state.currentAgent === name) {
1157
+ state.currentAgent = null;
1158
+ notificationDispatcher.setCurrentAgent(connId, null);
1159
+ notificationDispatcher.dispatchAgentCurrentChanged(connId, name, null);
1160
+ }
1161
+ }
1162
+ // agent.removal.retired (observability): never log key material — only the name + agent_id.
1163
+ logger.info("agent.removal.retired", { agentName: name, agentId });
1164
+ notificationDispatcher.dispatchAgentStateChanged(name, "offline", "removed");
1165
+ }
1166
+ const baseLine = wasActive
1167
+ ? "Agent retired. This is one-way: its identity and history are kept for accountability, but it can no longer connect. The name is now free to reuse with cello create-agent."
1168
+ : "Agent was already retired locally.";
1169
+ const dirLine = directoryRevocation === "recorded"
1170
+ ? " A signed revocation was recorded at the directory — peers will see it as revoked."
1171
+ : directoryRevocation === "deferred"
1172
+ ? ` The directory could NOT be reached to record the revocation (${revocationReason ?? "directory_unreachable"}) — peers do not yet see it as revoked. Re-run 'cello remove-agent ${name}' when the directory is reachable to push it.`
1173
+ : revocationReason === "registered_without_directory_id"
1174
+ ? " WARNING: this agent appears registered but has no directory id recorded locally, so its revocation could NOT be pushed — peers may still see it as reachable. Check the daemon logs (agent.removal.failed)."
1175
+ : " It was never registered with a directory, so there is no directory revocation to record.";
1176
+ return { ok: true, name, agentId, oneWay: true, directoryRevocation, guidance: baseLine + dirLine };
962
1177
  });
963
1178
  // ─── MCP-001: cello_stop_agent handler ───
964
1179
  handlers.set("cello_stop_agent", async (params, _connectionId) => {
@@ -2603,25 +2818,11 @@ export async function startDaemon(config) {
2603
2818
  handleInboundSessionAssignment(frame);
2604
2819
  });
2605
2820
  // M7 DOD-SPINE-7: session_sealed listener. The directory delivers this over the SESSION-OWNING
2606
- // agent's signaling stream after the relay-mediated bilateral seal notarizes. Registered on the
2607
- // keystone (primary agent) here AND per-agent in getAgentSignaling — for a non-primary agent the
2608
- // directory routes session_sealed to its per-agent stream, so a keystone-only listener would
2609
- // leave that agent's close waiter unresolved (reviewer finding). Resolve the close waiter with
2610
- // the sealed_root and mark the session sealed. Guarded on primaryAgent: the keystone listener now
2611
- // needs the primary agent's name/pubkey to verify the seal signature (legibility-TBS-binding), and
2612
- // with no agents there are no keystone sessions to seal anyway.
2613
- if (primaryAgent) {
2614
- registerSessionSealedListener(signalingManager, primaryAgent.name, primaryAgent.pubkey);
2615
- // SESSION-002: the keystone counterpart for the unilateral certificate listener — the
2616
- // primary agent closes over the keystone stream, so the directory routes its
2617
- // seal_unilateral_confirmed there (mirrors the session_sealed keystone listener above).
2618
- registerUnilateralConfirmedListener(signalingManager, primaryAgent.name, primaryAgent.pubkey);
2619
- // DOD-UP-1: the keystone counterpart for the absent-party upgrade listener. The directory
2620
- // PUSHES the queued seal_unilateral_notification during the keystone's auth/reconnect drain —
2621
- // BEFORE any cello_start_agent runs — so the handler MUST be registered here at startup, not only
2622
- // in startAgent, or B (the returning absent party) would miss its own ratification trigger.
2623
- registerUnilateralUpgradeListener(signalingManager, primaryAgent.name, primaryAgent.pubkey);
2624
- }
2821
+ // CELLO-M7-ONBOARD-001: the keystone primary's seal-completion listeners (session_sealed /
2822
+ // seal_unilateral_confirmed / seal_unilateral_notification) are now registered inside
2823
+ // wireKeystonePrimary at startup for a loaded primary AND at runtime when the first agent is
2824
+ // elected (cello_create_agent on a fresh install). They must NOT be a separate startup-only block,
2825
+ // or a fresh-install primary's seals would silently never finalize (review HIGH).
2625
2826
  // cello_await_session — the counterparty's blocking pull for the next inbound session.
2626
2827
  // Returns immediately if one is already queued for the current agent (FIFO), otherwise
2627
2828
  // blocks until one arrives or timeout_ms elapses. Response shape matches the established