@cello-protocol/daemon 0.0.47 → 0.0.48

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;AAU/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,CAy8L7E"}
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;AAKpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAU/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,CA4gM7E"}
package/dist/daemon.js CHANGED
@@ -31,6 +31,7 @@ import { dirname, join } from "node:path";
31
31
  import { loadAgents } from "./agent-loader.js";
32
32
  import { acquireLock, removeLock } from "./lock-file.js";
33
33
  import { createIpcServer } from "./ipc-server.js";
34
+ import { renderForSurface } from "./vocabulary.js";
34
35
  import { SessionNodeManager } from "./session-node-manager.js";
35
36
  import { TIER, isKnownTierValue } from "./contacts-tier-migration.js";
36
37
  import { isValidSettingKey, allSettingKeys, validateSettingValue, AWAY_MESSAGE_MAX_LEN } from "./agent-settings-keys.js";
@@ -1797,7 +1798,7 @@ export async function startDaemon(config) {
1797
1798
  function startAgentInternal(name) {
1798
1799
  const agent = agents.find((a) => a.name === name);
1799
1800
  if (!agent || agent.state === "load_failed") {
1800
- return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Run 'cello login' to register agents, or check agent names with cello_list_agents.` };
1801
+ return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Run 'cello login' to register agents, or check agent names with cello_agents.` };
1801
1802
  }
1802
1803
  if (onlineAgents.has(name)) {
1803
1804
  // Idempotent — already online, no event
@@ -1874,7 +1875,7 @@ export async function startDaemon(config) {
1874
1875
  }
1875
1876
  const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
1876
1877
  if (store.hasActiveAgent(name) || agents.some((a) => a.name === name)) {
1877
- return { ok: false, reason: "agent_already_exists", guidance: `Agent '${name}' already exists. Choose a different name, or see cello_list_agents.` };
1878
+ return { ok: false, reason: "agent_already_exists", guidance: `Agent '${name}' already exists. Choose a different name, or see cello_agents.` };
1878
1879
  }
1879
1880
  let pubkeyHex;
1880
1881
  let agentId;
@@ -1984,7 +1985,7 @@ export async function startDaemon(config) {
1984
1985
  // any retire — the active-only accessors filter retired rows out once state is flipped.
1985
1986
  const target = store.getAgentForRevocation(name);
1986
1987
  if (!target) {
1987
- return { ok: false, reason: "agent_not_found", guidance: `No agent named '${name}'. Check cello_list_agents.` };
1988
+ return { ok: false, reason: "agent_not_found", guidance: `No agent named '${name}'. Check cello_agents.` };
1988
1989
  }
1989
1990
  const wasActive = target.state !== "retired";
1990
1991
  const agentId = target.localAgentId;
@@ -2082,7 +2083,7 @@ export async function startDaemon(config) {
2082
2083
  }
2083
2084
  const agent = agents.find((a) => a.name === name);
2084
2085
  if (!agent || agent.state === "load_failed") {
2085
- return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Check agent names with cello_list_agents.` };
2086
+ return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Check agent names with cello_agents.` };
2086
2087
  }
2087
2088
  if (!onlineAgents.has(name)) {
2088
2089
  // Idempotent — already registered/offline, no event
@@ -2113,7 +2114,7 @@ export async function startDaemon(config) {
2113
2114
  }
2114
2115
  const agent = agents.find((a) => a.name === name);
2115
2116
  if (!agent || agent.state === "load_failed") {
2116
- return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Create it with 'cello create-agent ${name}', register it, then retry — or check names with cello_list_agents.` };
2117
+ return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Create it with 'cello create-agent ${name}', register it, then retry — or check names with cello_agents.` };
2117
2118
  }
2118
2119
  const connState = perConnectionState.get(connectionId);
2119
2120
  if (!connState) {
@@ -2166,7 +2167,7 @@ export async function startDaemon(config) {
2166
2167
  const reg = await new DbRegistrationPersistence({ db: sessionNodeManager.getDb(), agentName: name, logger }).loadRegistrationState();
2167
2168
  if (!reg || reg.status !== "active") {
2168
2169
  result["warning"] = "not_registered";
2169
- result["warning_guidance"] = `Agent '${name}' is now selected but is not registered with the directory — run 'cello register ${name}' to enable sessions with peers. Run 'cello status' to watch registration complete.`;
2170
+ result["warning_guidance"] = `Agent '${name}' is now selected but is not registered with the directory — run 'cello register-agent ${name}' to enable sessions with peers. Run 'cello status' to watch registration complete.`;
2170
2171
  }
2171
2172
  }
2172
2173
  catch (err) {
@@ -2219,11 +2220,11 @@ export async function startDaemon(config) {
2219
2220
  return { ok: false, reason: "missing_params", guidance: "Provide 'agent' (the agent name to register) and 'preAuthToken' (the pre-authorization ticket from the CELLO Operations Agent)." };
2220
2221
  }
2221
2222
  if (!preAuthToken) {
2222
- return { ok: false, reason: "missing_preauth_token", guidance: "Registration requires a 'preAuthToken' issued by the CELLO Operations Agent (Telegram). Obtain one, then retry cello_register." };
2223
+ return { ok: false, reason: "missing_preauth_token", guidance: "Registration requires a 'preAuthToken' issued by the CELLO Operations Agent (Telegram). Obtain one, then retry 'cello register-agent'." };
2223
2224
  }
2224
2225
  const keyProvider = keyProviders.get(name);
2225
2226
  if (!keyProvider) {
2226
- return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Create it first with cello_create_agent('${name}') (or 'cello create-agent ${name}'), then retry cello_register.` };
2227
+ return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Create it first with 'cello create-agent ${name}', then retry 'cello register-agent'.` };
2227
2228
  }
2228
2229
  if (!directoryEndpointResolver) {
2229
2230
  return { ok: false, reason: "directory_unreachable", guidance: "The daemon has no directory endpoint resolver configured, so it cannot reach the directory to register." };
@@ -2273,7 +2274,7 @@ export async function startDaemon(config) {
2273
2274
  return {
2274
2275
  ok: false,
2275
2276
  reason: "directory_signaling_timeout",
2276
- guidance: `Agent '${name}' could not establish its directory signaling stream within 10s. Check CELLO_DIRECTORY_URL and that the directory is reachable, then retry cello_register.`,
2277
+ guidance: `Agent '${name}' could not establish its directory signaling stream within 10s. Check CELLO_DIRECTORY_URL and that the directory is reachable, then retry 'cello register-agent'.`,
2277
2278
  };
2278
2279
  }
2279
2280
  const persistence = getPersistence(name);
@@ -3092,7 +3093,7 @@ export async function startDaemon(config) {
3092
3093
  return {
3093
3094
  ok: false,
3094
3095
  reason: "session_not_found",
3095
- guidance: "No session found with this ID. Check cello_list_sessions for active and interrupted sessions.",
3096
+ guidance: "No session found with this ID. Check cello_sessions for active and interrupted sessions.",
3096
3097
  };
3097
3098
  }
3098
3099
  // Ownership: redundant now that the lookup is agent-scoped (record.agent_name === currentAgent),
@@ -3101,7 +3102,7 @@ export async function startDaemon(config) {
3101
3102
  return {
3102
3103
  ok: false,
3103
3104
  reason: "session_not_owned",
3104
- guidance: "This session belongs to a different agent. Call cello_use_agent to switch to the agent that owns it (see cello_list_sessions), then retry.",
3105
+ guidance: "This session belongs to a different agent. Call cello_use_agent to switch to the agent that owns it (see cello_sessions), then retry.",
3105
3106
  };
3106
3107
  }
3107
3108
  // AC-010: already sealed
@@ -3109,7 +3110,7 @@ export async function startDaemon(config) {
3109
3110
  return {
3110
3111
  ok: false,
3111
3112
  reason: "session_already_sealed",
3112
- guidance: "This session is already sealed. No further action is needed — check cello_list_sessions to view its sealed record and the FROST notarization.",
3113
+ guidance: "This session is already sealed. No further action is needed — check cello_sessions to view its sealed record and the FROST notarization.",
3113
3114
  };
3114
3115
  }
3115
3116
  // CC-5/F21 terminal-escape: force-abandon a session that can never be bilaterally sealed — a
@@ -3354,7 +3355,7 @@ export async function startDaemon(config) {
3354
3355
  return {
3355
3356
  ok: false,
3356
3357
  reason: uniResult.reason,
3357
- guidance: "The unilateral seal did not complete (the directory could not verify the reported root, or the certificate failed verification). Confirm your messages reached the relay (cello_list_sessions) before retrying cello_close_session.",
3358
+ guidance: "The unilateral seal did not complete (the directory could not verify the reported root, or the certificate failed verification). Confirm your messages reached the relay (cello_sessions) before retrying cello_close_session.",
3358
3359
  };
3359
3360
  }
3360
3361
  finally {
@@ -3374,7 +3375,7 @@ export async function startDaemon(config) {
3374
3375
  return {
3375
3376
  ok: false,
3376
3377
  reason: "session_not_closeable",
3377
- guidance: `Session is in status '${record.status}', which cannot be closed via cello_close_session. Check cello_list_sessions; a seal_interrupted_pending session is awaiting FROST notarization.`,
3378
+ guidance: `Session is in status '${record.status}', which cannot be closed via cello_close_session. Check cello_sessions; a seal_interrupted_pending session is awaiting FROST notarization.`,
3378
3379
  };
3379
3380
  });
3380
3381
  // ─── MCP-001: stubs for tools registered in cello-mcp.ts but not yet implemented ───
@@ -3396,7 +3397,7 @@ export async function startDaemon(config) {
3396
3397
  // cello-mcp forwards this as { session_id } (snake_case, matching the other session tools).
3397
3398
  const sessionId = params?.["session_id"];
3398
3399
  if (!sessionId || typeof sessionId !== "string") {
3399
- return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) of the sealed session. Check cello_list_sessions for sealed sessions." };
3400
+ return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) of the sealed session. Check cello_sessions for sealed sessions." };
3400
3401
  }
3401
3402
  // DOD-LOOP-1: the certificate is keyed by (agent, session_id) — read the current agent's.
3402
3403
  const connState = perConnectionState.get(connectionId);
@@ -3428,7 +3429,7 @@ export async function startDaemon(config) {
3428
3429
  return {
3429
3430
  ok: false,
3430
3431
  reason: "wrong_agent",
3431
- guidance: `This session belongs to agent '${owner.name}', not the current agent '${agentName}'. Call cello_use_agent('${owner.name}') then retry cello_get_sealed_receipt.`,
3432
+ guidance: `This session belongs to agent '${owner.name}', not the current agent '${agentName}'. Call cello_use_agent('${owner.name}') then retry cello_sealed_receipt.`,
3432
3433
  };
3433
3434
  }
3434
3435
  // A truncated paste: the id is a strict prefix of one of this agent's real session ids.
@@ -3439,13 +3440,13 @@ export async function startDaemon(config) {
3439
3440
  return {
3440
3441
  ok: false,
3441
3442
  reason: "session_id_too_short",
3442
- guidance: "That looks like a truncated session id. cello_list_sessions and cello status show the FULL id — copy the complete id and retry.",
3443
+ guidance: "That looks like a truncated session id. cello_sessions and cello status show the FULL id — copy the complete id and retry.",
3443
3444
  };
3444
3445
  }
3445
3446
  return {
3446
3447
  ok: false,
3447
3448
  reason: "unknown_session",
3448
- guidance: "No session with this id belongs to the current agent. Run cello_list_sessions to see the full ids of this agent's sessions.",
3449
+ guidance: "No session with this id belongs to the current agent. Run cello_sessions to see the full ids of this agent's sessions.",
3449
3450
  };
3450
3451
  });
3451
3452
  // DOD-LOG-1 (PERSIST-LOG-001): read the durable, decrypted conversation transcript for a session —
@@ -3455,7 +3456,7 @@ export async function startDaemon(config) {
3455
3456
  handlers.set("cello_get_transcript", async (params, connectionId) => {
3456
3457
  const sessionId = params?.["session_id"];
3457
3458
  if (!sessionId || typeof sessionId !== "string") {
3458
- return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) whose transcript to read. See cello_list_sessions." };
3459
+ return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) whose transcript to read. See cello_sessions." };
3459
3460
  }
3460
3461
  const connState = perConnectionState.get(connectionId);
3461
3462
  // CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
@@ -4743,7 +4744,7 @@ export async function startDaemon(config) {
4743
4744
  return {
4744
4745
  ok: false,
4745
4746
  reason: "seal_interrupted_counterparty_unavailable",
4746
- guidance: "The counterparty is not currently reachable to complete the seal-interrupted flow. Retry when the counterparty is online — check their connection status via cello_list_connections.",
4747
+ guidance: "The counterparty is not currently reachable to complete the seal-interrupted flow. Retry when the counterparty is online — check their connection status via cello_status.",
4747
4748
  };
4748
4749
  }
4749
4750
  // Wait for counterparty ack/rejection via the shared signaling await machinery.
@@ -4759,7 +4760,7 @@ export async function startDaemon(config) {
4759
4760
  return {
4760
4761
  ok: false,
4761
4762
  reason: "seal_interrupted_counterparty_unavailable",
4762
- guidance: "The counterparty is not currently reachable to complete the seal-interrupted flow. Retry when the counterparty is online — check their connection status via cello_list_connections.",
4763
+ guidance: "The counterparty is not currently reachable to complete the seal-interrupted flow. Retry when the counterparty is online — check their connection status via cello_status.",
4763
4764
  };
4764
4765
  }
4765
4766
  if (ackResult.type === "seal_interrupted_rejection") {
@@ -4955,7 +4956,7 @@ export async function startDaemon(config) {
4955
4956
  ok: false,
4956
4957
  reason,
4957
4958
  guidance: ackResult.type === "timeout"
4958
- ? "The counterparty did not acknowledge the seal in time. Retry when they are online — check cello_list_connections. The session remains active and usable."
4959
+ ? "The counterparty did not acknowledge the seal in time. Retry when they are online — check cello_status. The session remains active and usable."
4959
4960
  : "The counterparty rejected the seal request. Their session state may be inconsistent. Ask them to check cello status before retrying.",
4960
4961
  };
4961
4962
  }
@@ -5030,7 +5031,7 @@ export async function startDaemon(config) {
5030
5031
  // DOD-LOOP-1: the (agent, session_id) lookup is itself the ownership scope.
5031
5032
  const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
5032
5033
  if (!record) {
5033
- return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check cello_list_sessions for active sessions." };
5034
+ return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check cello_sessions for active sessions." };
5034
5035
  }
5035
5036
  if (record.agent_name !== agentName) {
5036
5037
  return { ok: false, reason: "session_not_owned", guidance: "This session belongs to a different agent. Call cello_use_agent to switch to the agent that owns it, then retry." };
@@ -5091,7 +5092,12 @@ export async function startDaemon(config) {
5091
5092
  current_seq: currentSeq,
5092
5093
  last_read_seq: connectionCursor,
5093
5094
  unread_received: unreadReceived,
5094
- guidance: `This agent hasn't read ${unreadReceived} received message(s) on session ${sessionId}. Call cello_receive (or cello_get_transcript for the full history) to read them, then retry the send.`,
5095
+ // DOD-ONBOARD-HELP-1 §5: say what is wrong and what to do, in that order, in plain words.
5096
+ // The old text opened with the internal noun ("this agent hasn't read…"); a refused send is
5097
+ // the most common wall a new operator hits, so it leads with the COUNT and the FIX. The
5098
+ // session id is interpolated so the remedy is copy-pasteable, not a template to fill in.
5099
+ // Rendered per surface at the IPC boundary — a CLI caller sees `cello receive <id>`.
5100
+ guidance: `${unreadReceived} unread message(s) — run cello_receive ${sessionId} first to read them, then send again. (Or cello_transcript ${sessionId} for the whole conversation.) You are blocked from replying to something you haven't read.`,
5095
5101
  };
5096
5102
  }
5097
5103
  // M9-FEED-001 §6: the agent's governance re-send decisions, keyed by the flagId a prior `warn`
@@ -5214,7 +5220,7 @@ export async function startDaemon(config) {
5214
5220
  return {
5215
5221
  ok: false,
5216
5222
  reason: sendResult.reason,
5217
- guidance: "The content could not be delivered over the session stream right now. It has been queued in the durable retry queue and will be retried when the counterparty reconnects. The session remains usable — check cello_list_connections for the counterparty's status.",
5223
+ guidance: "The content could not be delivered over the session stream right now. It has been queued in the durable retry queue and will be retried when the counterparty reconnects. The session remains usable — check cello_status for the counterparty's status.",
5218
5224
  };
5219
5225
  }
5220
5226
  // Delivered directly OR dispatched to relay (DOD-LEAVEMSG-1) — either way the content is now
@@ -5313,7 +5319,7 @@ export async function startDaemon(config) {
5313
5319
  let transcriptOnly = false;
5314
5320
  if (!record) {
5315
5321
  if (sessionNodeManager.readTranscript(agentName, sessionId).messages.length === 0) {
5316
- return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check cello_list_sessions." };
5322
+ return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check cello_sessions." };
5317
5323
  }
5318
5324
  transcriptOnly = true;
5319
5325
  }
@@ -5360,7 +5366,7 @@ export async function startDaemon(config) {
5360
5366
  return {
5361
5367
  ok: false,
5362
5368
  reason: "session_not_live",
5363
- guidance: "This session exists only as a durable transcript (no live session — it was never established or predates this daemon). Read it with cello_receive { since_seq } (e.g. since_seq: -1 for everything) or cello_get_transcript.",
5369
+ guidance: "This session exists only as a durable transcript (no live session — it was never established or predates this daemon). Read it with cello_receive { since_seq } (e.g. since_seq: -1 for everything) or cello_transcript.",
5364
5370
  };
5365
5371
  }
5366
5372
  const rawTimeout = params?.timeout_ms;
@@ -5401,8 +5407,8 @@ export async function startDaemon(config) {
5401
5407
  ...(sealedRoot ? { sealed_root: sealedRoot } : {}),
5402
5408
  unread_count: terminal.unreadCount,
5403
5409
  guidance: terminal.unreadCount > 0
5404
- ? `The session has been sealed by both parties. ${terminal.unreadCount} message(s) arrived that were not read live — call cello_get_transcript to retrieve the full sealed history. No further actions are required on this session.`
5405
- : "The session has been sealed by both parties. The full history is available via cello_get_transcript. No further actions are required on this session.",
5410
+ ? `The session has been sealed by both parties. ${terminal.unreadCount} message(s) arrived that were not read live — call cello_transcript to retrieve the full sealed history. No further actions are required on this session.`
5411
+ : "The session has been sealed by both parties. The full history is available via cello_transcript. No further actions are required on this session.",
5406
5412
  };
5407
5413
  }
5408
5414
  // 3) Out of time — non-blocking-equivalent empty answer.
@@ -5420,7 +5426,7 @@ export async function startDaemon(config) {
5420
5426
  guidance: "The counterparty's session connection has dropped (liveness: gone) — it may have crashed or gone offline. No more content will arrive on the direct path. Call cello_close_session to seal the session; if the counterparty never co-closes, a unilateral seal becomes available after the directory's delivery-grace window.",
5421
5427
  };
5422
5428
  }
5423
- return { ok: true, content: null, guidance: "No content arrived within timeout_ms. Call cello_receive again to keep waiting, or read cello_get_transcript for the full session history." };
5429
+ return { ok: true, content: null, guidance: "No content arrived within timeout_ms. Call cello_receive again to keep waiting, or read cello_transcript for the full session history." };
5424
5430
  }
5425
5431
  await new Promise((r) => setTimeout(r, Math.min(20, remaining)));
5426
5432
  }
