@cello-protocol/daemon 0.0.13 → 0.0.15

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,CAgoH7E"}
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,CA8vH7E"}
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";
@@ -42,7 +43,7 @@ import { RegistrationManager } from "./registration-manager.js";
42
43
  import { DaemonRegistrationContext } from "./registration-context.js";
43
44
  import { DbRegistrationPersistence, DbIdentityStore } from "./db-identity-store.js";
44
45
  import { DbManifestVersionStore } from "./manifest-version-store-db.js";
45
- import { verify as ed25519Verify, sealToRecipient, generateKLocalSeed, InMemoryKeyProvider } from "@cello-protocol/crypto";
46
+ import { verify as ed25519Verify, sealToRecipient, generateKLocalSeed, InMemoryKeyProvider, hash as cryptoHash } from "@cello-protocol/crypto";
46
47
  import { attemptSealUpgrade as attemptSealUpgradeImpl, verifyUpgradeConfirmedCert } from "./seal-upgrade.js";
47
48
  // CELLO-M7-MSG-001 (AC-013/AC-018): the single application content-size cap, enforced
48
49
  // at the send point here (the receive point lives in the transport content decode).
@@ -468,6 +469,15 @@ export async function startDaemon(config) {
468
469
  signaling: mgr,
469
470
  logger,
470
471
  });
472
+ // CELLO-M8-TRUST-001: receive sealed trust signals pushed from the directory pickup queue on
473
+ // THIS agent's stream. Open with k_local, verify the recomputed hash against the directory
474
+ // anchor, store locally, then ACK (so the directory deletes the ciphertext). The daemon is the
475
+ // ONLY party that can open the seal (SI-001); a hash mismatch is rejected without storing/ACKing.
476
+ mgr.registerInboundHandler((frame) => {
477
+ if (frame["type"] !== "trust_signal_pickup")
478
+ return;
479
+ void handleTrustSignalPickup(frame, agentKeyProvider, mgr, agentName);
480
+ });
471
481
  // CELLO-M7-CONN-001 (DOD-CONN-2): inbound session_assignment + seal_interrupted_request
472
482
  // on THIS agent's own stream, so a non-primary agent receives inbound sessions (SPINE-5).
473
483
  wirePerAgentSessionInbound(mgr);
