@cello-protocol/daemon 0.0.33 → 0.0.35
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.
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +150 -40
- package/dist/daemon.js.map +1 -1
- package/dist/session-category.d.ts.map +1 -1
- package/dist/session-category.js +5 -0
- package/dist/session-category.js.map +1 -1
- package/dist/session-node-manager.d.ts +28 -1
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +53 -2
- package/dist/session-node-manager.js.map +1 -1
- package/dist/types.d.ts +1 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
package/dist/daemon.d.ts.map
CHANGED
|
@@ -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,
|
|
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,CA+7K7E"}
|
package/dist/daemon.js
CHANGED
|
@@ -463,8 +463,6 @@ export async function startDaemon(config) {
|
|
|
463
463
|
assignment: carry,
|
|
464
464
|
};
|
|
465
465
|
};
|
|
466
|
-
// Stub: all connections marked as 'unverified' until connection validation is wired
|
|
467
|
-
const connections = [];
|
|
468
466
|
// M7-SIGNAL-001: Instantiate SignalingManager — owns directory signaling stream lifecycle.
|
|
469
467
|
const defaultConnect = async () => {
|
|
470
468
|
throw new Error("directory_signaling_not_configured");
|
|
@@ -818,10 +816,12 @@ export async function startDaemon(config) {
|
|
|
818
816
|
const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
|
|
819
817
|
if (!record || record.status !== "active")
|
|
820
818
|
return;
|
|
821
|
-
// M8C-CONTACT-1:
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
819
|
+
// M8C-CONTACT-1 / CC-1: select the ack wording by whether the sender is a known contact —
|
|
820
|
+
// STRANGER_TEXT ("Dispatched.") for unknown, AWAY_TEXT[kind] for known. Post-CC-1 the inbound
|
|
821
|
+
// accept path no longer auto-adds the sender, so an unattended stranger STAYS unknown across
|
|
822
|
+
// every inbound interaction (promotion now requires operator engagement — an outbound initiate,
|
|
823
|
+
// a cello_send reply, or an explicit contact add). No subsequent add is coupled to this line's
|
|
824
|
+
// ordering anymore; it is a plain read of current contact state.
|
|
825
825
|
const isKnown = sessionNodeManager.isContact(agentName, record.counterparty_pubkey);
|
|
826
826
|
awayAckSent.add(dedupKey); // guard BEFORE the async send — concurrent arrivals must not double-ack
|
|
827
827
|
try {
|
|
@@ -1391,8 +1391,13 @@ export async function startDaemon(config) {
|
|
|
1391
1391
|
sessionNodeManager.setContentParkHook(async ({ sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor }) => {
|
|
1392
1392
|
const node = sessionNodeManager.getStandingReceiverNode();
|
|
1393
1393
|
if (!node) {
|
|
1394
|
-
|
|
1395
|
-
|
|
1394
|
+
const reason = "standing_receiver_unavailable";
|
|
1395
|
+
logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason });
|
|
1396
|
+
// DOD-LEAVEMSG-1 (reviewer HIGH fix): return the typed failure, never resolve as if this
|
|
1397
|
+
// were a success — #parkContent's caller (sendContent) shapes a live "dispatched to relay"
|
|
1398
|
+
// response from this, and a silently-resolved void here would report a message as safely
|
|
1399
|
+
// parked when nothing was ever deposited.
|
|
1400
|
+
return { ok: false, reason };
|
|
1396
1401
|
}
|
|
1397
1402
|
const recipientPubkey = Buffer.from(recipientPubkeyHex, "hex");
|
|
1398
1403
|
// DOD-MSG-4 (2b): seal the ORDERING ENVELOPE (content + the relay's signed Structure2), not bare
|
|
@@ -1407,10 +1412,10 @@ export async function startDaemon(config) {
|
|
|
1407
1412
|
});
|
|
1408
1413
|
if (res.ok) {
|
|
1409
1414
|
logger.info("content.park.deposited", { sessionId, contentHash: contentHashHex, recipientPubkey: recipientPubkeyHex.slice(0, 16) });
|
|
1415
|
+
return { ok: true };
|
|
1410
1416
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
}
|
|
1417
|
+
logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason: res.reason });
|
|
1418
|
+
return { ok: false, reason: res.reason ?? "relay_deposit_failed" };
|
|
1414
1419
|
});
|
|
1415
1420
|
// CELLO-M7-MSG-001 (AC-004/AC-005, D-d): startup flush of locally-persisted un-acked
|
|
1416
1421
|
// content (the crash backstop). Runs HERE — before the IPC socket opens, consistent
|
|
@@ -1550,11 +1555,39 @@ export async function startDaemon(config) {
|
|
|
1550
1555
|
interruptedAt: row.interrupted_at ?? new Date(row.updated_at).toISOString(),
|
|
1551
1556
|
}));
|
|
1552
1557
|
}
|
|
1558
|
+
// CC-5/F21: reap dead half-open sessions on READ (compute-on-read, like reapExpiredInboundSessions —
|
|
1559
|
+
// no background timer). A session the standing receiver opened from an inbound offer the initiator
|
|
1560
|
+
// ABANDONED stays "active" forever (the counterparty never joins), clutters the open/active lists, and
|
|
1561
|
+
// its normal close fires an unsealable bilateral seal. Mark such a session terminal ("abandoned") once
|
|
1562
|
+
// it is PROVABLY dead: status active + counterparty never established (liveness != "alive" AND 0
|
|
1563
|
+
// RECEIVED messages — message_count alone counts our own auto-"Dispatched." ack, so it is NOT the
|
|
1564
|
+
// signal) + age past the grace TTL (a genuinely fresh session still setting up must survive).
|
|
1565
|
+
const HALF_OPEN_TTL_MS = Number(process.env["CELLO_HALF_OPEN_TTL_MS"]) || 5 * 60 * 1000;
|
|
1566
|
+
function reapDeadHalfOpenSessions(agentName) {
|
|
1567
|
+
const now = Date.now();
|
|
1568
|
+
for (const row of sessionNodeManager.getSessionsByStatus("active")) {
|
|
1569
|
+
if (agentName !== undefined && row.agent_name !== agentName)
|
|
1570
|
+
continue;
|
|
1571
|
+
if (now - row.created_at <= HALF_OPEN_TTL_MS)
|
|
1572
|
+
continue; // too young — may just be setting up
|
|
1573
|
+
if (sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id) === "alive")
|
|
1574
|
+
continue; // live
|
|
1575
|
+
if (sessionNodeManager.countReceivedMessages(row.agent_name, row.session_id) > 0)
|
|
1576
|
+
continue; // counterparty spoke
|
|
1577
|
+
// Non-awaited: abandonSession flips the DB status synchronously (before its first await), so THIS
|
|
1578
|
+
// read reflects it; the async node teardown finishes in the background.
|
|
1579
|
+
void sessionNodeManager.abandonSession(row.agent_name, row.session_id).catch((err) => {
|
|
1580
|
+
logger.warn("session.half_open.reap.failed", { agentName: row.agent_name, sessionId: row.session_id, reason: err instanceof Error ? err.message : String(err) });
|
|
1581
|
+
});
|
|
1582
|
+
logger.info("session.half_open.reaped", { agentName: row.agent_name, sessionId: row.session_id, ageMs: now - row.created_at });
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1553
1585
|
// M8B F16: per-session liveness for ACTIVE sessions, shared by both status surfaces
|
|
1554
1586
|
// ("status" for the CLI, "cello_status" for MCP). The signal (session.liveness.changed,
|
|
1555
1587
|
// tracked in the node manager) existed but nothing consumed it — a dead counterparty
|
|
1556
1588
|
// was invisible to the operator.
|
|
1557
1589
|
function buildActiveSessions() {
|
|
1590
|
+
reapDeadHalfOpenSessions(); // CC-5/F21: drop provably-dead half-open sessions before surfacing active ones
|
|
1558
1591
|
return sessionNodeManager.getSessionsByStatus("active").map((row) => ({
|
|
1559
1592
|
sessionId: row.session_id,
|
|
1560
1593
|
agentName: row.agent_name,
|
|
@@ -1572,11 +1605,22 @@ export async function startDaemon(config) {
|
|
|
1572
1605
|
// M8B F14 (fix 5): per-agent standing-receiver readiness, so a deaf agent (online but
|
|
1573
1606
|
// no armed receiver) is visible in cello_status instead of hiding behind the ANY-agent
|
|
1574
1607
|
// aggregate below (kept for backward compatibility).
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1608
|
+
// CC-8 (F5 parity): the CLI `cello status` surface must show online vs registered like the MCP
|
|
1609
|
+
// cello_status does. The stored `a.state` is stale — it stays "registered" even when the agent is
|
|
1610
|
+
// online, because startAgentInternal only adds to onlineAgents and never mutates the record — so
|
|
1611
|
+
// derive readiness from onlineAgents here, exactly as getAgentsForConnection (F5) already does for
|
|
1612
|
+
// the MCP surface. A load_failed agent keeps its state so a broken agent stays visible as broken.
|
|
1613
|
+
// (No `selected` here: this is the daemon-wide surface; selection is a per-connection concept and
|
|
1614
|
+
// the CLI opens an ephemeral connection that never runs cello_use_agent — see M8C-DECISIONS D24.)
|
|
1615
|
+
agents: agents.map((a) => {
|
|
1616
|
+
const online = onlineAgents.has(a.name);
|
|
1617
|
+
const state = a.state === "load_failed" ? "load_failed" : online ? "online" : "registered";
|
|
1618
|
+
return {
|
|
1619
|
+
...a,
|
|
1620
|
+
state,
|
|
1621
|
+
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
|
|
1622
|
+
};
|
|
1623
|
+
}),
|
|
1580
1624
|
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
|
|
1581
1625
|
retryQueueDepth: retryQueue.getTotalDepth(),
|
|
1582
1626
|
interrupted_sessions,
|
|
@@ -2122,6 +2166,23 @@ export async function startDaemon(config) {
|
|
|
2122
2166
|
// committed at this point (RegistrationManager awaits the persist before returning success).
|
|
2123
2167
|
// SI-001: never log a secret — only the agent name + PUBLIC key.
|
|
2124
2168
|
logger.info("persist.identity.persisted", { agentName: name, agentPubkey: agentPubkeyHex });
|
|
2169
|
+
// CC-2 (2026-07-07): registration succeeded — arm this agent's standing receiver NOW so a
|
|
2170
|
+
// brand-new agent can receive inbound immediately. Without this the agent reports
|
|
2171
|
+
// standing_receiver_ready:false and cannot receive until the operator restarts (logout/login),
|
|
2172
|
+
// so a fresh registration looks broken. Uses the SAME idempotent path login and cello_use_agent
|
|
2173
|
+
// arm through (startAgentInternal → onlineAgents + directory signaling + ensureStandingReceiver +
|
|
2174
|
+
// agent_state_changed). A start failure must NOT fail the (already durably persisted)
|
|
2175
|
+
// registration — surface it as a warning and let the operator recover via login.
|
|
2176
|
+
const armResult = startAgentInternal(name);
|
|
2177
|
+
if (!armResult.ok) {
|
|
2178
|
+
logger.warn("registration.standing_receiver.arm_failed", { agentName: name, reason: armResult.reason });
|
|
2179
|
+
}
|
|
2180
|
+
else {
|
|
2181
|
+
// arm_INITIATED, not armed: startAgentInternal returns ok once the agent is online + signaling
|
|
2182
|
+
// is up, but ensureStandingReceiverForAgent runs fire-and-forget (its own failure emits
|
|
2183
|
+
// session.standing_receiver.ensure.failed) — so this event marks the start, not readiness.
|
|
2184
|
+
logger.info("registration.standing_receiver.arm_initiated", { agentName: name });
|
|
2185
|
+
}
|
|
2125
2186
|
// Capture-now-or-lose-it: persist the agent→user link (using it is future
|
|
2126
2187
|
// trust-layer work). L1: the agent is already registered at this point —
|
|
2127
2188
|
// a link-write failure must NOT be reported as a registration failure.
|
|
@@ -2685,7 +2746,6 @@ export async function startDaemon(config) {
|
|
|
2685
2746
|
daemon: "running",
|
|
2686
2747
|
directory_signaling: directorySignalingStatus(),
|
|
2687
2748
|
agents: getAgentsForConnection(connectionId),
|
|
2688
|
-
connections,
|
|
2689
2749
|
// M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
|
|
2690
2750
|
// the daemon-wide getStatus() surfaces them.
|
|
2691
2751
|
interrupted_sessions: buildInterruptedSessions(),
|
|
@@ -2734,10 +2794,13 @@ export async function startDaemon(config) {
|
|
|
2734
2794
|
// to the MCP response.
|
|
2735
2795
|
handlers.set("cello_initiate_session", async (params, connectionId) => {
|
|
2736
2796
|
const connState = perConnectionState.get(connectionId);
|
|
2737
|
-
|
|
2797
|
+
// CC-3 / M8C-AUTOSTART-1 F18: resolve the target agent — explicit { name } wins, else this
|
|
2798
|
+
// connection's current agent, else the sole online agent (removes the no_current_agent papercut
|
|
2799
|
+
// after a /mcp reconnect when exactly one agent is online). 2+ online with none selected → null.
|
|
2800
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
2801
|
+
if (!agentName) {
|
|
2738
2802
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
2739
2803
|
}
|
|
2740
|
-
const agentName = connState.currentAgent;
|
|
2741
2804
|
const correlationId = randomUUID();
|
|
2742
2805
|
// AC-004/AC-019: the advertised address is chosen from the standing receiver's
|
|
2743
2806
|
// current dialability. Not dialable (or AutoNAT unavailable) → relay circuit.
|
|
@@ -2856,7 +2919,9 @@ export async function startDaemon(config) {
|
|
|
2856
2919
|
// SI-001: no auto-seal on session_interrupted receipt; operator must call explicitly
|
|
2857
2920
|
handlers.set("cello_close_session", async (params, connectionId) => {
|
|
2858
2921
|
const connState = perConnectionState.get(connectionId);
|
|
2859
|
-
|
|
2922
|
+
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
|
|
2923
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
2924
|
+
if (!agentName) {
|
|
2860
2925
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
2861
2926
|
}
|
|
2862
2927
|
// round-2 BLOCKING: the public IPC contract field is snake_case `session_id`
|
|
@@ -2876,7 +2941,7 @@ export async function startDaemon(config) {
|
|
|
2876
2941
|
// the ownership scope. A session_id owned only by a DIFFERENT agent does not exist in this
|
|
2877
2942
|
// agent's namespace (returns null → session_not_found), which is correct for loopback (two
|
|
2878
2943
|
// agents can hold the same session_id on one daemon).
|
|
2879
|
-
const record = sessionNodeManager.getSessionRecord(
|
|
2944
|
+
const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
|
|
2880
2945
|
if (!record) {
|
|
2881
2946
|
return {
|
|
2882
2947
|
ok: false,
|
|
@@ -2886,7 +2951,7 @@ export async function startDaemon(config) {
|
|
|
2886
2951
|
}
|
|
2887
2952
|
// Ownership: redundant now that the lookup is agent-scoped (record.agent_name === currentAgent),
|
|
2888
2953
|
// kept as a defensive invariant.
|
|
2889
|
-
if (record.agent_name !==
|
|
2954
|
+
if (record.agent_name !== agentName) {
|
|
2890
2955
|
return {
|
|
2891
2956
|
ok: false,
|
|
2892
2957
|
reason: "session_not_owned",
|
|
@@ -2901,6 +2966,26 @@ export async function startDaemon(config) {
|
|
|
2901
2966
|
guidance: "This session is already sealed. No further action is needed — check cello_list_sessions to view its sealed record and the FROST notarization.",
|
|
2902
2967
|
};
|
|
2903
2968
|
}
|
|
2969
|
+
// CC-5/F21 terminal-escape: force-abandon a session that can never be bilaterally sealed — a
|
|
2970
|
+
// half-open handshake the counterparty never joined, whose normal close fires a seal the absent/
|
|
2971
|
+
// rejecting counterparty can't complete (seal_interrupted_rejected_by_counterparty / timeout). Marks
|
|
2972
|
+
// it locally-terminal ("abandoned") with NO seal, so it leaves the open list. Additive: placed BEFORE
|
|
2973
|
+
// the seal branches, it never touches the seal flow. Owner-scoped (the lookup above is agent-scoped)
|
|
2974
|
+
// and idempotent. NOT for a healthy session — a normal close (no force) still seals so both parties
|
|
2975
|
+
// get a notarized receipt; force is the escape hatch for a provably unsealable ghost.
|
|
2976
|
+
if (params?.force === true) {
|
|
2977
|
+
if (record.status === "abandoned") {
|
|
2978
|
+
return { ok: true, status: "abandoned", reason: "already_abandoned", guidance: "This session was already force-abandoned." };
|
|
2979
|
+
}
|
|
2980
|
+
await sessionNodeManager.abandonSession(record.agent_name, sessionId);
|
|
2981
|
+
logger.info("session.force_abandoned", { agentName: record.agent_name, sessionId, priorStatus: record.status });
|
|
2982
|
+
return {
|
|
2983
|
+
ok: true,
|
|
2984
|
+
status: "abandoned",
|
|
2985
|
+
reason: "force_abandoned",
|
|
2986
|
+
guidance: `Session ${sessionId} was force-abandoned — marked terminal locally with no bilateral seal. Use force only for a half-open session that cannot be sealed; a normal close (no force) still attempts the seal so both parties get a notarized receipt.`,
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2904
2989
|
// AC-011: seal-interrupted already in progress
|
|
2905
2990
|
if (sealInterruptedInProgress.has(sealKey(record.agent_name, sessionId))) {
|
|
2906
2991
|
return {
|
|
@@ -3169,10 +3254,13 @@ export async function startDaemon(config) {
|
|
|
3169
3254
|
}
|
|
3170
3255
|
// DOD-LOOP-1: the certificate is keyed by (agent, session_id) — read the current agent's.
|
|
3171
3256
|
const connState = perConnectionState.get(connectionId);
|
|
3172
|
-
|
|
3257
|
+
// CC-3 / M8C-AUTOSTART-1 F18: resolve the target agent — explicit { name } wins, else this
|
|
3258
|
+
// connection's current agent, else the sole online agent (removes the no_current_agent papercut
|
|
3259
|
+
// after a /mcp reconnect when exactly one agent is online). 2+ online with none selected → null.
|
|
3260
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
3261
|
+
if (!agentName) {
|
|
3173
3262
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
3174
3263
|
}
|
|
3175
|
-
const agentName = connState.currentAgent;
|
|
3176
3264
|
const cert = sessionNodeManager.getSealCertificate(agentName, sessionId);
|
|
3177
3265
|
if (cert) {
|
|
3178
3266
|
return { ok: true, session_id: sessionId, sealed_root: cert.sealed_root, legibility: cert.legibility };
|
|
@@ -3224,10 +3312,12 @@ export async function startDaemon(config) {
|
|
|
3224
3312
|
return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) whose transcript to read. See cello_list_sessions." };
|
|
3225
3313
|
}
|
|
3226
3314
|
const connState = perConnectionState.get(connectionId);
|
|
3227
|
-
|
|
3315
|
+
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
|
|
3316
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
3317
|
+
if (!agentName) {
|
|
3228
3318
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
3229
3319
|
}
|
|
3230
|
-
const { messages, undecryptable } = sessionNodeManager.readTranscript(
|
|
3320
|
+
const { messages, undecryptable } = sessionNodeManager.readTranscript(agentName, sessionId);
|
|
3231
3321
|
// undecryptable > 0 means some rows failed GCM auth (tamper / wrong key) — surfaced, not hidden,
|
|
3232
3322
|
// so the reader can tell a real gap from an empty transcript.
|
|
3233
3323
|
// M8C-CURSOR-1: this is the ONLY reader that covers BOTH directions (sent + received), so it's
|
|
@@ -3288,10 +3378,13 @@ export async function startDaemon(config) {
|
|
|
3288
3378
|
// the live ones. Read from persisted SQLite, so it survives a daemon restart / fresh connection.
|
|
3289
3379
|
handlers.set("cello_list_sessions", async (params, connectionId) => {
|
|
3290
3380
|
const connState = perConnectionState.get(connectionId);
|
|
3291
|
-
|
|
3381
|
+
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
|
|
3382
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
3383
|
+
if (!agentName) {
|
|
3292
3384
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
3293
3385
|
}
|
|
3294
|
-
|
|
3386
|
+
reapDeadHalfOpenSessions(agentName); // CC-5/F21: drop provably-dead half-open sessions before listing
|
|
3387
|
+
return selectSessions(sessionNodeManager.getSessionsForAgent(agentName), params);
|
|
3295
3388
|
});
|
|
3296
3389
|
// list_sessions (daemon-wide, for the `cello sessions` CLI which has no current agent): same
|
|
3297
3390
|
// filter/limit semantics, across ALL agents.
|
|
@@ -4034,12 +4127,15 @@ export async function startDaemon(config) {
|
|
|
4034
4127
|
void sendTelegramDoorbell(agentName, parsed.sessionIdHex, "session_request", "New session request");
|
|
4035
4128
|
// M8C-AWAY-1: an unattended agent auto-acks a fresh inbound session request. Fire-and-forget
|
|
4036
4129
|
// (sendAwayResponse never throws) — best-effort, must not delay/block acceptance completion.
|
|
4037
|
-
// ORDER MATTERS: sendAwayResponse's isContact check runs synchronously (before any await) as
|
|
4038
|
-
// soon as it's invoked, so calling addContact on the NEXT line still lets a stranger's very
|
|
4039
|
-
// first contact be judged correctly unknown (M8C-CONTACT-1 D6: "accepting X's request adds X"
|
|
4040
|
-
// — they become known immediately after, for every subsequent interaction).
|
|
4041
4130
|
void sendAwayResponse(agentName, parsed.sessionIdHex, "request");
|
|
4042
|
-
|
|
4131
|
+
// CC-1 (2026-07-07): do NOT auto-add the requester here. Accepting the *connection* must not
|
|
4132
|
+
// grant *trust*. The old auto-add promoted any stranger who knocked once to "known" (Level-4
|
|
4133
|
+
// fast-track), which defeated BOTH the screening layer AND the ABUSE-1 acceptance caps
|
|
4134
|
+
// (checkUnknownSenderAcceptanceBound exempts contacts, so sessions 2+ bypassed the per-sender
|
|
4135
|
+
// cap — confirmed live 2026-07-07). Promotion to "known" now requires operator engagement only:
|
|
4136
|
+
// an outbound cello_initiate_session (below, ~3137), the operator replying INTO the session via
|
|
4137
|
+
// cello_send, or an explicit cello_contact_add. An unattended stranger is never auto-whitelisted.
|
|
4138
|
+
// See docs/planning/.../2026-07-07_1700_four-level-screening-policy.md (D21).
|
|
4043
4139
|
}
|
|
4044
4140
|
finally {
|
|
4045
4141
|
inboundInFlight.delete(parsed.sessionIdHex);
|
|
@@ -4228,10 +4324,13 @@ export async function startDaemon(config) {
|
|
|
4228
4324
|
// contract (core/adapter-claude-code/src/server.ts) so the E2E fixture migration is drop-in.
|
|
4229
4325
|
handlers.set("cello_await_session", async (params, connectionId) => {
|
|
4230
4326
|
const connState = perConnectionState.get(connectionId);
|
|
4231
|
-
|
|
4327
|
+
// CC-3 / M8C-AUTOSTART-1 F18: resolve the target agent — explicit { name } wins, else this
|
|
4328
|
+
// connection's current agent, else the sole online agent (removes the no_current_agent papercut
|
|
4329
|
+
// after a /mcp reconnect when exactly one agent is online). 2+ online with none selected → null.
|
|
4330
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
4331
|
+
if (!agentName) {
|
|
4232
4332
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
4233
4333
|
}
|
|
4234
|
-
const agentName = connState.currentAgent;
|
|
4235
4334
|
const timeoutMs = typeof params?.["timeout_ms"] === "number" ? params["timeout_ms"] : 30_000;
|
|
4236
4335
|
const toResponse = (e) => ({
|
|
4237
4336
|
type: "new_session",
|
|
@@ -4665,7 +4764,9 @@ export async function startDaemon(config) {
|
|
|
4665
4764
|
// ─── CELLO-M7-DAEMON-004: cello_send (live send + daemon-owned tree append) ──
|
|
4666
4765
|
handlers.set("cello_send", async (params, connectionId) => {
|
|
4667
4766
|
const connState = perConnectionState.get(connectionId);
|
|
4668
|
-
|
|
4767
|
+
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > current > sole online agent.
|
|
4768
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
4769
|
+
if (!agentName)
|
|
4669
4770
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
4670
4771
|
// round-2 BLOCKING: read the snake_case public field cello-mcp.ts actually sends.
|
|
4671
4772
|
const sessionId = params?.session_id;
|
|
@@ -4674,11 +4775,11 @@ export async function startDaemon(config) {
|
|
|
4674
4775
|
return { ok: false, reason: "missing_params", guidance: "Provide 'session_id' (hex) and 'content' (string) parameters." };
|
|
4675
4776
|
}
|
|
4676
4777
|
// DOD-LOOP-1: the (agent, session_id) lookup is itself the ownership scope.
|
|
4677
|
-
const record = sessionNodeManager.getSessionRecord(
|
|
4778
|
+
const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
|
|
4678
4779
|
if (!record) {
|
|
4679
4780
|
return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check cello_list_sessions for active sessions." };
|
|
4680
4781
|
}
|
|
4681
|
-
if (record.agent_name !==
|
|
4782
|
+
if (record.agent_name !== agentName) {
|
|
4682
4783
|
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." };
|
|
4683
4784
|
}
|
|
4684
4785
|
if (record.status !== "active") {
|
|
@@ -4844,6 +4945,14 @@ export async function startDaemon(config) {
|
|
|
4844
4945
|
// blocked by session_not_current on its own just-sent message.
|
|
4845
4946
|
advanceConnectionCursor(connectionId, sessionId, leafIndex);
|
|
4846
4947
|
void newRootHex;
|
|
4948
|
+
// CC-1 (2026-07-07): operator engagement promotes the counterparty to a known contact. A
|
|
4949
|
+
// committed reply — past the read-before-write gate, content now on the wire AND in the tree —
|
|
4950
|
+
// IS the operator choosing to trust this sender; the inbound-accept path deliberately no longer
|
|
4951
|
+
// auto-adds (that defeated screening + anti-spam). For an OUTBOUND session the counterparty is
|
|
4952
|
+
// already a contact (cello_initiate_session added it), so this is an idempotent no-op there; it
|
|
4953
|
+
// matters for inbound-originated sessions, where the reply is the trust signal. addContact is
|
|
4954
|
+
// INSERT OR IGNORE — it never refreshes added_at.
|
|
4955
|
+
sessionNodeManager.addContact(record.agent_name, recipientPubkey);
|
|
4847
4956
|
if (modified) {
|
|
4848
4957
|
logger.info("security.verdict.returned", { disposition: "redact", sessionId, sequenceNumber: leafIndex, correlationId });
|
|
4849
4958
|
}
|
|
@@ -4895,9 +5004,10 @@ export async function startDaemon(config) {
|
|
|
4895
5004
|
const RECEIVE_DEFAULT_TIMEOUT_MS = 30000; // matches the cello-mcp shim's documented default
|
|
4896
5005
|
const handleReceive = async (params, connectionId) => {
|
|
4897
5006
|
const connState = perConnectionState.get(connectionId);
|
|
4898
|
-
|
|
5007
|
+
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > current > sole online agent.
|
|
5008
|
+
const agentName = resolveCurrentAgent(connState, params?.name);
|
|
5009
|
+
if (!agentName)
|
|
4899
5010
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
4900
|
-
const agentName = connState.currentAgent;
|
|
4901
5011
|
// Read the snake_case public field cello-mcp.ts actually sends.
|
|
4902
5012
|
const sessionId = params?.session_id;
|
|
4903
5013
|
if (!sessionId) {
|