@@ -5477,6 +5483,11 @@ export async function startDaemon(config) {
5477
5483
  noticed_at: n.noticed_at,
5478
5484
  // Names the contact by the operator's OWN pet name (AC3); the self-declared name is a quoted,
5479
5485
  // untrusted claim; the adopt command carries its arguments so it is copy-pasteable.
5486
+ // The MCP call form below is translated WHOLE (arguments and all) for a CLI caller by
5487
+ // vocabulary.ts's CALL_FORMS — rewriting only the tool name would leave the tool's JSON
5488
+ // bolted onto a CLI verb, which looks like a command and is not one. `claimed_name` above
5489
+ // carries the peer's self-declared name UNTOUCHED, so the operator always has the raw value
5490
+ // even though the prose copy passes through the renderer.
5480
5491
  notice: `Your contact ${n.moniker !== null ? `"${n.moniker}" ` : ""}(${n.pubkey.slice(0, 16)}…) now calls themselves ` +
5481
5492
  `"${n.offered_name}" (self-declared — unverified). Adopt it: cello_contact_set_moniker ` +
5482
5493
  `{ pubkey: "${n.pubkey}", moniker: "${n.offered_name}" }, or ignore.`,
@@ -5494,6 +5505,30 @@ export async function startDaemon(config) {
5494
5505
  // M8C-CONTACT-1: cello contact add/remove/list [--agent <name>]. All three resolve the target
5495
5506
  // agent the same way — an explicit params.agent, else this connection's current/sole-online
5496
5507
  // agent (F18) — so a CLI/AI operator gets the same no_current_agent guidance INBOX already uses.
5508
+ /**
5509
+ * Review finding 7 — a contact pubkey must LOOK like a pubkey, or the row can never work.
5510
+ *
5511
+ * The address-book handlers took `params.pubkey` as any string and stored it. So
5512
+ * `cello contact tier add` — a plausible slip under the new `contact <pubkey> <op>` shape, where
5513
+ * the FIRST positional is the pubkey — persisted a contact whose pubkey is the literal text
5514
+ * "tier", and answered ok:true. Nothing would ever match it: it is not a key, so it can never be
5515
+ * a counterparty. The operator is told they added someone; they added a typo.
5516
+ *
5517
+ * A key is 32 bytes, hex: 64 characters. Anything else is refused, loudly, with the value echoed
5518
+ * so the slip is obvious. Returns null when the pubkey is absent — the caller's own
5519
+ * missing_params check owns that case and says something more useful.
5520
+ */
5521
+ function invalidPubkey(pubkey) {
5522
+ if (pubkey === undefined)
5523
+ return null; // absent → the caller's missing_params check owns it
5524
+ if (/^[0-9a-fA-F]{64}$/.test(pubkey))
5525
+ return null;
5526
+ return {
5527
+ ok: false,
5528
+ reason: "invalid_pubkey",
5529
+ guidance: `'${pubkey}' is not a public key. A CELLO public key is 64 hex characters (32 bytes). Nothing was changed — storing it would create an address-book entry that can never match anyone. Note the argument order is 'cello contact <pubkey> <operation>'.`,
5530
+ };
5531
+ }
5497
5532
  function resolveContactAgent(connState, params) {
5498
5533
  const explicit = typeof params?.agent === "string" ? params.agent : undefined;
5499
5534
  if (explicit)
@@ -5510,6 +5545,9 @@ export async function startDaemon(config) {
5510
5545
  }
5511
5546
  handlers.set("cello_contact_add", async (params, connectionId) => {
5512
5547
  const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5548
+ const badPubkey = invalidPubkey(pubkey);
5549
+ if (badPubkey)
5550
+ return badPubkey;
5513
5551
  if (!pubkey) {
5514
5552
  return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to add." };
5515
5553
  }
@@ -5547,6 +5585,9 @@ export async function startDaemon(config) {
5547
5585
  // key is NOT a clear (Entry-66-F3): a request that omits it is malformed and rejected.
5548
5586
  handlers.set("cello_contact_set_moniker", async (params, connectionId) => {
5549
5587
  const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5588
+ const badPubkey = invalidPubkey(pubkey);
5589
+ if (badPubkey)
5590
+ return badPubkey;
5550
5591
  if (!pubkey || !params || !("moniker" in params)) {
5551
5592
  return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'moniker' — a string to set the pet name, or null to clear it." };
5552
5593
  }
@@ -5572,6 +5613,9 @@ export async function startDaemon(config) {
5572
5613
  // (0..4) — an unknown value is REFUSED, never coerced. Emits contact.tier.changed (old→new).
5573
5614
  handlers.set("cello_contact_set_tier", async (params, connectionId) => {
5574
5615
  const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5616
+ const badPubkey = invalidPubkey(pubkey);
5617
+ if (badPubkey)
5618
+ return badPubkey;
5575
5619
  const tier = typeof params?.tier === "number" ? params.tier : undefined;
5576
5620
  if (!pubkey || tier === undefined) {
5577
5621
  return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'tier' (0=blocked, 1=unknown, 2=known, 3=whitelisted, 4=vip)." };
@@ -5597,6 +5641,9 @@ export async function startDaemon(config) {
5597
5641
  // on the outbound path at send time (SI), never here.
5598
5642
  handlers.set("cello_contact_set_away", async (params, connectionId) => {
5599
5643
  const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5644
+ const badPubkey = invalidPubkey(pubkey);
5645
+ if (badPubkey)
5646
+ return badPubkey;
5600
5647
  if (!pubkey || !params || !("message" in params)) {
5601
5648
  return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'message' — a string away text, or null to clear it." };
5602
5649
  }
@@ -5620,6 +5667,11 @@ export async function startDaemon(config) {
5620
5667
  return { ok: true, agent: resolved.agent, pubkey };
5621
5668
  });
5622
5669
  handlers.set("cello_contact_remove", async (params, connectionId) => {
5670
+ // Deliberately NOT shape-gated. `remove` is the ESCAPE HATCH, and gating it would trap the very
5671
+ // rows the gate exists to prevent: a junk contact written by the older, unvalidated code (pubkey
5672
+ // = the literal text "tier") could never be deleted, because deleting it requires naming it.
5673
+ // Validation belongs on the paths that CREATE or MUTATE a row, never on the one that removes it.
5674
+ // Removing a key that was never stored is a harmless not_found.
5623
5675
  const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5624
5676
  if (!pubkey) {
5625
5677
  return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to remove." };
@@ -5714,7 +5766,7 @@ export async function startDaemon(config) {
5714
5766
  }
5715
5767
  const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
5716
5768
  if (!store.setMoniker(resolved.agent, moniker)) {
5717
- return { ok: false, reason: "agent_not_found", guidance: `No active agent named '${resolved.agent}'. See cello_list_agents.` };
5769
+ return { ok: false, reason: "agent_not_found", guidance: `No active agent named '${resolved.agent}'. See cello_agents.` };
5718
5770
  }
5719
5771
  logger.info("agent.moniker.set", { agentName: resolved.agent, cleared: moniker === null });
5720
5772
  return { ok: true, agent: resolved.agent, moniker };
@@ -5744,8 +5796,28 @@ export async function startDaemon(config) {
5744
5796
  }
5745
5797
  return { acknowledged: true };
5746
5798
  });
5799
+ // DOD-ONBOARD-HELP-1 §5 — render every response for the surface that asked.
5800
+ //
5801
+ // The daemon is where an operator actually MEETS the tool names: a refused send says "call
5802
+ // cello_receive first". Those strings are written with the canonical MCP names, so an MCP caller
5803
+ // gets them verbatim — but a CLI caller must be told `cello receive`, the thing they can type.
5804
+ // (P2-7 was the one-off report of this; it is a whole CLASS, and this closes the class.)
5805
+ //
5806
+ // Wrapping the handler map is the ONE choke point that has both the response and the connection's
5807
+ // clientType. Doing it per-handler would mean 60+ call sites, and the 61st would forget.
5808
+ const renderedHandlers = new Map();
5809
+ for (const [method, handler] of handlers) {
5810
+ renderedHandlers.set(method, async (params, connectionId) => {
5811
+ const result = await handler(params, connectionId);
5812
+ // Default to "cli": a connection that never sent ipc.connect has no recorded surface, and the
5813
+ // CLI verb is the safe answer — it is at least a real command an operator can run, whereas an
5814
+ // MCP tool name is useless in a terminal.
5815
+ const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
5816
+ return renderForSurface(result, surface);
5817
+ });
5818
+ }
5747
5819
  // Create and start IPC server
5748
- const ipcServer = createIpcServer({ socketPath, maxConnections, logger }, handlers);
5820
+ const ipcServer = createIpcServer({ socketPath, maxConnections, logger }, renderedHandlers);
5749
5821
  try {
5750
5822
  await ipcServer.start();
5751
5823
  }