@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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/bin/murmur-npm.mjs +6 -0
  4. package/package.json +42 -0
  5. package/runtime/bin/murmur-svc.exe +0 -0
  6. package/runtime/npm-runtime-manifest.json +299 -0
  7. package/runtime/package.json +1 -0
  8. package/runtime/packages/mcp-server/dist/src/index.js +1 -0
  9. package/runtime/packages/mcp-server/package.json +1 -0
  10. package/runtime/packages/setup/bin/murmur.mjs +26 -0
  11. package/runtime/packages/setup/dist/src/appdata.d.ts +9 -0
  12. package/runtime/packages/setup/dist/src/appdata.js +53 -0
  13. package/runtime/packages/setup/dist/src/cli.d.ts +9 -0
  14. package/runtime/packages/setup/dist/src/cli.js +130 -0
  15. package/runtime/packages/setup/dist/src/clients.d.ts +151 -0
  16. package/runtime/packages/setup/dist/src/clients.js +251 -0
  17. package/runtime/packages/setup/dist/src/commands.d.ts +35 -0
  18. package/runtime/packages/setup/dist/src/commands.js +115 -0
  19. package/runtime/packages/setup/dist/src/config.d.ts +49 -0
  20. package/runtime/packages/setup/dist/src/config.js +70 -0
  21. package/runtime/packages/setup/dist/src/doctor.d.ts +25 -0
  22. package/runtime/packages/setup/dist/src/doctor.js +174 -0
  23. package/runtime/packages/setup/dist/src/file-identity.d.ts +3 -0
  24. package/runtime/packages/setup/dist/src/file-identity.js +11 -0
  25. package/runtime/packages/setup/dist/src/index.d.ts +7 -0
  26. package/runtime/packages/setup/dist/src/index.js +7 -0
  27. package/runtime/packages/setup/dist/src/onboarding.d.ts +46 -0
  28. package/runtime/packages/setup/dist/src/onboarding.js +298 -0
  29. package/runtime/packages/setup/dist/src/outbox-attention.d.ts +51 -0
  30. package/runtime/packages/setup/dist/src/outbox-attention.js +98 -0
  31. package/runtime/packages/setup/dist/src/paths.d.ts +10 -0
  32. package/runtime/packages/setup/dist/src/paths.js +41 -0
  33. package/runtime/packages/setup/dist/src/platform/darwin.d.ts +17 -0
  34. package/runtime/packages/setup/dist/src/platform/darwin.js +270 -0
  35. package/runtime/packages/setup/dist/src/platform/linux.d.ts +9 -0
  36. package/runtime/packages/setup/dist/src/platform/linux.js +176 -0
  37. package/runtime/packages/setup/dist/src/platform/windows.d.ts +25 -0
  38. package/runtime/packages/setup/dist/src/platform/windows.js +189 -0
  39. package/runtime/packages/setup/dist/src/private-file.d.ts +5 -0
  40. package/runtime/packages/setup/dist/src/private-file.js +101 -0
  41. package/runtime/packages/setup/dist/src/reply-test.d.ts +25 -0
  42. package/runtime/packages/setup/dist/src/reply-test.js +75 -0
  43. package/runtime/packages/setup/dist/src/state.d.ts +3 -0
  44. package/runtime/packages/setup/dist/src/state.js +12 -0
  45. package/runtime/packages/setup/dist/src/status-line.d.ts +5 -0
  46. package/runtime/packages/setup/dist/src/status-line.js +19 -0
  47. package/runtime/packages/setup/dist/src/status.d.ts +137 -0
  48. package/runtime/packages/setup/dist/src/status.js +199 -0
  49. package/runtime/packages/setup/dist/src/types.d.ts +40 -0
  50. package/runtime/packages/setup/dist/src/types.js +1 -0
  51. package/runtime/packages/setup/dist/src/updates.d.ts +46 -0
  52. package/runtime/packages/setup/dist/src/updates.js +284 -0
  53. package/runtime/packages/setup/dist/src/verdict.d.ts +11 -0
  54. package/runtime/packages/setup/dist/src/verdict.js +159 -0
  55. package/runtime/packages/setup/package.json +1 -0
  56. package/runtime/plugins/claude-code/.claude-plugin/plugin.json +34 -0
  57. package/runtime/plugins/claude-code/.mcp.json +8 -0
  58. package/runtime/plugins/claude-code/scripts/configure-statusline.mjs +124 -0
  59. package/runtime/plugins/claude-code/scripts/statusline.mjs +210 -0
  60. package/runtime/plugins/claude-code/skills/inbox/SKILL.md +8 -0
  61. package/runtime/plugins/claude-code/skills/mark-read/SKILL.md +13 -0
  62. package/runtime/plugins/claude-code/skills/status/SKILL.md +13 -0
  63. package/runtime/scripts/ack-security.mjs +22 -0
  64. package/runtime/scripts/codex-app-server-wake.mjs +696 -0
  65. package/runtime/scripts/daemon-observation.mjs +48 -0
  66. package/runtime/scripts/doctor-protocol.mjs +96 -0
  67. package/runtime/scripts/lease.mjs +193 -0
  68. package/runtime/scripts/murmur-daemon.mjs +545 -0
  69. package/runtime/scripts/murmur-jetstream-advisory.mjs +11 -0
  70. package/runtime/scripts/murmur-shell-send.mjs +142 -0
  71. package/runtime/scripts/notify-router.mjs +309 -0
  72. package/runtime/scripts/runtime-capability.mjs +34 -0
  73. package/runtime/scripts/secure-state.mjs +105 -0
  74. package/runtime/scripts/wake-drain-claude.mjs +484 -0
  75. 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
+ }