@@ -902,9 +912,18 @@ export async function startDaemon(config) {
902
912
  // array from SQLite. Shared by both getStatus() (daemon-wide) and the
903
913
  // cello_status MCP handler (per-connection) so live MCP clients see the same
904
914
  // interrupted sessions a CLI `cello status` would.
915
+ // `cello status` is a health snapshot, not a session archive. It surfaces ONLY genuinely
916
+ // RESUMABLE sessions (interrupted with messages exchanged) — never failed inits (interrupted,
917
+ // 0 messages — a dead handshake), which classify as "failed" and would otherwise accumulate
918
+ // unbounded and confuse. The list is also capped; the full, queryable history is `cello sessions`
919
+ // / cello_list_sessions (with filter + limit flags).
920
+ const STATUS_RESUMABLE_CAP = 10;
905
921
  function buildInterruptedSessions() {
906
- const interruptedRows = sessionNodeManager.getSessionsByStatus("interrupted");
907
- return interruptedRows.map((row) => ({
922
+ return sessionNodeManager
923
+ .getSessionsByStatus("interrupted")
924
+ .filter((row) => (row.message_count ?? 0) > 0) // resumable only — drop failed 0-message inits
925
+ .slice(0, STATUS_RESUMABLE_CAP)
926
+ .map((row) => ({
908
927
  sessionId: row.session_id,
909
928
  agentName: row.agent_name,
910
929
  counterpartyPubkey: row.counterparty_pubkey,
@@ -2114,27 +2133,56 @@ export async function startDaemon(config) {
2114
2133
  // and the guidance strings that point here ("See cello_list_sessions") dead-end.
2115
2134
  // Read from the persisted SQLite store, so it works after a daemon restart and
2116
2135
  // from a fresh MCP connection (no in-memory session-node required).
2117
- handlers.set("cello_list_sessions", async (_params, connectionId) => {
2118
- const connState = perConnectionState.get(connectionId);
2119
- if (!connState || !connState.currentAgent) {
2120
- return NO_CURRENT_AGENT_RESPONSE;
2121
- }
2122
- const agentName = connState.currentAgent;
2123
- const sessions = sessionNodeManager
2124
- .getSessionsForAgent(agentName)
2125
- .map((row) => ({
2136
+ // Classify → filter → cap a set of session rows into a SessionListResponse. Shared by the
2137
+ // per-agent MCP surface (cello_list_sessions) and the daemon-wide CLI surface (list_sessions),
2138
+ // so the operator-facing categories (open/closed/failed) and the default cap never drift.
2139
+ // Default filter = "open" (live/resumable only — the common case); default limit bounds the
2140
+ // response so a long-lived agent's history can't balloon it. `all` includes failed/closed.
2141
+ const DEFAULT_LIST_LIMIT = 50;
2142
+ const MAX_LIST_LIMIT = 500;
2143
+ function selectSessions(rows, params) {
2144
+ const rawFilter = typeof params?.filter === "string" ? params.filter : "open";
2145
+ const filter = rawFilter === "closed" || rawFilter === "failed" || rawFilter === "all" ? rawFilter : "open";
2146
+ let limit = Number(params?.limit);
2147
+ if (!Number.isFinite(limit) || limit < 1)
2148
+ limit = DEFAULT_LIST_LIMIT;
2149
+ limit = Math.min(Math.floor(limit), MAX_LIST_LIMIT);
2150
+ const classified = rows.map((row) => {
2151
+ const messageCount = row.message_count ?? 0;
2152
+ const category = classifySession(row.status, messageCount);
2153
+ return { row, messageCount, category };
2154
+ });
2155
+ const matched = filter === "all" ? classified : classified.filter((c) => c.category === filter);
2156
+ const sessions = matched.slice(0, limit).map(({ row, messageCount, category }) => ({
2126
2157
  sessionId: row.session_id,
2127
2158
  agentName: row.agent_name,
2128
2159
  counterpartyPubkey: row.counterparty_pubkey,
2129
2160
  status: row.status,
2130
- messageCount: row.message_count ?? 0,
2161
+ category,
2162
+ messageCount,
2131
2163
  createdAt: new Date(row.created_at).toISOString(),
2132
2164
  updatedAt: new Date(row.updated_at).toISOString(),
2133
2165
  // interrupted_at is the canonical ISO interruption stamp; null for any
2134
2166
  // session that was never interrupted (active/sealed).
2135
2167
  interruptedAt: row.interrupted_at ?? null,
2136
2168
  }));
2137
- return { ok: true, sessions };
2169
+ return { ok: true, filter, limit, totalMatched: matched.length, sessions };
2170
+ }
2171
+ // cello_list_sessions (MCP, per current agent): the discovery surface for the by-id reads
2172
+ // (cello_get_transcript / cello_get_sealed_receipt). Accepts { filter?: open|closed|failed|all,
2173
+ // limit?: number } — defaults to open + DEFAULT_LIST_LIMIT so failed/dead handshakes don't drown
2174
+ // the live ones. Read from persisted SQLite, so it survives a daemon restart / fresh connection.
2175
+ handlers.set("cello_list_sessions", async (params, connectionId) => {
2176
+ const connState = perConnectionState.get(connectionId);
2177
+ if (!connState || !connState.currentAgent) {
2178
+ return NO_CURRENT_AGENT_RESPONSE;
2179
+ }
2180
+ return selectSessions(sessionNodeManager.getSessionsForAgent(connState.currentAgent), params);
2181
+ });
2182
+ // list_sessions (daemon-wide, for the `cello sessions` CLI which has no current agent): same
2183
+ // filter/limit semantics, across ALL agents.
2184
+ handlers.set("list_sessions", async (params) => {
2185
+ return selectSessions(sessionNodeManager.getAllSessions(), params);
2138
2186
  });
2139
2187
  // DAEMON-003 IPC handlers: queue_failed_send and check_nonce (AC-010)
2140
2188
  handlers.set("queue_failed_send", async (params, _connectionId) => {
@@ -2852,6 +2900,68 @@ export async function startDaemon(config) {
2852
2900
  // the keystone) received them. A frame arrives on exactly one agent's stream, so there is no
2853
2901
  // double-dispatch; each handler resolves the local agent internally and that resolution
2854
2902
  // matches the stream the directory routed the frame to.
2903
+ // CELLO-M8-TRUST-001: open + verify + store + ACK a sealed trust signal pushed from the directory
2904
+ // pickup queue. The daemon is the ONLY party that can open the seal (k_local — SI-001); it verifies
2905
+ // the recomputed hash against the directory's anchor before storing, and ACKs only on success so the
2906
+ // directory deletes the ciphertext (AC-001/AC-002). open-fail / hash-mismatch / store-fail → NO ACK
2907
+ // (the directory keeps the pickup for a later retry; the signal is re-mintable).
2908
+ async function handleTrustSignalPickup(frame, keyProvider, mgr, agentName) {
2909
+ const id = typeof frame["id"] === "string" ? frame["id"] : null;
2910
+ const signalKind = typeof frame["signal_kind"] === "string" ? frame["signal_kind"] : null;
2911
+ const signalHash = typeof frame["signal_hash"] === "string" ? frame["signal_hash"] : null;
2912
+ const ciphertext = frame["ciphertext"];
2913
+ if (!id || !signalKind || !signalHash || !(ciphertext instanceof Uint8Array)) {
2914
+ // Neither stores nor ACKs → the directory retains the row and re-delivers. Log it: a PERMANENTLY
2915
+ // malformed frame would otherwise be re-delivered forever with zero daemon-side signal (fallback-finder).
2916
+ logger.warn("daemon.trust_signal.malformed", {
2917
+ agentName,
2918
+ hasId: !!id,
2919
+ hasSignalKind: !!signalKind,
2920
+ hasSignalHash: !!signalHash,
2921
+ ciphertextOk: ciphertext instanceof Uint8Array,
2922
+ });
2923
+ return;
2924
+ }
2925
+ if (!keyProvider.openContentSeal) {
2926
+ // A session-node stub key cannot open content seals. No ACK → the directory re-delivers; log so a
2927
+ // pickup persistently routed to a stub-key agent is visible rather than a silent forever-retry.
2928
+ logger.warn("daemon.trust_signal.no_content_key", { agentName, signalKind, correlationId: id });
2929
+ return;
2930
+ }
2931
+ // The pickup id correlates the directory's deliver/ack with the daemon's receive (TRUST-001 obs).
2932
+ const correlationId = id;
2933
+ let recovered;
2934
+ try {
2935
+ recovered = await keyProvider.openContentSeal(ciphertext);
2936
+ }
2937
+ catch {
2938
+ recovered = null;
2939
+ }
2940
+ if (!recovered) {
2941
+ logger.warn("daemon.trust_signal.open_failed", { agentName, signalKind, correlationId });
2942
+ return;
2943
+ }
2944
+ const recomputed = Buffer.from(cryptoHash(recovered)).toString("hex");
2945
+ if (recomputed !== signalHash) {
2946
+ logger.error("daemon.trust_signal.hash_mismatch", { agentName, signalKind, correlationId });
2947
+ return;
2948
+ }
2949
+ try {
2950
+ const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
2951
+ store.storeTrustSignal({ signalHash, agentId: null, signalKind, payload: recovered });
2952
+ }
2953
+ catch (err) {
2954
+ logger.error("daemon.trust_signal.store_failed", {
2955
+ agentName,
2956
+ signalKind,
2957
+ correlationId,
2958
+ error: err instanceof Error ? err.message : String(err),
2959
+ });
2960
+ return;
2961
+ }
2962
+ logger.info("daemon.trust_signal.received", { agentName, signalKind, verified: true, correlationId });
2963
+ await mgr.sendRaw({ type: "trust_signal_ack", id });
2964
+ }
2855
2965
  function wirePerAgentSessionInbound(mgr) {
2856
2966
  mgr.registerInboundHandler((frame) => {
2857
2967
  if (frame["type"] !== "seal_interrupted_request")