@cello-protocol/daemon 0.0.14 → 0.0.16

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,EAMrB,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;AAqInF,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,CA2sH7E"}
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;AAM/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;AAqInF,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,CAyzH7E"}
package/dist/daemon.js CHANGED
@@ -32,6 +32,7 @@ import { loadAgents } from "./agent-loader.js";
32
32
  import { acquireLock, removeLock } from "./lock-file.js";
33
33
  import { createIpcServer } from "./ipc-server.js";
34
34
  import { SessionNodeManager } from "./session-node-manager.js";
35
+ import { classifySession } from "./session-category.js";
35
36
  import { RetryQueue } from "./retry-queue.js";
36
37
  import { NonceDedupStore } from "./nonce-dedup.js";
37
38
  import { ContentParkClient } from "./content-park-client.js";
@@ -54,7 +55,7 @@ import { wireSessionCeremonyHandler, wireSessionOfferHandler, wireSealCeremonyHa
54
55
  import { reDeriveFrontiers, findInflatedFrontier } from "./seal-frontier-verify.js";
55
56
  import { LocalAutoNatStub } from "@cello-protocol/transport";
56
57
  import { startHttpManifestPoll } from "./http-manifest-poll.js";
57
- import { resolveDirectoryUrl } from "./directory-bootstrap.js";
58
+ import { resolveDirectoryUrl, manifestNodesToEndpoints } from "./directory-bootstrap.js";
58
59
  /**
59
60
  * M7-SESSION-001 (H-1): canonical byte encoding of a SEAL-INTERRUPTED leaf for
60
61
  * Ed25519 signing/verification. Field order is fixed and deterministic. Both the
@@ -225,6 +226,10 @@ export async function startDaemon(config) {
225
226
  // M7 Keystone: the version of the verified manifest, surfaced in ConnectResult.
226
227
  // Stays 0 when no manifestProvider is configured (the M6 backward-compat path).
227
228
  let verifiedManifestVersion = 0;
229
+ // DOD-MANIFEST-1: the consortium node set resolved to live directory endpoints from
230
+ // the VERIFIED manifest — the N-node roster a T-of-N ceremony (DOD-DKG-1) fans out to.
231
+ // Empty in the M6 backward-compat path (no manifest) or if no node resolves.
232
+ let consortiumEndpoints = [];
228
233
  if (manifestProvider && manifestRootKeys && manifestThreshold !== undefined) {
229
234
  try {
230
235
  const manifest = await manifestProvider.loadAndVerify(manifestRootKeys, manifestThreshold);
@@ -261,6 +266,38 @@ export async function startDaemon(config) {
261
266
  manifestVersion: manifest.version,
262
267
  signerCount: manifest.signatures.length,
263
268
  });
269
+ // DOD-MANIFEST-1: resolve the FULL verified node set to live directory
270
+ // endpoints (replaces the implicit single-endpoint assumption). This is the
271
+ // roster T-of-N ceremonies fan out to. Availability-aware — a node that is
272
+ // down is skipped, the rest still resolve (redundancy invariant: a ceremony
273
+ // needs only T of N). The resolved count is logged so an operator sees the
274
+ // real reachable consortium at startup; the ceremony layer (DOD-DKG-1)
275
+ // re-checks the threshold against this roster and never silently falls back
276
+ // to the single hardcoded endpoint for a missing/forged node.
277
+ consortiumEndpoints = await manifestNodesToEndpoints(manifest.nodes, { logger });
278
+ const declaredNodes = manifest.nodes.length;
279
+ const resolvedNodes = consortiumEndpoints.length;
280
+ // Carry the nodeId↔peerId pairing (not just peerIds) so a consumer/test can verify
281
+ // each manifest identity bound to the right live directory, not merely the set.
282
+ const consortiumLog = {
283
+ manifestVersion: manifest.version,
284
+ declaredNodes,
285
+ resolvedNodes,
286
+ nodes: consortiumEndpoints.map((e) => ({ nodeId: e.nodeId, peerId: e.peerId })),
287
+ };
288
+ // Degraded ≠ healthy: a verified manifest whose nodes don't all resolve must NOT
289
+ // log at the same severity as a full roster. Threshold-REFUSAL (refusing to run a
290
+ // ceremony below T) is DOD-DKG-1's gate; here we make the health signal loud so a
291
+ // shrunken/empty roster is visible and never buried at info.
292
+ if (resolvedNodes === 0) {
293
+ logger.error("directory.consortium.none", consortiumLog);
294
+ }
295
+ else if (resolvedNodes < declaredNodes) {
296
+ logger.warn("directory.consortium.partial", consortiumLog);
297
+ }
298
+ else {
299
+ logger.info("directory.consortium.resolved", consortiumLog);
300
+ }
264
301
  }
265
302
  }
266
303
  }
@@ -285,6 +322,14 @@ export async function startDaemon(config) {
285
322
  throw new Error("Manifest verification failed. The daemon cannot start with an unverified manifest when manifestProvider is configured. " +
286
323
  "Check the logs for the specific failure reason (manifest_signature_invalid, manifest_expired, or manifest_version_rollback).");
287
324
  }
325
+ // DOD-SIGN-1: resolve the consortium roster for a session/seal FROST ceremony (same source as
326
+ // registration's DOD-DKG-1 path — the verified manifest, re-resolved at ceremony time). NULL when
327
+ // no consortium manifest is configured → single-node ceremony (M6/M7 back-compat). Shared by all
328
+ // wireSessionCeremonyHandler / wireSealCeremonyHandler call sites below.
329
+ const resolveConsortiumRoster = async () => {
330
+ const m = manifestProvider?.getCurrentManifest();
331
+ return m ? await manifestNodesToEndpoints(m.nodes, { logger }) : null;
332
+ };
288
333
  // CELLO-M7-CONN-001 (DOD-CONN-3): start the daemon-level HTTP manifest poll. It fetches
289
334
  // ${directoryHttpUrl}/manifest, verifies the threshold signature against the locally-pinned
290
335
  // root keys, applies anti-rollback + expiry, and adopts a newer manifest — independent of any
@@ -439,6 +484,7 @@ export async function startDaemon(config) {
439
484
  agentPubkeyHex,
440
485
  getNode: entry.getNode,
441
486
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
487
+ getConsortiumEndpoints: resolveConsortiumRoster,
442
488
  signaling: mgr,
443
489
  logger,
444
490
  });
@@ -449,6 +495,7 @@ export async function startDaemon(config) {
449
495
  agentPubkeyHex,
450
496
  getNode: entry.getNode,
451
497
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
498
+ getConsortiumEndpoints: resolveConsortiumRoster,
452
499
  signaling: mgr,
453
500
  logger,
454
501
  });
@@ -515,6 +562,7 @@ export async function startDaemon(config) {
515
562
  agentPubkeyHex: agent.pubkey,
516
563
  getNode: getDirectoryNode,
517
564
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
565
+ getConsortiumEndpoints: resolveConsortiumRoster,
518
566
  signaling: mgr,
519
567
  logger,
520
568
  });
@@ -524,6 +572,7 @@ export async function startDaemon(config) {
524
572
  agentPubkeyHex: agent.pubkey,
525
573
  getNode: getDirectoryNode,
526
574
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
575
+ getConsortiumEndpoints: resolveConsortiumRoster,
527
576
  signaling: mgr,
528
577
  logger,
529
578
  });
@@ -911,9 +960,18 @@ export async function startDaemon(config) {
911
960
  // array from SQLite. Shared by both getStatus() (daemon-wide) and the
912
961
  // cello_status MCP handler (per-connection) so live MCP clients see the same
913
962
  // interrupted sessions a CLI `cello status` would.
963
+ // `cello status` is a health snapshot, not a session archive. It surfaces ONLY genuinely
964
+ // RESUMABLE sessions (interrupted with messages exchanged) — never failed inits (interrupted,
965
+ // 0 messages — a dead handshake), which classify as "failed" and would otherwise accumulate
966
+ // unbounded and confuse. The list is also capped; the full, queryable history is `cello sessions`
967
+ // / cello_list_sessions (with filter + limit flags).
968
+ const STATUS_RESUMABLE_CAP = 10;
914
969
  function buildInterruptedSessions() {
915
- const interruptedRows = sessionNodeManager.getSessionsByStatus("interrupted");
916
- return interruptedRows.map((row) => ({
970
+ return sessionNodeManager
971
+ .getSessionsByStatus("interrupted")
972
+ .filter((row) => (row.message_count ?? 0) > 0) // resumable only — drop failed 0-message inits
973
+ .slice(0, STATUS_RESUMABLE_CAP)
974
+ .map((row) => ({
917
975
  sessionId: row.session_id,
918
976
  agentName: row.agent_name,
919
977
  counterpartyPubkey: row.counterparty_pubkey,
@@ -1363,10 +1421,21 @@ export async function startDaemon(config) {
1363
1421
  };
1364
1422
  }
1365
1423
  const persistence = getPersistence(name);
1424
+ // DOD-DKG-1: resolve the full consortium roster from the VERIFIED manifest so the DKG fans
1425
+ // across all N directory nodes. Re-resolved here (ceremony time) for fresh failover
1426
+ // coordinates. NULL when NO manifest is configured (→ single-node DKG, M6/M7 back-compat);
1427
+ // a (possibly EMPTY) array when a manifest IS configured. The null-vs-empty distinction is
1428
+ // load-bearing: an empty roster (consortium configured but unreachable) must REFUSE in
1429
+ // registration-manager, NOT downgrade to single-node (code-reviewer B1 / fallback-finder).
1430
+ const currentManifest = manifestProvider?.getCurrentManifest();
1431
+ const consortiumRoster = currentManifest
1432
+ ? await manifestNodesToEndpoints(currentManifest.nodes, { logger })
1433
+ : null;
1366
1434
  const ctx = new DaemonRegistrationContext({
1367
1435
  signaling: agentSignaling,
1368
1436
  getDirectoryNode: agentGetNode,
1369
1437
  getDirectoryEndpoint: () => directoryEndpoint,
1438
+ getConsortiumEndpoints: () => consortiumRoster,
1370
1439
  keyProvider,
1371
1440
  persistence,
1372
1441
  logger,
@@ -2123,27 +2192,56 @@ export async function startDaemon(config) {
2123
2192
  // and the guidance strings that point here ("See cello_list_sessions") dead-end.
2124
2193
  // Read from the persisted SQLite store, so it works after a daemon restart and
2125
2194
  // from a fresh MCP connection (no in-memory session-node required).
2126
- handlers.set("cello_list_sessions", async (_params, connectionId) => {
2127
- const connState = perConnectionState.get(connectionId);
2128
- if (!connState || !connState.currentAgent) {
2129
- return NO_CURRENT_AGENT_RESPONSE;
2130
- }
2131
- const agentName = connState.currentAgent;
2132
- const sessions = sessionNodeManager
2133
- .getSessionsForAgent(agentName)
2134
- .map((row) => ({
2195
+ // Classify → filter → cap a set of session rows into a SessionListResponse. Shared by the
2196
+ // per-agent MCP surface (cello_list_sessions) and the daemon-wide CLI surface (list_sessions),
2197
+ // so the operator-facing categories (open/closed/failed) and the default cap never drift.
2198
+ // Default filter = "open" (live/resumable only — the common case); default limit bounds the
2199
+ // response so a long-lived agent's history can't balloon it. `all` includes failed/closed.
2200
+ const DEFAULT_LIST_LIMIT = 50;
2201
+ const MAX_LIST_LIMIT = 500;
2202
+ function selectSessions(rows, params) {
2203
+ const rawFilter = typeof params?.filter === "string" ? params.filter : "open";
2204
+ const filter = rawFilter === "closed" || rawFilter === "failed" || rawFilter === "all" ? rawFilter : "open";
2205
+ let limit = Number(params?.limit);
2206
+ if (!Number.isFinite(limit) || limit < 1)
2207
+ limit = DEFAULT_LIST_LIMIT;
2208
+ limit = Math.min(Math.floor(limit), MAX_LIST_LIMIT);
2209
+ const classified = rows.map((row) => {
2210
+ const messageCount = row.message_count ?? 0;
2211
+ const category = classifySession(row.status, messageCount);
2212
+ return { row, messageCount, category };
2213
+ });
2214
+ const matched = filter === "all" ? classified : classified.filter((c) => c.category === filter);
2215
+ const sessions = matched.slice(0, limit).map(({ row, messageCount, category }) => ({
2135
2216
  sessionId: row.session_id,
2136
2217
  agentName: row.agent_name,
2137
2218
  counterpartyPubkey: row.counterparty_pubkey,
2138
2219
  status: row.status,
2139
- messageCount: row.message_count ?? 0,
2220
+ category,
2221
+ messageCount,
2140
2222
  createdAt: new Date(row.created_at).toISOString(),
2141
2223
  updatedAt: new Date(row.updated_at).toISOString(),
2142
2224
  // interrupted_at is the canonical ISO interruption stamp; null for any
2143
2225
  // session that was never interrupted (active/sealed).
2144
2226
  interruptedAt: row.interrupted_at ?? null,
2145
2227
  }));
2146
- return { ok: true, sessions };
2228
+ return { ok: true, filter, limit, totalMatched: matched.length, sessions };
2229
+ }
2230
+ // cello_list_sessions (MCP, per current agent): the discovery surface for the by-id reads
2231
+ // (cello_get_transcript / cello_get_sealed_receipt). Accepts { filter?: open|closed|failed|all,
2232
+ // limit?: number } — defaults to open + DEFAULT_LIST_LIMIT so failed/dead handshakes don't drown
2233
+ // the live ones. Read from persisted SQLite, so it survives a daemon restart / fresh connection.
2234
+ handlers.set("cello_list_sessions", async (params, connectionId) => {
2235
+ const connState = perConnectionState.get(connectionId);
2236
+ if (!connState || !connState.currentAgent) {
2237
+ return NO_CURRENT_AGENT_RESPONSE;
2238
+ }
2239
+ return selectSessions(sessionNodeManager.getSessionsForAgent(connState.currentAgent), params);
2240
+ });
2241
+ // list_sessions (daemon-wide, for the `cello sessions` CLI which has no current agent): same
2242
+ // filter/limit semantics, across ALL agents.
2243
+ handlers.set("list_sessions", async (params) => {
2244
+ return selectSessions(sessionNodeManager.getAllSessions(), params);
2147
2245
  });
2148
2246
  // DAEMON-003 IPC handlers: queue_failed_send and check_nonce (AC-010)
2149
2247
  handlers.set("queue_failed_send", async (params, _connectionId) => {