@cello-protocol/daemon 0.0.46 → 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.
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +170 -37
- package/dist/daemon.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/session-node-manager.d.ts +13 -0
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +37 -2
- package/dist/session-node-manager.js.map +1 -1
- package/dist/vocabulary.d.ts +122 -0
- package/dist/vocabulary.d.ts.map +1 -0
- package/dist/vocabulary.js +268 -0
- package/dist/vocabulary.js.map +1 -0
- package/package.json +2 -2
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,EAQrB,MAAM,YAAY,CAAC;
|
|
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";
|
|
@@ -786,6 +787,24 @@ export async function startDaemon(config) {
|
|
|
786
787
|
cursor += 1;
|
|
787
788
|
advanceConnectionCursor(connectionId, sessionId, cursor);
|
|
788
789
|
}
|
|
790
|
+
/**
|
|
791
|
+
* DOD-CURSOR-DURABLE-1: the same hole-safe walk, applied to the PERSISTED per-(agent, session)
|
|
792
|
+
* read watermark. Used by cello_get_transcript, whose delivery covers BOTH directions.
|
|
793
|
+
*
|
|
794
|
+
* Walks a CONTIGUOUS run from the agent's current watermark, stopping at the first gap — for the
|
|
795
|
+
* identical reason safeCursorAdvance does: leaf indices are contiguous across both directions, so
|
|
796
|
+
* a gap can hide an unread RECEIVED message (e.g. a row that failed to decrypt is absent from
|
|
797
|
+
* `messages`). Advancing past it would mark unseen counterparty content as read and unblock a
|
|
798
|
+
* send that never saw it — defeating the very guarantee this gate exists to enforce. Monotonic:
|
|
799
|
+
* advanceLastDeliveredSeq takes MAX, so this can never lower a watermark.
|
|
800
|
+
*/
|
|
801
|
+
function safeWatermarkAdvance(agentName, sessionId, deliveredSeqs) {
|
|
802
|
+
let frontier = sessionNodeManager.getLastDeliveredSeq(agentName, sessionId);
|
|
803
|
+
while (deliveredSeqs.has(frontier + 1))
|
|
804
|
+
frontier += 1;
|
|
805
|
+
if (frontier >= 0)
|
|
806
|
+
sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, frontier);
|
|
807
|
+
}
|
|
789
808
|
// M8C-AWAY-1: away response — an unattended Primary auto-answers session requests + messages
|
|
790
809
|
// with the default transparent away text and queues them (the DoD's own mandated default).
|
|
791
810
|
// CORE ships now; the operator-configurable custom-text / opaque-privacy-mode SWITCH is PARKED
|
|
@@ -1779,7 +1798,7 @@ export async function startDaemon(config) {
|
|
|
1779
1798
|
function startAgentInternal(name) {
|
|
1780
1799
|
const agent = agents.find((a) => a.name === name);
|
|
1781
1800
|
if (!agent || agent.state === "load_failed") {
|
|
1782
|
-
return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Run 'cello login' to register agents, or check agent names with
|
|
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.` };
|
|
1783
1802
|
}
|
|
1784
1803
|
if (onlineAgents.has(name)) {
|
|
1785
1804
|
// Idempotent — already online, no event
|
|
@@ -1856,7 +1875,7 @@ export async function startDaemon(config) {
|
|
|
1856
1875
|
}
|
|
1857
1876
|
const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
|
|
1858
1877
|
if (store.hasActiveAgent(name) || agents.some((a) => a.name === name)) {
|
|
1859
|
-
return { ok: false, reason: "agent_already_exists", guidance: `Agent '${name}' already exists. Choose a different name, or see
|
|
1878
|
+
return { ok: false, reason: "agent_already_exists", guidance: `Agent '${name}' already exists. Choose a different name, or see cello_agents.` };
|
|
1860
1879
|
}
|
|
1861
1880
|
let pubkeyHex;
|
|
1862
1881
|
let agentId;
|
|
@@ -1966,7 +1985,7 @@ export async function startDaemon(config) {
|
|
|
1966
1985
|
// any retire — the active-only accessors filter retired rows out once state is flipped.
|
|
1967
1986
|
const target = store.getAgentForRevocation(name);
|
|
1968
1987
|
if (!target) {
|
|
1969
|
-
return { ok: false, reason: "agent_not_found", guidance: `No agent named '${name}'. Check
|
|
1988
|
+
return { ok: false, reason: "agent_not_found", guidance: `No agent named '${name}'. Check cello_agents.` };
|
|
1970
1989
|
}
|
|
1971
1990
|
const wasActive = target.state !== "retired";
|
|
1972
1991
|
const agentId = target.localAgentId;
|
|
@@ -2064,7 +2083,7 @@ export async function startDaemon(config) {
|
|
|
2064
2083
|
}
|
|
2065
2084
|
const agent = agents.find((a) => a.name === name);
|
|
2066
2085
|
if (!agent || agent.state === "load_failed") {
|
|
2067
|
-
return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Check agent names with
|
|
2086
|
+
return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Check agent names with cello_agents.` };
|
|
2068
2087
|
}
|
|
2069
2088
|
if (!onlineAgents.has(name)) {
|
|
2070
2089
|
// Idempotent — already registered/offline, no event
|
|
@@ -2095,7 +2114,7 @@ export async function startDaemon(config) {
|
|
|
2095
2114
|
}
|
|
2096
2115
|
const agent = agents.find((a) => a.name === name);
|
|
2097
2116
|
if (!agent || agent.state === "load_failed") {
|
|
2098
|
-
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
|
|
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.` };
|
|
2099
2118
|
}
|
|
2100
2119
|
const connState = perConnectionState.get(connectionId);
|
|
2101
2120
|
if (!connState) {
|
|
@@ -2148,7 +2167,7 @@ export async function startDaemon(config) {
|
|
|
2148
2167
|
const reg = await new DbRegistrationPersistence({ db: sessionNodeManager.getDb(), agentName: name, logger }).loadRegistrationState();
|
|
2149
2168
|
if (!reg || reg.status !== "active") {
|
|
2150
2169
|
result["warning"] = "not_registered";
|
|
2151
|
-
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.`;
|
|
2152
2171
|
}
|
|
2153
2172
|
}
|
|
2154
2173
|
catch (err) {
|
|
@@ -2201,11 +2220,11 @@ export async function startDaemon(config) {
|
|
|
2201
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)." };
|
|
2202
2221
|
}
|
|
2203
2222
|
if (!preAuthToken) {
|
|
2204
|
-
return { ok: false, reason: "missing_preauth_token", guidance: "Registration requires a 'preAuthToken' issued by the CELLO Operations Agent (Telegram). Obtain one, then retry
|
|
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'." };
|
|
2205
2224
|
}
|
|
2206
2225
|
const keyProvider = keyProviders.get(name);
|
|
2207
2226
|
if (!keyProvider) {
|
|
2208
|
-
return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Create it first with
|
|
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'.` };
|
|
2209
2228
|
}
|
|
2210
2229
|
if (!directoryEndpointResolver) {
|
|
2211
2230
|
return { ok: false, reason: "directory_unreachable", guidance: "The daemon has no directory endpoint resolver configured, so it cannot reach the directory to register." };
|
|
@@ -2255,7 +2274,7 @@ export async function startDaemon(config) {
|
|
|
2255
2274
|
return {
|
|
2256
2275
|
ok: false,
|
|
2257
2276
|
reason: "directory_signaling_timeout",
|
|
2258
|
-
guidance: `Agent '${name}' could not establish its directory signaling stream within 10s. Check CELLO_DIRECTORY_URL and that the directory is reachable, then retry
|
|
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'.`,
|
|
2259
2278
|
};
|
|
2260
2279
|
}
|
|
2261
2280
|
const persistence = getPersistence(name);
|
|
@@ -3074,7 +3093,7 @@ export async function startDaemon(config) {
|
|
|
3074
3093
|
return {
|
|
3075
3094
|
ok: false,
|
|
3076
3095
|
reason: "session_not_found",
|
|
3077
|
-
guidance: "No session found with this ID. Check
|
|
3096
|
+
guidance: "No session found with this ID. Check cello_sessions for active and interrupted sessions.",
|
|
3078
3097
|
};
|
|
3079
3098
|
}
|
|
3080
3099
|
// Ownership: redundant now that the lookup is agent-scoped (record.agent_name === currentAgent),
|
|
@@ -3083,7 +3102,7 @@ export async function startDaemon(config) {
|
|
|
3083
3102
|
return {
|
|
3084
3103
|
ok: false,
|
|
3085
3104
|
reason: "session_not_owned",
|
|
3086
|
-
guidance: "This session belongs to a different agent. Call cello_use_agent to switch to the agent that owns it (see
|
|
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.",
|
|
3087
3106
|
};
|
|
3088
3107
|
}
|
|
3089
3108
|
// AC-010: already sealed
|
|
@@ -3091,7 +3110,7 @@ export async function startDaemon(config) {
|
|
|
3091
3110
|
return {
|
|
3092
3111
|
ok: false,
|
|
3093
3112
|
reason: "session_already_sealed",
|
|
3094
|
-
guidance: "This session is already sealed. No further action is needed — check
|
|
3113
|
+
guidance: "This session is already sealed. No further action is needed — check cello_sessions to view its sealed record and the FROST notarization.",
|
|
3095
3114
|
};
|
|
3096
3115
|
}
|
|
3097
3116
|
// CC-5/F21 terminal-escape: force-abandon a session that can never be bilaterally sealed — a
|
|
@@ -3336,7 +3355,7 @@ export async function startDaemon(config) {
|
|
|
3336
3355
|
return {
|
|
3337
3356
|
ok: false,
|
|
3338
3357
|
reason: uniResult.reason,
|
|
3339
|
-
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 (
|
|
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.",
|
|
3340
3359
|
};
|
|
3341
3360
|
}
|
|
3342
3361
|
finally {
|
|
@@ -3356,7 +3375,7 @@ export async function startDaemon(config) {
|
|
|
3356
3375
|
return {
|
|
3357
3376
|
ok: false,
|
|
3358
3377
|
reason: "session_not_closeable",
|
|
3359
|
-
guidance: `Session is in status '${record.status}', which cannot be closed via cello_close_session. Check
|
|
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.`,
|
|
3360
3379
|
};
|
|
3361
3380
|
});
|
|
3362
3381
|
// ─── MCP-001: stubs for tools registered in cello-mcp.ts but not yet implemented ───
|
|
@@ -3378,7 +3397,7 @@ export async function startDaemon(config) {
|
|
|
3378
3397
|
// cello-mcp forwards this as { session_id } (snake_case, matching the other session tools).
|
|
3379
3398
|
const sessionId = params?.["session_id"];
|
|
3380
3399
|
if (!sessionId || typeof sessionId !== "string") {
|
|
3381
|
-
return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) of the sealed session. Check
|
|
3400
|
+
return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) of the sealed session. Check cello_sessions for sealed sessions." };
|
|
3382
3401
|
}
|
|
3383
3402
|
// DOD-LOOP-1: the certificate is keyed by (agent, session_id) — read the current agent's.
|
|
3384
3403
|
const connState = perConnectionState.get(connectionId);
|
|
@@ -3410,7 +3429,7 @@ export async function startDaemon(config) {
|
|
|
3410
3429
|
return {
|
|
3411
3430
|
ok: false,
|
|
3412
3431
|
reason: "wrong_agent",
|
|
3413
|
-
guidance: `This session belongs to agent '${owner.name}', not the current agent '${agentName}'. Call cello_use_agent('${owner.name}') then retry
|
|
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.`,
|
|
3414
3433
|
};
|
|
3415
3434
|
}
|
|
3416
3435
|
// A truncated paste: the id is a strict prefix of one of this agent's real session ids.
|
|
@@ -3421,13 +3440,13 @@ export async function startDaemon(config) {
|
|
|
3421
3440
|
return {
|
|
3422
3441
|
ok: false,
|
|
3423
3442
|
reason: "session_id_too_short",
|
|
3424
|
-
guidance: "That looks like a truncated session id.
|
|
3443
|
+
guidance: "That looks like a truncated session id. cello_sessions and cello status show the FULL id — copy the complete id and retry.",
|
|
3425
3444
|
};
|
|
3426
3445
|
}
|
|
3427
3446
|
return {
|
|
3428
3447
|
ok: false,
|
|
3429
3448
|
reason: "unknown_session",
|
|
3430
|
-
guidance: "No session with this id belongs to the current agent. Run
|
|
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.",
|
|
3431
3450
|
};
|
|
3432
3451
|
});
|
|
3433
3452
|
// DOD-LOG-1 (PERSIST-LOG-001): read the durable, decrypted conversation transcript for a session —
|
|
@@ -3437,7 +3456,7 @@ export async function startDaemon(config) {
|
|
|
3437
3456
|
handlers.set("cello_get_transcript", async (params, connectionId) => {
|
|
3438
3457
|
const sessionId = params?.["session_id"];
|
|
3439
3458
|
if (!sessionId || typeof sessionId !== "string") {
|
|
3440
|
-
return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) whose transcript to read. See
|
|
3459
|
+
return { ok: false, reason: "missing_session_id", guidance: "Provide the session_id (hex) whose transcript to read. See cello_sessions." };
|
|
3441
3460
|
}
|
|
3442
3461
|
const connState = perConnectionState.get(connectionId);
|
|
3443
3462
|
// CC-3 / M8C-AUTOSTART-1 F18: explicit { name } > this connection's current > sole online agent.
|
|
@@ -3455,7 +3474,16 @@ export async function startDaemon(config) {
|
|
|
3455
3474
|
// reviewer HIGH finding (aa5928e2/a9099571): recordTranscriptMessage swallows a DB write
|
|
3456
3475
|
// failure without rolling back the tree's leaf count, so a hole in `messages` is possible in
|
|
3457
3476
|
// principle; the contiguous-run walk refuses to vault the cursor past such a hole even here.
|
|
3458
|
-
|
|
3477
|
+
const deliveredSeqs = new Set(messages.map((m) => m.sequence));
|
|
3478
|
+
safeCursorAdvance(connectionId, sessionId, deliveredSeqs);
|
|
3479
|
+
// DOD-CURSOR-DURABLE-1 (AC3): reading the full history IS reading — so this must advance the
|
|
3480
|
+
// PERSISTED watermark too, not just this connection's in-memory cursor. Previously it advanced
|
|
3481
|
+
// only the cursor, which made the gate's own guidance ("call cello_get_transcript, then retry")
|
|
3482
|
+
// a dead end for any stateless client: a fresh process reads the transcript, exits, and the next
|
|
3483
|
+
// process still presents an unread count. Same contiguous-run safety as the cursor — never vault
|
|
3484
|
+
// past a gap (an undecryptable row is absent from `messages`, so the walk stops there and those
|
|
3485
|
+
// messages correctly stay unread).
|
|
3486
|
+
safeWatermarkAdvance(agentName, sessionId, deliveredSeqs);
|
|
3459
3487
|
return { ok: true, session_id: sessionId, messages, undecryptable };
|
|
3460
3488
|
});
|
|
3461
3489
|
// cello_list_sessions: the discovery surface — every persisted session for the
|
|
@@ -4716,7 +4744,7 @@ export async function startDaemon(config) {
|
|
|
4716
4744
|
return {
|
|
4717
4745
|
ok: false,
|
|
4718
4746
|
reason: "seal_interrupted_counterparty_unavailable",
|
|
4719
|
-
guidance: "The counterparty is not currently reachable to complete the seal-interrupted flow. Retry when the counterparty is online — check their connection status via
|
|
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.",
|
|
4720
4748
|
};
|
|
4721
4749
|
}
|
|
4722
4750
|
// Wait for counterparty ack/rejection via the shared signaling await machinery.
|
|
@@ -4732,7 +4760,7 @@ export async function startDaemon(config) {
|
|
|
4732
4760
|
return {
|
|
4733
4761
|
ok: false,
|
|
4734
4762
|
reason: "seal_interrupted_counterparty_unavailable",
|
|
4735
|
-
guidance: "The counterparty is not currently reachable to complete the seal-interrupted flow. Retry when the counterparty is online — check their connection status via
|
|
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.",
|
|
4736
4764
|
};
|
|
4737
4765
|
}
|
|
4738
4766
|
if (ackResult.type === "seal_interrupted_rejection") {
|
|
@@ -4928,7 +4956,7 @@ export async function startDaemon(config) {
|
|
|
4928
4956
|
ok: false,
|
|
4929
4957
|
reason,
|
|
4930
4958
|
guidance: ackResult.type === "timeout"
|
|
4931
|
-
? "The counterparty did not acknowledge the seal in time. Retry when they are online — check
|
|
4959
|
+
? "The counterparty did not acknowledge the seal in time. Retry when they are online — check cello_status. The session remains active and usable."
|
|
4932
4960
|
: "The counterparty rejected the seal request. Their session state may be inconsistent. Ask them to check cello status before retrying.",
|
|
4933
4961
|
};
|
|
4934
4962
|
}
|
|
@@ -5003,7 +5031,7 @@ export async function startDaemon(config) {
|
|
|
5003
5031
|
// DOD-LOOP-1: the (agent, session_id) lookup is itself the ownership scope.
|
|
5004
5032
|
const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
|
|
5005
5033
|
if (!record) {
|
|
5006
|
-
return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check
|
|
5034
|
+
return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check cello_sessions for active sessions." };
|
|
5007
5035
|
}
|
|
5008
5036
|
if (record.agent_name !== agentName) {
|
|
5009
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." };
|
|
@@ -5019,18 +5047,57 @@ export async function startDaemon(config) {
|
|
|
5019
5047
|
// rather than let it send blind — the WhatsApp-group-chat model. Runs BEFORE M9's
|
|
5020
5048
|
// governance-decisions parsing below — an access-control gate should short-circuit before any
|
|
5021
5049
|
// unrelated prep work for a send that may not even be allowed to proceed.
|
|
5050
|
+
// DOD-CURSOR-DURABLE-1: the gate now consults TWO authorities and passes if EITHER says the
|
|
5051
|
+
// caller is caught up.
|
|
5052
|
+
//
|
|
5053
|
+
// 1. the connection cursor (in-memory, per-connection) — today's rule, UNCHANGED. A
|
|
5054
|
+
// long-lived client (the MCP shim, one socket for the whole session) satisfies this
|
|
5055
|
+
// exactly as before, so the shipped single-connection path is byte-for-byte identical.
|
|
5056
|
+
//
|
|
5057
|
+
// 2. the persisted per-(agent, session) read watermark — NEW. A STATELESS client (the `cello`
|
|
5058
|
+
// CLI runs a fresh process, and therefore a fresh connection, per command) always presents
|
|
5059
|
+
// cursor -1, so authority 1 can never be satisfied by it: after the counterparty spoke
|
|
5060
|
+
// once, every CLI send was refused forever, even though the agent had demonstrably read
|
|
5061
|
+
// the message. That made a two-way conversation impossible from bash — and it silently hit
|
|
5062
|
+
// any RECONNECTING MCP client too, whose fresh connectionId also resets the cursor to -1.
|
|
5063
|
+
//
|
|
5064
|
+
// What the gate protects, precisely: an agent must never reply to COUNTERPARTY content nobody
|
|
5065
|
+
// on its side has seen. That guarantee is preserved and is now durable — it survives the socket.
|
|
5066
|
+
//
|
|
5067
|
+
// What it deliberately no longer protects (the approved trade — see
|
|
5068
|
+
// 2026-07-11_cursor-durable-read-before-write-design.md §6, Andre's go): a message this agent
|
|
5069
|
+
// SENT from a DIFFERENT local connection no longer blocks. Two attended windows on one agent
|
|
5070
|
+
// used to gate each other; now they do not, because a locally-authored message is not "unread
|
|
5071
|
+
// counterparty content" — the AGENT wrote it. The principal here is the agent, not the socket,
|
|
5072
|
+
// and the daemon cannot referee which of an operator's own windows a human is looking at.
|
|
5022
5073
|
const currentSeq = record.message_count - 1;
|
|
5023
|
-
const
|
|
5024
|
-
|
|
5074
|
+
const connectionCursor = getConnectionCursor(connectionId, sessionId);
|
|
5075
|
+
const unreadReceived = sessionNodeManager.getUnreadReceivedCount(agentName, sessionId);
|
|
5076
|
+
const caughtUp = connectionCursor >= currentSeq || unreadReceived === 0;
|
|
5077
|
+
if (!caughtUp) {
|
|
5025
5078
|
// M8C-CURSOR-1 (reviewer MEDIUM fix): every sibling rejection in this handler logs; this
|
|
5026
5079
|
// gate must too — a security-relevant control-flow path with no observability is a gap.
|
|
5027
|
-
|
|
5080
|
+
// Both authorities are logged, so the next reader can tell WHICH one refused.
|
|
5081
|
+
logger.warn("session.send.blocked", {
|
|
5082
|
+
sessionId,
|
|
5083
|
+
currentSeq,
|
|
5084
|
+
lastReadSeq: connectionCursor,
|
|
5085
|
+
unreadReceived,
|
|
5086
|
+
connectionId,
|
|
5087
|
+
agentName,
|
|
5088
|
+
});
|
|
5028
5089
|
return {
|
|
5029
5090
|
ok: false,
|
|
5030
5091
|
reason: "session_not_current",
|
|
5031
5092
|
current_seq: currentSeq,
|
|
5032
|
-
last_read_seq:
|
|
5033
|
-
|
|
5093
|
+
last_read_seq: connectionCursor,
|
|
5094
|
+
unread_received: unreadReceived,
|
|
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.`,
|
|
5034
5101
|
};
|
|
5035
5102
|
}
|
|
5036
5103
|
// M9-FEED-001 §6: the agent's governance re-send decisions, keyed by the flagId a prior `warn`
|
|
@@ -5153,7 +5220,7 @@ export async function startDaemon(config) {
|
|
|
5153
5220
|
return {
|
|
5154
5221
|
ok: false,
|
|
5155
5222
|
reason: sendResult.reason,
|
|
5156
|
-
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
|
|
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.",
|
|
5157
5224
|
};
|
|
5158
5225
|
}
|
|
5159
5226
|
// Delivered directly OR dispatched to relay (DOD-LEAVEMSG-1) — either way the content is now
|
|
@@ -5252,7 +5319,7 @@ export async function startDaemon(config) {
|
|
|
5252
5319
|
let transcriptOnly = false;
|
|
5253
5320
|
if (!record) {
|
|
5254
5321
|
if (sessionNodeManager.readTranscript(agentName, sessionId).messages.length === 0) {
|
|
5255
|
-
return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check
|
|
5322
|
+
return { ok: false, reason: "session_not_found", guidance: "No session found with this ID. Check cello_sessions." };
|
|
5256
5323
|
}
|
|
5257
5324
|
transcriptOnly = true;
|
|
5258
5325
|
}
|
|
@@ -5299,7 +5366,7 @@ export async function startDaemon(config) {
|
|
|
5299
5366
|
return {
|
|
5300
5367
|
ok: false,
|
|
5301
5368
|
reason: "session_not_live",
|
|
5302
|
-
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
|
|
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.",
|
|
5303
5370
|
};
|
|
5304
5371
|
}
|
|
5305
5372
|
const rawTimeout = params?.timeout_ms;
|
|
@@ -5340,8 +5407,8 @@ export async function startDaemon(config) {
|
|
|
5340
5407
|
...(sealedRoot ? { sealed_root: sealedRoot } : {}),
|
|
5341
5408
|
unread_count: terminal.unreadCount,
|
|
5342
5409
|
guidance: terminal.unreadCount > 0
|
|
5343
|
-
? `The session has been sealed by both parties. ${terminal.unreadCount} message(s) arrived that were not read live — call
|
|
5344
|
-
: "The session has been sealed by both parties. The full history is available via
|
|
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.",
|
|
5345
5412
|
};
|
|
5346
5413
|
}
|
|
5347
5414
|
// 3) Out of time — non-blocking-equivalent empty answer.
|
|
@@ -5359,7 +5426,7 @@ export async function startDaemon(config) {
|
|
|
5359
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.",
|
|
5360
5427
|
};
|
|
5361
5428
|
}
|
|
5362
|
-
return { ok: true, content: null, guidance: "No content arrived within timeout_ms. Call cello_receive again to keep waiting, or read
|
|
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." };
|
|
5363
5430
|
}
|
|
5364
5431
|
await new Promise((r) => setTimeout(r, Math.min(20, remaining)));
|
|
5365
5432
|
}
|
|
@@ -5416,6 +5483,11 @@ export async function startDaemon(config) {
|
|
|
5416
5483
|
noticed_at: n.noticed_at,
|
|
5417
5484
|
// Names the contact by the operator's OWN pet name (AC3); the self-declared name is a quoted,
|
|
5418
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.
|
|
5419
5491
|
notice: `Your contact ${n.moniker !== null ? `"${n.moniker}" ` : ""}(${n.pubkey.slice(0, 16)}…) now calls themselves ` +
|
|
5420
5492
|
`"${n.offered_name}" (self-declared — unverified). Adopt it: cello_contact_set_moniker ` +
|
|
5421
5493
|
`{ pubkey: "${n.pubkey}", moniker: "${n.offered_name}" }, or ignore.`,
|
|
@@ -5433,6 +5505,30 @@ export async function startDaemon(config) {
|
|
|
5433
5505
|
// M8C-CONTACT-1: cello contact add/remove/list [--agent <name>]. All three resolve the target
|
|
5434
5506
|
// agent the same way — an explicit params.agent, else this connection's current/sole-online
|
|
5435
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
|
+
}
|
|
5436
5532
|
function resolveContactAgent(connState, params) {
|
|
5437
5533
|
const explicit = typeof params?.agent === "string" ? params.agent : undefined;
|
|
5438
5534
|
if (explicit)
|
|
@@ -5449,6 +5545,9 @@ export async function startDaemon(config) {
|
|
|
5449
5545
|
}
|
|
5450
5546
|
handlers.set("cello_contact_add", async (params, connectionId) => {
|
|
5451
5547
|
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5548
|
+
const badPubkey = invalidPubkey(pubkey);
|
|
5549
|
+
if (badPubkey)
|
|
5550
|
+
return badPubkey;
|
|
5452
5551
|
if (!pubkey) {
|
|
5453
5552
|
return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to add." };
|
|
5454
5553
|
}
|
|
@@ -5486,6 +5585,9 @@ export async function startDaemon(config) {
|
|
|
5486
5585
|
// key is NOT a clear (Entry-66-F3): a request that omits it is malformed and rejected.
|
|
5487
5586
|
handlers.set("cello_contact_set_moniker", async (params, connectionId) => {
|
|
5488
5587
|
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5588
|
+
const badPubkey = invalidPubkey(pubkey);
|
|
5589
|
+
if (badPubkey)
|
|
5590
|
+
return badPubkey;
|
|
5489
5591
|
if (!pubkey || !params || !("moniker" in params)) {
|
|
5490
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." };
|
|
5491
5593
|
}
|
|
@@ -5511,6 +5613,9 @@ export async function startDaemon(config) {
|
|
|
5511
5613
|
// (0..4) — an unknown value is REFUSED, never coerced. Emits contact.tier.changed (old→new).
|
|
5512
5614
|
handlers.set("cello_contact_set_tier", async (params, connectionId) => {
|
|
5513
5615
|
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5616
|
+
const badPubkey = invalidPubkey(pubkey);
|
|
5617
|
+
if (badPubkey)
|
|
5618
|
+
return badPubkey;
|
|
5514
5619
|
const tier = typeof params?.tier === "number" ? params.tier : undefined;
|
|
5515
5620
|
if (!pubkey || tier === undefined) {
|
|
5516
5621
|
return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'tier' (0=blocked, 1=unknown, 2=known, 3=whitelisted, 4=vip)." };
|
|
@@ -5536,6 +5641,9 @@ export async function startDaemon(config) {
|
|
|
5536
5641
|
// on the outbound path at send time (SI), never here.
|
|
5537
5642
|
handlers.set("cello_contact_set_away", async (params, connectionId) => {
|
|
5538
5643
|
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5644
|
+
const badPubkey = invalidPubkey(pubkey);
|
|
5645
|
+
if (badPubkey)
|
|
5646
|
+
return badPubkey;
|
|
5539
5647
|
if (!pubkey || !params || !("message" in params)) {
|
|
5540
5648
|
return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'message' — a string away text, or null to clear it." };
|
|
5541
5649
|
}
|
|
@@ -5559,6 +5667,11 @@ export async function startDaemon(config) {
|
|
|
5559
5667
|
return { ok: true, agent: resolved.agent, pubkey };
|
|
5560
5668
|
});
|
|
5561
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.
|
|
5562
5675
|
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5563
5676
|
if (!pubkey) {
|
|
5564
5677
|
return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to remove." };
|
|
@@ -5653,7 +5766,7 @@ export async function startDaemon(config) {
|
|
|
5653
5766
|
}
|
|
5654
5767
|
const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
|
|
5655
5768
|
if (!store.setMoniker(resolved.agent, moniker)) {
|
|
5656
|
-
return { ok: false, reason: "agent_not_found", guidance: `No active agent named '${resolved.agent}'. See
|
|
5769
|
+
return { ok: false, reason: "agent_not_found", guidance: `No active agent named '${resolved.agent}'. See cello_agents.` };
|
|
5657
5770
|
}
|
|
5658
5771
|
logger.info("agent.moniker.set", { agentName: resolved.agent, cleared: moniker === null });
|
|
5659
5772
|
return { ok: true, agent: resolved.agent, moniker };
|
|
@@ -5683,8 +5796,28 @@ export async function startDaemon(config) {
|
|
|
5683
5796
|
}
|
|
5684
5797
|
return { acknowledged: true };
|
|
5685
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
|
+
}
|
|
5686
5819
|
// Create and start IPC server
|
|
5687
|
-
const ipcServer = createIpcServer({ socketPath, maxConnections, logger },
|
|
5820
|
+
const ipcServer = createIpcServer({ socketPath, maxConnections, logger }, renderedHandlers);
|
|
5688
5821
|
try {
|
|
5689
5822
|
await ipcServer.start();
|
|
5690
5823
|
}
|