@murmurv2/cli 2.11.0
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/LICENSE +21 -0
- package/README.md +53 -0
- package/bin/murmur-npm.mjs +6 -0
- package/package.json +42 -0
- package/runtime/bin/murmur-svc.exe +0 -0
- package/runtime/npm-runtime-manifest.json +299 -0
- package/runtime/package.json +1 -0
- package/runtime/packages/mcp-server/dist/src/index.js +1 -0
- package/runtime/packages/mcp-server/package.json +1 -0
- package/runtime/packages/setup/bin/murmur.mjs +26 -0
- package/runtime/packages/setup/dist/src/appdata.d.ts +9 -0
- package/runtime/packages/setup/dist/src/appdata.js +53 -0
- package/runtime/packages/setup/dist/src/cli.d.ts +9 -0
- package/runtime/packages/setup/dist/src/cli.js +130 -0
- package/runtime/packages/setup/dist/src/clients.d.ts +151 -0
- package/runtime/packages/setup/dist/src/clients.js +251 -0
- package/runtime/packages/setup/dist/src/commands.d.ts +35 -0
- package/runtime/packages/setup/dist/src/commands.js +115 -0
- package/runtime/packages/setup/dist/src/config.d.ts +49 -0
- package/runtime/packages/setup/dist/src/config.js +70 -0
- package/runtime/packages/setup/dist/src/doctor.d.ts +25 -0
- package/runtime/packages/setup/dist/src/doctor.js +174 -0
- package/runtime/packages/setup/dist/src/file-identity.d.ts +3 -0
- package/runtime/packages/setup/dist/src/file-identity.js +11 -0
- package/runtime/packages/setup/dist/src/index.d.ts +7 -0
- package/runtime/packages/setup/dist/src/index.js +7 -0
- package/runtime/packages/setup/dist/src/onboarding.d.ts +46 -0
- package/runtime/packages/setup/dist/src/onboarding.js +298 -0
- package/runtime/packages/setup/dist/src/outbox-attention.d.ts +51 -0
- package/runtime/packages/setup/dist/src/outbox-attention.js +98 -0
- package/runtime/packages/setup/dist/src/paths.d.ts +10 -0
- package/runtime/packages/setup/dist/src/paths.js +41 -0
- package/runtime/packages/setup/dist/src/platform/darwin.d.ts +17 -0
- package/runtime/packages/setup/dist/src/platform/darwin.js +270 -0
- package/runtime/packages/setup/dist/src/platform/linux.d.ts +9 -0
- package/runtime/packages/setup/dist/src/platform/linux.js +176 -0
- package/runtime/packages/setup/dist/src/platform/windows.d.ts +25 -0
- package/runtime/packages/setup/dist/src/platform/windows.js +189 -0
- package/runtime/packages/setup/dist/src/private-file.d.ts +5 -0
- package/runtime/packages/setup/dist/src/private-file.js +101 -0
- package/runtime/packages/setup/dist/src/reply-test.d.ts +25 -0
- package/runtime/packages/setup/dist/src/reply-test.js +75 -0
- package/runtime/packages/setup/dist/src/state.d.ts +3 -0
- package/runtime/packages/setup/dist/src/state.js +12 -0
- package/runtime/packages/setup/dist/src/status-line.d.ts +5 -0
- package/runtime/packages/setup/dist/src/status-line.js +19 -0
- package/runtime/packages/setup/dist/src/status.d.ts +137 -0
- package/runtime/packages/setup/dist/src/status.js +199 -0
- package/runtime/packages/setup/dist/src/types.d.ts +40 -0
- package/runtime/packages/setup/dist/src/types.js +1 -0
- package/runtime/packages/setup/dist/src/updates.d.ts +46 -0
- package/runtime/packages/setup/dist/src/updates.js +284 -0
- package/runtime/packages/setup/dist/src/verdict.d.ts +11 -0
- package/runtime/packages/setup/dist/src/verdict.js +159 -0
- package/runtime/packages/setup/package.json +1 -0
- package/runtime/plugins/claude-code/.claude-plugin/plugin.json +34 -0
- package/runtime/plugins/claude-code/.mcp.json +8 -0
- package/runtime/plugins/claude-code/scripts/configure-statusline.mjs +124 -0
- package/runtime/plugins/claude-code/scripts/statusline.mjs +210 -0
- package/runtime/plugins/claude-code/skills/inbox/SKILL.md +8 -0
- package/runtime/plugins/claude-code/skills/mark-read/SKILL.md +13 -0
- package/runtime/plugins/claude-code/skills/status/SKILL.md +13 -0
- package/runtime/scripts/ack-security.mjs +22 -0
- package/runtime/scripts/codex-app-server-wake.mjs +696 -0
- package/runtime/scripts/daemon-observation.mjs +48 -0
- package/runtime/scripts/doctor-protocol.mjs +96 -0
- package/runtime/scripts/lease.mjs +193 -0
- package/runtime/scripts/murmur-daemon.mjs +545 -0
- package/runtime/scripts/murmur-jetstream-advisory.mjs +11 -0
- package/runtime/scripts/murmur-shell-send.mjs +142 -0
- package/runtime/scripts/notify-router.mjs +309 -0
- package/runtime/scripts/runtime-capability.mjs +34 -0
- package/runtime/scripts/secure-state.mjs +105 -0
- package/runtime/scripts/wake-drain-claude.mjs +484 -0
- package/runtime/scripts/wake-monitor.mjs +653 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { writePrivateJson } from "./secure-state.mjs";
|
|
4
|
+
|
|
5
|
+
/** Non-secret, PID/store-bound liveness evidence for the read-only setup CLI. */
|
|
6
|
+
export function createDaemonObservation({ dataDir, storePath, agentId, wake, log = () => {} }) {
|
|
7
|
+
const startedAt = new Date().toISOString();
|
|
8
|
+
const snapshot = { schema: "murmur.runtime/1", agentId, pid: process.pid, startedAt,
|
|
9
|
+
storePath: null, measuredAt: null, wake: { ...wake, lastFault: null, lastFaultAt: null },
|
|
10
|
+
broker: { state: "unknown", connectedAt: null, lastError: null, lastErrorAt: null } };
|
|
11
|
+
let writing = false;
|
|
12
|
+
let timer;
|
|
13
|
+
const write = async () => {
|
|
14
|
+
if (writing) return;
|
|
15
|
+
writing = true;
|
|
16
|
+
try {
|
|
17
|
+
snapshot.storePath = await realpath(storePath);
|
|
18
|
+
snapshot.measuredAt = new Date().toISOString();
|
|
19
|
+
await writePrivateJson(path.join(dataDir, "daemon-observation.json"), snapshot);
|
|
20
|
+
} catch { log("warn", "Daemon observation write failed", { reason: "runtime.observation-write-failed" }); }
|
|
21
|
+
finally { writing = false; }
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
async start() { await write(); timer = setInterval(() => { void write(); }, 5000); timer.unref(); },
|
|
25
|
+
async connected() { snapshot.broker.state = "connected"; snapshot.broker.connectedAt = new Date().toISOString(); await write(); },
|
|
26
|
+
onStatus(event) {
|
|
27
|
+
if (event.type === "disconnect") {
|
|
28
|
+
snapshot.broker.state = "disconnected";
|
|
29
|
+
snapshot.broker.lastError = "broker.disconnected";
|
|
30
|
+
snapshot.broker.lastErrorAt = new Date().toISOString();
|
|
31
|
+
} else if (event.type === "reconnect") {
|
|
32
|
+
snapshot.broker.state = "connected";
|
|
33
|
+
snapshot.broker.connectedAt = new Date().toISOString();
|
|
34
|
+
}
|
|
35
|
+
void write();
|
|
36
|
+
},
|
|
37
|
+
observeLog(level, message, data) {
|
|
38
|
+
// Preserve a monitor crash even when SQLite could not record the failed wake.
|
|
39
|
+
// Never persist hook commands, stdout, message text or arbitrary error strings.
|
|
40
|
+
if ((level === "error" || level === "fatal") && message.startsWith("WakeMonitor")) {
|
|
41
|
+
snapshot.wake.lastFault = data?.error === "database is locked" ? "wake.database-locked" : "wake.runtime-fault";
|
|
42
|
+
snapshot.wake.lastFaultAt = new Date().toISOString();
|
|
43
|
+
return write();
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
stop() { if (timer) clearInterval(timer); },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Machine-only roundtrip protocol. Call only after peer signature verification,
|
|
2
|
+
// decryption and channel authorization. This proves transport, never AI wake.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { resolveMessageSubject, stableEnvelopePayload } from "@murmurv2/core";
|
|
5
|
+
import { decryptPayload, encryptPayload, signEnvelope } from "@murmurv2/security";
|
|
6
|
+
|
|
7
|
+
const PREFIX = "murmur:doctor:";
|
|
8
|
+
const CONVERSATION = /^murmur:doctor:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
9
|
+
const REQUEST = /^Murmur diagnostic roundtrip\. Reply in this same conversation with exactly this line: MURMUR-DOCTOR-REPLY ([0-9a-f]{48})$/;
|
|
10
|
+
const REPLY = /^MURMUR-DOCTOR-REPLY ([0-9a-f]{48})$/;
|
|
11
|
+
const WINDOW_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
export function classifyVerifiedDoctorMessage(config, envelope, text, now = Date.now()) {
|
|
14
|
+
if (!envelope.conversationId.startsWith(PREFIX)) return null;
|
|
15
|
+
const invalid = { kind: "ignored" };
|
|
16
|
+
const peer = config.peers[envelope.senderAgentId];
|
|
17
|
+
const at = Date.parse(envelope.createdAt);
|
|
18
|
+
if (!peer || envelope.senderAgentId === config.agentId
|
|
19
|
+
|| !CONVERSATION.test(envelope.conversationId) || envelope.conversationId.includes("\n")
|
|
20
|
+
|| envelope.recipients.length !== 1 || envelope.recipients[0] !== config.agentId
|
|
21
|
+
|| !Number.isFinite(at) || at < now - WINDOW_MS || at > now + 5000
|
|
22
|
+
|| envelope.channelId !== peer.channelId
|
|
23
|
+
|| envelope.senderMemberId !== peer.memberId
|
|
24
|
+
|| envelope.addresseeMemberId !== config.memberId) return invalid;
|
|
25
|
+
const request = REQUEST.exec(text);
|
|
26
|
+
if (request?.[0] === text) return { kind: "request", nonce: request[1] };
|
|
27
|
+
const reply = REPLY.exec(text);
|
|
28
|
+
return reply?.[0] === text ? { kind: "reply" } : invalid;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function doctorReplyId(agentId, envelope) {
|
|
32
|
+
const hex = createHash("sha256").update(JSON.stringify([
|
|
33
|
+
"murmur-doctor-reply/1", agentId, envelope.senderAgentId, envelope.msgId,
|
|
34
|
+
])).digest("hex");
|
|
35
|
+
// Stable UUID: a redelivery after a crash can finish enqueueing, but cannot
|
|
36
|
+
// resurrect a settled reply or create another message.
|
|
37
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createDoctorResponder({ config, outbox, messages, now = Date.now }) {
|
|
41
|
+
let recent = [];
|
|
42
|
+
const inFlight = new Map();
|
|
43
|
+
const reply = async (request, nonce) => {
|
|
44
|
+
const msgId = doctorReplyId(config.agentId, request);
|
|
45
|
+
const existing = await outbox.getOutboxRecord(msgId);
|
|
46
|
+
let envelope = existing?.envelope;
|
|
47
|
+
const text = `MURMUR-DOCTOR-REPLY ${nonce}`;
|
|
48
|
+
const peer = config.peers[request.senderAgentId];
|
|
49
|
+
if (envelope) {
|
|
50
|
+
const storedText = await decryptPayload({ ciphertext: envelope.payloadCiphertext,
|
|
51
|
+
nonce: envelope.payloadNonce, senderPublicKey: peer.encryption.publicKey }, config.keys.encryption.privateKey);
|
|
52
|
+
if (envelope.conversationId !== request.conversationId || storedText !== text) {
|
|
53
|
+
throw new Error("doctor.request-conflict");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!envelope) {
|
|
57
|
+
const at = now();
|
|
58
|
+
recent = recent.filter(entry => entry.at > at - WINDOW_MS);
|
|
59
|
+
// At most 60 retained entries: no unbounded peer map. Diagnostic floods
|
|
60
|
+
// from a configured peer cannot create unbounded response work.
|
|
61
|
+
if (recent.length >= 60 || recent.filter(entry => entry.peer === request.senderAgentId).length >= 6) {
|
|
62
|
+
return { state: "rate-limited" };
|
|
63
|
+
}
|
|
64
|
+
recent.push({ at, peer: request.senderAgentId });
|
|
65
|
+
const encrypted = await encryptPayload(text, peer.encryption.publicKey, config.keys.encryption.privateKey);
|
|
66
|
+
envelope = {
|
|
67
|
+
schemaVersion: "1.0", msgId, conversationId: request.conversationId,
|
|
68
|
+
senderAgentId: config.agentId, recipients: [request.senderAgentId], createdAt: new Date(at).toISOString(),
|
|
69
|
+
payloadCiphertext: encrypted.ciphertext, payloadNonce: encrypted.nonce, signature: "",
|
|
70
|
+
...(peer.channelId ? { channelId: peer.channelId } : {}),
|
|
71
|
+
...(config.memberId ? { senderMemberId: config.memberId } : {}),
|
|
72
|
+
...(peer.memberId ? { addresseeMemberId: peer.memberId } : {}),
|
|
73
|
+
};
|
|
74
|
+
envelope.signature = await signEnvelope(stableEnvelopePayload(envelope), config.keys.signing.privateKey);
|
|
75
|
+
await outbox.enqueue(resolveMessageSubject(peer, peer.channelId), envelope);
|
|
76
|
+
}
|
|
77
|
+
// Repair the history half if a crash followed the durable outbox enqueue.
|
|
78
|
+
await messages.append({
|
|
79
|
+
msgId, conversationId: envelope.conversationId, direction: "outbound", sender: config.agentId,
|
|
80
|
+
text, createdAt: envelope.createdAt, transport: "nats", channelId: envelope.channelId,
|
|
81
|
+
senderMemberId: envelope.senderMemberId, addresseeMemberId: envelope.addresseeMemberId,
|
|
82
|
+
});
|
|
83
|
+
return { state: existing ? "already-queued" : "queued", msgId };
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
respond(envelope, plaintext) {
|
|
87
|
+
const diagnostic = classifyVerifiedDoctorMessage(config, envelope, plaintext, now());
|
|
88
|
+
if (diagnostic?.kind !== "request") return Promise.resolve({ state: "ignored" });
|
|
89
|
+
const id = doctorReplyId(config.agentId, envelope);
|
|
90
|
+
if (inFlight.has(id)) return inFlight.get(id);
|
|
91
|
+
const pending = reply(envelope, diagnostic.nonce).finally(() => inFlight.delete(id));
|
|
92
|
+
inFlight.set(id, pending);
|
|
93
|
+
return pending;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Murmur Scoped Channels — session-ownership lease (spike #80, S0.2).
|
|
2
|
+
// Atomic CAS claim + heartbeat + per-turn fencing token over a dedicated SQLite file
|
|
3
|
+
// (separate WAL from local_messages, per review). Contract: packages/core/lease-schema.sql.
|
|
4
|
+
// Cross-runtime sibling: a Python wrapper over the SAME SQL is built by codex coldstart (#83).
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
const DDL = `
|
|
10
|
+
PRAGMA journal_mode=WAL;
|
|
11
|
+
PRAGMA busy_timeout=10000;
|
|
12
|
+
CREATE TABLE IF NOT EXISTS channel_owner (
|
|
13
|
+
conversation_id TEXT NOT NULL,
|
|
14
|
+
member_slot TEXT NOT NULL,
|
|
15
|
+
owner_session_id TEXT NOT NULL,
|
|
16
|
+
token INTEGER NOT NULL,
|
|
17
|
+
epoch INTEGER NOT NULL,
|
|
18
|
+
heartbeat_at INTEGER NOT NULL,
|
|
19
|
+
ttl_ms INTEGER NOT NULL,
|
|
20
|
+
PRIMARY KEY (conversation_id, member_slot)
|
|
21
|
+
);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS session_presence (
|
|
23
|
+
session_id TEXT PRIMARY KEY,
|
|
24
|
+
agent_id TEXT NOT NULL,
|
|
25
|
+
thread_id TEXT,
|
|
26
|
+
pid INTEGER,
|
|
27
|
+
mode TEXT NOT NULL,
|
|
28
|
+
heartbeat_at INTEGER NOT NULL,
|
|
29
|
+
started_at INTEGER NOT NULL
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_session_presence_agent ON session_presence(agent_id, mode);
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
// Single-statement atomic compare-and-swap. WHERE on DO UPDATE makes a live, non-stale
|
|
35
|
+
// foreign owner a no-op (no RETURNING row) — that is the "skip" branch.
|
|
36
|
+
// The optional `extraWhere` lets a real session preempt a low-priority fallback owner
|
|
37
|
+
// (e.g. a foreground chat preempting a `native:` cold-wake owner) even when not stale.
|
|
38
|
+
const buildClaimSql = (extraWhere) => `
|
|
39
|
+
INSERT INTO channel_owner
|
|
40
|
+
(conversation_id, member_slot, owner_session_id, token, epoch, heartbeat_at, ttl_ms)
|
|
41
|
+
VALUES (?, ?, ?, 1, 1, ?, ?)
|
|
42
|
+
ON CONFLICT(conversation_id, member_slot) DO UPDATE SET
|
|
43
|
+
owner_session_id = excluded.owner_session_id,
|
|
44
|
+
token = channel_owner.token + 1,
|
|
45
|
+
epoch = channel_owner.epoch + (CASE WHEN channel_owner.owner_session_id <> excluded.owner_session_id THEN 1 ELSE 0 END),
|
|
46
|
+
heartbeat_at = excluded.heartbeat_at,
|
|
47
|
+
ttl_ms = excluded.ttl_ms
|
|
48
|
+
WHERE channel_owner.owner_session_id = excluded.owner_session_id
|
|
49
|
+
OR (excluded.heartbeat_at - channel_owner.heartbeat_at) > channel_owner.ttl_ms${extraWhere}
|
|
50
|
+
RETURNING owner_session_id AS ownerSessionId, token, epoch
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const CLAIM_SQL = buildClaimSql("");
|
|
54
|
+
const CLAIM_PREEMPT_SQL = buildClaimSql("\n OR channel_owner.owner_session_id LIKE ?");
|
|
55
|
+
|
|
56
|
+
// Reserved session-id namespace for the daemon's cold-wake fallback owner. Interactive
|
|
57
|
+
// session ids (foreground / mcp-channel / coldstart) MUST NOT start with this prefix, so that
|
|
58
|
+
// a `preemptPrefix` of NATIVE_SESSION_PREFIX only ever reclaims the channel from the fallback,
|
|
59
|
+
// never from a legitimate live session (review note, Stas 2026-06-23).
|
|
60
|
+
export const NATIVE_SESSION_PREFIX = "native:";
|
|
61
|
+
|
|
62
|
+
export class SessionLeaseStore {
|
|
63
|
+
constructor(dbPath = ".data/lease.db") {
|
|
64
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
65
|
+
this.db = new DatabaseSync(dbPath);
|
|
66
|
+
this.db.exec(DDL);
|
|
67
|
+
this._claim = this.db.prepare(CLAIM_SQL);
|
|
68
|
+
this._claimPreempt = this.db.prepare(CLAIM_PREEMPT_SQL);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// S0.0 session registry. Upsert a live session's presence row.
|
|
72
|
+
registerSession({ sessionId, agentId, threadId = null, pid = null, mode, now = Date.now() }) {
|
|
73
|
+
this.db
|
|
74
|
+
.prepare(
|
|
75
|
+
`INSERT INTO session_presence (session_id, agent_id, thread_id, pid, mode, heartbeat_at, started_at)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
77
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
78
|
+
thread_id = excluded.thread_id, pid = excluded.pid,
|
|
79
|
+
mode = excluded.mode, heartbeat_at = excluded.heartbeat_at`,
|
|
80
|
+
)
|
|
81
|
+
.run(sessionId, agentId, threadId, pid, mode, now, now);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
sessionHeartbeat(sessionId, now = Date.now()) {
|
|
85
|
+
return this.db
|
|
86
|
+
.prepare(`UPDATE session_presence SET heartbeat_at = ? WHERE session_id = ?`)
|
|
87
|
+
.run(now, sessionId).changes;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Is there a live (non-stale) interactive session for this agent? The native fallback wake
|
|
91
|
+
// defers to it (so it never spawns a competing thread while a chat window is attended).
|
|
92
|
+
hasLiveInteractiveSession(agentId, ttlMs, now = Date.now(), modes = ["foreground", "mcp-channel"]) {
|
|
93
|
+
const placeholders = modes.map(() => "?").join(",");
|
|
94
|
+
const row = this.db
|
|
95
|
+
.prepare(
|
|
96
|
+
`SELECT COUNT(*) AS n FROM session_presence
|
|
97
|
+
WHERE agent_id = ? AND mode IN (${placeholders}) AND (? - heartbeat_at) <= ?`,
|
|
98
|
+
)
|
|
99
|
+
.get(agentId, ...modes, now, ttlMs);
|
|
100
|
+
return Number(row?.n ?? 0) > 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Atomic claim. Call BEFORE any side-effect. Returns { won, token, ownerSessionId }.
|
|
104
|
+
// `preemptPrefix` (optional): take over a current owner whose session id starts with it,
|
|
105
|
+
// even when that owner is still live — used so real chat sessions beat `native:` fallbacks.
|
|
106
|
+
// Pass `NATIVE_SESSION_PREFIX`; interactive session ids must never start with that prefix,
|
|
107
|
+
// so preemption can only reclaim from the daemon fallback, never from a real owner.
|
|
108
|
+
claimOrSkip(conversationId, memberSlot, sessionId, ttlMs, now = Date.now(), preemptPrefix = null) {
|
|
109
|
+
const row = preemptPrefix
|
|
110
|
+
? this._claimPreempt.get(conversationId, memberSlot, sessionId, now, ttlMs, `${preemptPrefix}%`)
|
|
111
|
+
: this._claim.get(conversationId, memberSlot, sessionId, now, ttlMs);
|
|
112
|
+
if (row && row.ownerSessionId === sessionId) {
|
|
113
|
+
return { won: true, token: Number(row.token), ownerSessionId: sessionId };
|
|
114
|
+
}
|
|
115
|
+
const cur = this.getOwner(conversationId, memberSlot);
|
|
116
|
+
return { won: false, token: cur?.token ?? null, ownerSessionId: cur?.ownerSessionId ?? null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Keep the lease alive without bumping the token. Returns 0 if ownership was lost.
|
|
120
|
+
heartbeat(conversationId, memberSlot, sessionId, now = Date.now()) {
|
|
121
|
+
return this.db
|
|
122
|
+
.prepare(
|
|
123
|
+
`UPDATE channel_owner SET heartbeat_at = ?
|
|
124
|
+
WHERE conversation_id = ? AND member_slot = ? AND owner_session_id = ?`,
|
|
125
|
+
)
|
|
126
|
+
.run(now, conversationId, memberSlot, sessionId).changes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Outbound fence: is `token` still the current owner token for this channel?
|
|
130
|
+
// INVARIANT: `token` is strictly monotonic per (conversation_id, member_slot) and is bumped on
|
|
131
|
+
// every successful claim, so `token === current` uniquely identifies the live claim epoch — a
|
|
132
|
+
// superseded or stale owner necessarily holds an older token. Fencing on the token alone is
|
|
133
|
+
// therefore sufficient; owner_session_id is implied by the token and need not be re-checked here.
|
|
134
|
+
isCurrentToken(conversationId, memberSlot, token) {
|
|
135
|
+
const row = this.db
|
|
136
|
+
.prepare(`SELECT token FROM channel_owner WHERE conversation_id = ? AND member_slot = ?`)
|
|
137
|
+
.get(conversationId, memberSlot);
|
|
138
|
+
return !!row && Number(row.token) === token;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getOwner(conversationId, memberSlot) {
|
|
142
|
+
const row = this.db
|
|
143
|
+
.prepare(
|
|
144
|
+
`SELECT owner_session_id AS ownerSessionId, token, epoch, heartbeat_at AS heartbeatAt, ttl_ms AS ttlMs
|
|
145
|
+
FROM channel_owner WHERE conversation_id = ? AND member_slot = ?`,
|
|
146
|
+
)
|
|
147
|
+
.get(conversationId, memberSlot);
|
|
148
|
+
if (!row) return null;
|
|
149
|
+
return { ownerSessionId: row.ownerSessionId, token: Number(row.token), epoch: Number(row.epoch), heartbeatAt: Number(row.heartbeatAt), ttlMs: Number(row.ttlMs) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
release(conversationId, memberSlot, sessionId) {
|
|
153
|
+
return this.db
|
|
154
|
+
.prepare(
|
|
155
|
+
`DELETE FROM channel_owner WHERE conversation_id = ? AND member_slot = ? AND owner_session_id = ?`,
|
|
156
|
+
)
|
|
157
|
+
.run(conversationId, memberSlot, sessionId).changes;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
close() {
|
|
161
|
+
this.db.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Native-daemon wake is a FALLBACK owner (#82): it must NOT spawn a competing thread while a
|
|
166
|
+
// chat window is attended. Returns a leaseGate(payload, peer) for WakeMonitor that:
|
|
167
|
+
// 1. defers (mute) if a live interactive session_presence row exists for the agent
|
|
168
|
+
// (foreground / mcp-channel sessions register+heartbeat presence, #83) — no race;
|
|
169
|
+
// 2. otherwise claims the channel as `native:<agentId>` and allows the wake (cold fallback);
|
|
170
|
+
// 3. mutes if some other live owner already holds the channel.
|
|
171
|
+
// Interactive sessions claim with preemptPrefix="native:" so they reclaim instantly should the
|
|
172
|
+
// native fallback have won during a presence gap.
|
|
173
|
+
export function createNativeLeaseGate({ store, agentId, ttlMs = 20000, memberSlot, presenceTtlMs, now = () => Date.now(), log = () => {} }) {
|
|
174
|
+
const sessionId = `${NATIVE_SESSION_PREFIX}${agentId}`;
|
|
175
|
+
const slot = memberSlot || agentId;
|
|
176
|
+
const pTtl = presenceTtlMs || ttlMs;
|
|
177
|
+
return async (payload) => {
|
|
178
|
+
const conversationId = payload?.conversationId;
|
|
179
|
+
if (!conversationId) return { allow: true, token: null, reason: "no-conversation" };
|
|
180
|
+
const ts = now();
|
|
181
|
+
if (store.hasLiveInteractiveSession(agentId, pTtl, ts)) {
|
|
182
|
+
log("info", "native lease defer (live interactive session)", { conversationId });
|
|
183
|
+
return { allow: false, token: null, reason: "live-interactive-session" };
|
|
184
|
+
}
|
|
185
|
+
const res = store.claimOrSkip(conversationId, slot, sessionId, ttlMs, ts);
|
|
186
|
+
if (res.won) {
|
|
187
|
+
log("info", "native lease claimed (fallback wake)", { conversationId, token: res.token, ownerSessionId: sessionId });
|
|
188
|
+
return { allow: true, token: res.token, ownerSessionId: sessionId };
|
|
189
|
+
}
|
|
190
|
+
log("info", "native lease skip (live owner holds channel)", { conversationId, ownerSessionId: res.ownerSessionId });
|
|
191
|
+
return { allow: false, token: null, ownerSessionId: res.ownerSessionId, reason: "live-owner" };
|
|
192
|
+
};
|
|
193
|
+
}
|