@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,653 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
const ensureObject = (value) => (value && typeof value === "object" ? value : {});
|
|
5
|
+
const DEFAULT_WAKE_MAX_ATTEMPTS = 5;
|
|
6
|
+
const DEFAULT_WAKE_RETRY_BACKOFF_MS = 30_000;
|
|
7
|
+
const DEFAULT_WAKE_RETRY_BACKOFF_MAX_MS = 10 * 60_000;
|
|
8
|
+
const DEFAULT_WAKE_CONCURRENCY = 4;
|
|
9
|
+
const validMode = (mode) => mode === "stateless" || mode === "codex_app_server";
|
|
10
|
+
|
|
11
|
+
export const normalizeWakeConfig = (config = {}) => {
|
|
12
|
+
const wake = ensureObject(config.wake);
|
|
13
|
+
const dedup = ensureObject(wake.dedup);
|
|
14
|
+
const loopBreaker = ensureObject(wake.loopBreaker);
|
|
15
|
+
const retry = ensureObject(wake.retry);
|
|
16
|
+
const hookTimeoutMs = wake.hookTimeoutMs === undefined ? 10000 : wake.hookTimeoutMs;
|
|
17
|
+
if (!Number.isSafeInteger(hookTimeoutMs) || hookTimeoutMs < 1 || hookTimeoutMs > 2147483647) {
|
|
18
|
+
throw new Error("wake-hook-timeout-invalid");
|
|
19
|
+
}
|
|
20
|
+
const peers = Object.fromEntries(
|
|
21
|
+
Object.entries(ensureObject(wake.peers)).map(([agentId, peer]) => {
|
|
22
|
+
const value = ensureObject(peer);
|
|
23
|
+
const normalized = {
|
|
24
|
+
mode: validMode(value.mode) ? value.mode : undefined,
|
|
25
|
+
socketPath: typeof value.socketPath === "string" && value.socketPath.trim() ? value.socketPath.trim() : undefined,
|
|
26
|
+
threadId: typeof value.threadId === "string" && value.threadId.trim() ? value.threadId.trim() : undefined,
|
|
27
|
+
};
|
|
28
|
+
if (typeof value.cwd === "string" && value.cwd.trim()) normalized.cwd = value.cwd.trim();
|
|
29
|
+
if (typeof value.model === "string" && value.model.trim()) normalized.model = value.model.trim();
|
|
30
|
+
if (typeof value.murmurRoot === "string" && value.murmurRoot.trim()) normalized.murmurRoot = value.murmurRoot.trim();
|
|
31
|
+
if (typeof value.dataDir === "string" && value.dataDir.trim()) normalized.dataDir = value.dataDir.trim();
|
|
32
|
+
if (typeof value.storePath === "string" && value.storePath.trim()) normalized.storePath = value.storePath.trim();
|
|
33
|
+
if (value.relayFinalToMurmur === true) normalized.relayFinalToMurmur = true;
|
|
34
|
+
// Without this the injector's `peer.resume === false` opt-out is unreachable
|
|
35
|
+
// from a real config: normalization used to drop the field entirely.
|
|
36
|
+
if (typeof value.resume === "boolean") normalized.resume = value.resume;
|
|
37
|
+
// Same story for baseInstructions: the channel binding resolver falls back to
|
|
38
|
+
// `peer.baseInstructions`, so dropping it here makes per-peer role instructions
|
|
39
|
+
// impossible to configure — the only remaining lever is `personaId`, which Codex
|
|
40
|
+
// rejects for anything outside its own `none|friendly|pragmatic` enum.
|
|
41
|
+
if (typeof value.baseInstructions === "string" && value.baseInstructions.trim()) {
|
|
42
|
+
normalized.baseInstructions = value.baseInstructions;
|
|
43
|
+
}
|
|
44
|
+
if (Number.isFinite(Number(value.replyTimeoutMs))) normalized.replyTimeoutMs = Number(value.replyTimeoutMs);
|
|
45
|
+
for (const [key, min, max] of [["steer_batch_window_ms", 0, 60000], ["steer_max_per_turn", 1, 100]]) {
|
|
46
|
+
if (value[key] === undefined) continue;
|
|
47
|
+
if (!Number.isInteger(value[key]) || value[key] < min || value[key] > max) throw new Error(`wake-batch-invalid:${key}`);
|
|
48
|
+
normalized[key] = value[key];
|
|
49
|
+
}
|
|
50
|
+
return [agentId, normalized];
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
return {
|
|
54
|
+
enabled: wake.enabled !== false,
|
|
55
|
+
hookTimeoutMs,
|
|
56
|
+
mode: validMode(wake.mode) ? wake.mode : "stateless",
|
|
57
|
+
peers,
|
|
58
|
+
auditHook: typeof wake.auditHook === "string" && wake.auditHook.trim() ? wake.auditHook.trim() : null,
|
|
59
|
+
dedup: {
|
|
60
|
+
cooldownMs: Number.isFinite(Number(dedup.cooldownMs)) ? Number(dedup.cooldownMs) : 300000,
|
|
61
|
+
},
|
|
62
|
+
loopBreaker: {
|
|
63
|
+
maxWakes: Number.isFinite(Number(loopBreaker.maxWakes)) ? Number(loopBreaker.maxWakes) : 5,
|
|
64
|
+
windowMs: Number.isFinite(Number(loopBreaker.windowMs)) ? Number(loopBreaker.windowMs) : 60000,
|
|
65
|
+
},
|
|
66
|
+
// #107 — how many wakes may run at once. Lanes are keyed per peer/conversation, so
|
|
67
|
+
// this bounds parallelism across conversations; within one, order is kept.
|
|
68
|
+
concurrency: Number.isFinite(Number(wake.concurrency)) && Number(wake.concurrency) >= 1 ? Math.floor(Number(wake.concurrency)) : DEFAULT_WAKE_CONCURRENCY,
|
|
69
|
+
// #105 — how long a delivery keeps being retried before it is dead-lettered.
|
|
70
|
+
retry: {
|
|
71
|
+
maxAttempts: Number.isFinite(Number(retry.maxAttempts)) ? Number(retry.maxAttempts) : DEFAULT_WAKE_MAX_ATTEMPTS,
|
|
72
|
+
backoffMs: Number.isFinite(Number(retry.backoffMs)) ? Number(retry.backoffMs) : DEFAULT_WAKE_RETRY_BACKOFF_MS,
|
|
73
|
+
backoffMaxMs: Number.isFinite(Number(retry.backoffMaxMs)) ? Number(retry.backoffMaxMs) : DEFAULT_WAKE_RETRY_BACKOFF_MAX_MS,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const createShellHook = ({ command, timeoutMs = 10000, baseEnv = process.env, log = () => {} }) => {
|
|
79
|
+
if (!command) return null;
|
|
80
|
+
return (payload) => new Promise((resolve, reject) => {
|
|
81
|
+
const env = {
|
|
82
|
+
...baseEnv,
|
|
83
|
+
MURMUR_FROM: payload.from,
|
|
84
|
+
MURMUR_TEXT: payload.text,
|
|
85
|
+
MURMUR_MSG_ID: payload.msgId,
|
|
86
|
+
MURMUR_CONVERSATION_ID: payload.conversationId,
|
|
87
|
+
MURMUR_CHANNEL_ID: payload.channelId || "",
|
|
88
|
+
MURMUR_SENDER_MEMBER_ID: payload.senderMemberId || "",
|
|
89
|
+
MURMUR_ADDRESSEE_MEMBER_ID: payload.addresseeMemberId || "",
|
|
90
|
+
...(payload.env || {}),
|
|
91
|
+
};
|
|
92
|
+
execFile("sh", ["-c", command], { env, timeout: timeoutMs }, (err) => {
|
|
93
|
+
if (err) {
|
|
94
|
+
// execFile's message includes the command and captured output. Persist only
|
|
95
|
+
// a stable failure code; hooks may contain credentials or private content.
|
|
96
|
+
const reason = err.killed ? "timeout-or-killed" : String(err.code ?? err.signal ?? "failed");
|
|
97
|
+
const failure = new Error(`wake-hook-${reason}`);
|
|
98
|
+
// Shell convention: 126/127 means the command cannot execute/is absent.
|
|
99
|
+
// Other exit codes (including 2) can belong to the invoked application.
|
|
100
|
+
failure.retryable = !["ENOENT", "EACCES", "EPERM", 126, 127].includes(err.code);
|
|
101
|
+
log("warn", "wake hook failed", { error: failure.message, msgId: payload.msgId });
|
|
102
|
+
reject(failure);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
resolve();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const createAuditShellHook = ({ command, timeoutMs = 10000, baseEnv = process.env, log = () => {} }) => {
|
|
111
|
+
if (!command) return null;
|
|
112
|
+
return (payload) => new Promise((resolve) => {
|
|
113
|
+
const env = {
|
|
114
|
+
...baseEnv,
|
|
115
|
+
MURMUR_FROM: payload.from,
|
|
116
|
+
MURMUR_TEXT: payload.text,
|
|
117
|
+
MURMUR_MSG_ID: payload.msgId,
|
|
118
|
+
MURMUR_CONVERSATION_ID: payload.conversationId,
|
|
119
|
+
MURMUR_CHANNEL_ID: payload.channelId || "",
|
|
120
|
+
MURMUR_SENDER_MEMBER_ID: payload.senderMemberId || "",
|
|
121
|
+
MURMUR_ADDRESSEE_MEMBER_ID: payload.addresseeMemberId || "",
|
|
122
|
+
...(payload.env || {}),
|
|
123
|
+
};
|
|
124
|
+
execFile("sh", ["-c", command], { env, timeout: timeoutMs }, (err, stdout) => {
|
|
125
|
+
if (err) {
|
|
126
|
+
log("warn", "wake audit hook failed", { error: err.message, msgId: payload.msgId });
|
|
127
|
+
resolve("deny");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const verdict = String(stdout || "").split(/\r?\n/, 1)[0]?.trim();
|
|
131
|
+
resolve(verdict === "deny" || verdict === "require_approval" || verdict === "allow" ? verdict : "deny");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Exponential backoff for wake retries: base·2^(attempt−1), capped. */
|
|
137
|
+
export const wakeRetryBackoffMs = (attempt, baseMs = DEFAULT_WAKE_RETRY_BACKOFF_MS, maxMs = DEFAULT_WAKE_RETRY_BACKOFF_MAX_MS) =>
|
|
138
|
+
Math.min(maxMs, baseMs * 2 ** Math.max(0, Number(attempt || 1) - 1));
|
|
139
|
+
|
|
140
|
+
/** Rebuild the hook payload from a stored inbound row (backlog and retries come from the table). */
|
|
141
|
+
export const payloadFromDeliveryRow = (row) => ({
|
|
142
|
+
from: row.sender,
|
|
143
|
+
text: row.text,
|
|
144
|
+
msgId: row.msgId,
|
|
145
|
+
conversationId: row.conversationId,
|
|
146
|
+
channelId: row.channelId,
|
|
147
|
+
senderMemberId: row.senderMemberId,
|
|
148
|
+
addresseeMemberId: row.addresseeMemberId,
|
|
149
|
+
wakeEligible: row.wakeEligible === undefined ? true : Boolean(row.wakeEligible),
|
|
150
|
+
ts: row.createdAt,
|
|
151
|
+
cursor: Number(row.rowid),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
export class WakeMonitor {
|
|
155
|
+
constructor(options = {}) {
|
|
156
|
+
const wakeConfig = normalizeWakeConfig({ wake: options });
|
|
157
|
+
this.enabled = options.enabled ?? wakeConfig.enabled;
|
|
158
|
+
this.mode = options.mode ?? wakeConfig.mode;
|
|
159
|
+
this.peers = options.peers ?? wakeConfig.peers;
|
|
160
|
+
this.cooldownMs = options.dedup?.cooldownMs ?? wakeConfig.dedup.cooldownMs;
|
|
161
|
+
this.loopBreaker = {
|
|
162
|
+
maxWakes: options.loopBreaker?.maxWakes ?? wakeConfig.loopBreaker.maxWakes,
|
|
163
|
+
windowMs: options.loopBreaker?.windowMs ?? wakeConfig.loopBreaker.windowMs,
|
|
164
|
+
};
|
|
165
|
+
this.hook = options.hook || null;
|
|
166
|
+
this.injector = options.injector || null;
|
|
167
|
+
this.auditHook = options.auditHook || null;
|
|
168
|
+
this.leaseGate = options.leaseGate || null;
|
|
169
|
+
this.notify = options.notify || null;
|
|
170
|
+
this.loadBacklogAfter = options.loadBacklogAfter || null;
|
|
171
|
+
// #105 — durable delivery state (a SQLiteMessageStore or anything with the same
|
|
172
|
+
// claimWake / settleWake / listOpenWakes / wakeCursor / recoverInflightWakes shape).
|
|
173
|
+
// With it, the table is the queue: the cursor is derived from settled rows, a failed
|
|
174
|
+
// wake is retried under the same delivery id, and a restart resumes from where the
|
|
175
|
+
// rows say it stopped. Without it the monitor behaves exactly as before.
|
|
176
|
+
this.deliveries = options.deliveries || null;
|
|
177
|
+
if (Object.entries(this.peers).some(([from]) => this.batchOptions({ from })) &&
|
|
178
|
+
(!this.deliveries?.assignWakeBatch || !this.deliveries?.settleWakeBatch || !this.deliveries?.getWakeBatch)) {
|
|
179
|
+
throw new Error("wake-batching-requires-durable-store");
|
|
180
|
+
}
|
|
181
|
+
this.maxAttempts = Number.isFinite(Number(options.maxAttempts)) ? Number(options.maxAttempts) : wakeConfig.retry.maxAttempts;
|
|
182
|
+
this.retryBackoffMs = Number.isFinite(Number(options.retryBackoffMs)) ? Number(options.retryBackoffMs) : wakeConfig.retry.backoffMs;
|
|
183
|
+
this.retryBackoffMaxMs = Number.isFinite(Number(options.retryBackoffMaxMs)) ? Number(options.retryBackoffMaxMs) : wakeConfig.retry.backoffMaxMs;
|
|
184
|
+
this.recoveredInflight = false;
|
|
185
|
+
this.concurrency = Number.isFinite(Number(options.concurrency)) && Number(options.concurrency) >= 1
|
|
186
|
+
? Math.floor(Number(options.concurrency))
|
|
187
|
+
: wakeConfig.concurrency;
|
|
188
|
+
this.dispatchSignal = null;
|
|
189
|
+
this.now = options.now || (() => Date.now());
|
|
190
|
+
this.log = options.log || (() => {});
|
|
191
|
+
this.seen = new Map();
|
|
192
|
+
this.senderWindows = new Map();
|
|
193
|
+
this.suspendedSenders = new Map();
|
|
194
|
+
this.queue = [];
|
|
195
|
+
this.queuedKeys = new Set();
|
|
196
|
+
this.enqueuedAt = new Map();
|
|
197
|
+
this.processing = false;
|
|
198
|
+
this.cursor = Number.isFinite(Number(options.initialCursor)) ? Number(options.initialCursor) : 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async onInbound(payload) {
|
|
202
|
+
if (!this.enabled) return;
|
|
203
|
+
this.enqueue(payload);
|
|
204
|
+
await this.drain();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
enqueue(payload) {
|
|
208
|
+
if (!payload?.msgId) return;
|
|
209
|
+
const key = this.keyFor(payload);
|
|
210
|
+
if (this.queuedKeys.has(key)) return;
|
|
211
|
+
this.queuedKeys.add(key);
|
|
212
|
+
this.enqueuedAt.set(key, Date.now());
|
|
213
|
+
this.queue.push(payload);
|
|
214
|
+
this.kickDispatcher();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
nowIso() {
|
|
218
|
+
return new Date(this.now()).toISOString();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Wake a drain that is parked on busy lanes: something new is in the queue. */
|
|
222
|
+
kickDispatcher() {
|
|
223
|
+
const signal = this.dispatchSignal;
|
|
224
|
+
this.dispatchSignal = null;
|
|
225
|
+
signal?.resolve();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* #107 — lanes instead of one line. Every queued payload belongs to a lane (see
|
|
230
|
+
* `laneKeyFor`); at most `concurrency` lanes run at once and a lane never runs two
|
|
231
|
+
* payloads together, so a long turn for one peer no longer holds a short question for
|
|
232
|
+
* another, while messages within one conversation keep their order.
|
|
233
|
+
*/
|
|
234
|
+
async runLanes() {
|
|
235
|
+
const active = new Map();
|
|
236
|
+
while (this.queue.length > 0 || active.size > 0) {
|
|
237
|
+
if (this.enabled && active.size < this.concurrency) {
|
|
238
|
+
const index = this.queue.findIndex((payload) => !active.has(this.laneKeyFor(payload)));
|
|
239
|
+
if (index >= 0) {
|
|
240
|
+
const [payload] = this.queue.splice(index, 1);
|
|
241
|
+
this.queuedKeys.delete(this.keyFor(payload));
|
|
242
|
+
const lane = this.laneKeyFor(payload);
|
|
243
|
+
const previousBatch = await this.deliveries?.getWakeBatch?.(payload.msgId);
|
|
244
|
+
if (!previousBatch && !this.batchOptions(payload)) this.enqueuedAt.delete(payload.msgId);
|
|
245
|
+
const run = (previousBatch || this.batchOptions(payload)
|
|
246
|
+
? this.processLaneBatch(payload, previousBatch)
|
|
247
|
+
: this.processPayload(payload))
|
|
248
|
+
.catch((err) => {
|
|
249
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
250
|
+
this.log("error", "WakeMonitor lane crashed", { error: e.message, msgId: payload.msgId, lane });
|
|
251
|
+
})
|
|
252
|
+
.then(() => { active.delete(lane); });
|
|
253
|
+
active.set(lane, run);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (active.size === 0) break;
|
|
258
|
+
const parked = new Promise((resolve) => { this.dispatchSignal = { resolve }; });
|
|
259
|
+
await Promise.race([...active.values(), parked]);
|
|
260
|
+
this.dispatchSignal = null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async drain() {
|
|
265
|
+
// The daemon timer enters here without onInbound(). Disabled means paused:
|
|
266
|
+
// keep rows and attempts intact, including recovery of old in-flight rows.
|
|
267
|
+
if (!this.enabled) return;
|
|
268
|
+
if (this.processing) {
|
|
269
|
+
this.kickDispatcher();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.processing = true;
|
|
273
|
+
try {
|
|
274
|
+
if (this.deliveries && !this.recoveredInflight) {
|
|
275
|
+
this.recoveredInflight = true;
|
|
276
|
+
const recovered = await this.deliveries.recoverInflightWakes?.({ now: this.nowIso() });
|
|
277
|
+
if (recovered) this.log("warn", "WakeMonitor recovered in-flight deliveries from a previous process", { count: recovered });
|
|
278
|
+
this.cursor = await this.deliveries.wakeCursor();
|
|
279
|
+
}
|
|
280
|
+
const offeredThisDrain = new Set();
|
|
281
|
+
while (this.enabled) {
|
|
282
|
+
await this.runLanes();
|
|
283
|
+
if (!this.enabled) break;
|
|
284
|
+
|
|
285
|
+
let backlog;
|
|
286
|
+
if (this.loadBacklogAfter) {
|
|
287
|
+
backlog = await this.loadBacklogAfter(this.cursor);
|
|
288
|
+
} else if (this.deliveries) {
|
|
289
|
+
const rows = await this.deliveries.listOpenWakes({ now: this.nowIso() });
|
|
290
|
+
// A row that came back after being processed in this very pass is not due
|
|
291
|
+
// again (its retry lies in the future); refusing it here keeps the loop finite.
|
|
292
|
+
backlog = rows.filter((row) => !offeredThisDrain.has(row.msgId)).map(payloadFromDeliveryRow);
|
|
293
|
+
for (const row of rows) offeredThisDrain.add(row.msgId);
|
|
294
|
+
}
|
|
295
|
+
if (!Array.isArray(backlog) || backlog.length === 0) break;
|
|
296
|
+
for (const payload of backlog) this.enqueue(payload);
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
this.processing = false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async processPayload(payload, { prepareOnly = false } = {}) {
|
|
304
|
+
const durable = Boolean(this.deliveries);
|
|
305
|
+
let claim = null;
|
|
306
|
+
if (durable) {
|
|
307
|
+
claim = await this.deliveries.claimWake(payload.msgId, { now: this.nowIso() });
|
|
308
|
+
if (!claim.claimed) {
|
|
309
|
+
// Handled, muted, dead-lettered, in flight elsewhere, or not yet due: whichever
|
|
310
|
+
// it is, this copy is not new work. The delivery itself already succeeded.
|
|
311
|
+
this.log("info", "WakeMonitor delivery already claimed", {
|
|
312
|
+
msgId: payload.msgId,
|
|
313
|
+
conversationId: payload.conversationId,
|
|
314
|
+
status: claim.status ?? null,
|
|
315
|
+
});
|
|
316
|
+
await this.advanceCursor(payload);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
payload.attempt = claim.attempts;
|
|
320
|
+
}
|
|
321
|
+
const settle = async (status, extra = {}) => {
|
|
322
|
+
if (durable) await this.deliveries.settleWake(payload.msgId, { status, ...extra, now: this.nowIso() });
|
|
323
|
+
await this.advanceCursor(payload);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// `wakeEligible` is a local receive-time authorization decision persisted by
|
|
327
|
+
// the daemon. Check it at the shared effect boundary so delayed backlog rows
|
|
328
|
+
// cannot bypass the immediate channel-addressing gate. Missing means legacy.
|
|
329
|
+
if (payload.wakeEligible === false) {
|
|
330
|
+
await settle("muted", { error: "receive-time-ineligible" });
|
|
331
|
+
this.log("info", "WakeMonitor policy mute", {
|
|
332
|
+
msgId: payload.msgId,
|
|
333
|
+
conversationId: payload.conversationId,
|
|
334
|
+
reason: "receive-time-ineligible",
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const key = this.keyFor(payload);
|
|
339
|
+
const now = this.now();
|
|
340
|
+
this.pruneSeen(now);
|
|
341
|
+
if (!durable) {
|
|
342
|
+
// Without durable state the in-memory cooldown is the only duplicate guard. With
|
|
343
|
+
// it, the claim above already refused every copy that is not a due retry.
|
|
344
|
+
const lastWakeAt = this.seen.get(key);
|
|
345
|
+
if (lastWakeAt !== undefined && now - lastWakeAt < this.cooldownMs) {
|
|
346
|
+
await this.advanceCursor(payload);
|
|
347
|
+
this.log("info", "WakeMonitor duplicate dropped", { msgId: payload.msgId, conversationId: payload.conversationId });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.seen.set(key, now);
|
|
353
|
+
if (!prepareOnly && await this.isLoopBreakerBlocked(payload, now)) {
|
|
354
|
+
await settle("muted", { error: "loop-breaker" });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const verdict = await this.audit(payload);
|
|
359
|
+
if (verdict === "deny") {
|
|
360
|
+
this.log("warn", "WakeMonitor audit denied wake", { msgId: payload.msgId, conversationId: payload.conversationId, from: payload.from });
|
|
361
|
+
await settle("muted", { error: "audit-deny" });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (verdict === "require_approval") {
|
|
365
|
+
await this.notify?.(payload, "require_approval");
|
|
366
|
+
this.log("warn", "WakeMonitor audit requires approval", { msgId: payload.msgId, conversationId: payload.conversationId, from: payload.from });
|
|
367
|
+
await settle("muted", { error: "audit-require-approval" });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Scoped-channels lease gate (#82): the native daemon wake is a fallback owner.
|
|
372
|
+
// If a live chat session already owns this conversation, mute the native wake so it
|
|
373
|
+
// does not spawn a competing thread; otherwise claim and route as the cold-wake owner.
|
|
374
|
+
if (this.leaseGate) {
|
|
375
|
+
let decision;
|
|
376
|
+
try {
|
|
377
|
+
decision = await this.leaseGate(payload, this.peerFor(payload));
|
|
378
|
+
} catch (err) {
|
|
379
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
380
|
+
decision = { allow: false, reason: `lease-gate-error:${e.message}` };
|
|
381
|
+
}
|
|
382
|
+
if (!decision || decision.allow !== true) {
|
|
383
|
+
this.log("info", "WakeMonitor lease mute (non-owner)", {
|
|
384
|
+
msgId: payload.msgId,
|
|
385
|
+
conversationId: payload.conversationId,
|
|
386
|
+
ownerSessionId: decision?.ownerSessionId ?? null,
|
|
387
|
+
reason: decision?.reason ?? "non-owner",
|
|
388
|
+
});
|
|
389
|
+
await settle("muted", { error: `lease:${decision?.reason ?? "non-owner"}` });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
payload.leaseToken = decision.token ?? null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const execute = async (effect, settleEffect = settle) => {
|
|
396
|
+
let result;
|
|
397
|
+
try {
|
|
398
|
+
const peer = this.peerFor(payload);
|
|
399
|
+
if (effect) {
|
|
400
|
+
result = await effect();
|
|
401
|
+
} else if (peer.mode === "codex_app_server") {
|
|
402
|
+
if (!this.injector) throw new Error(`wake-native-injector-missing:${payload.from}`);
|
|
403
|
+
result = await this.injector(payload, peer);
|
|
404
|
+
this.log("info", "WakeMonitor native wake completed", { msgId: payload.msgId, conversationId: payload.conversationId, mode: peer.mode });
|
|
405
|
+
} else if (this.hook) {
|
|
406
|
+
result = await this.hook(payload);
|
|
407
|
+
this.log("info", "WakeMonitor hook completed", { msgId: payload.msgId, conversationId: payload.conversationId });
|
|
408
|
+
} else {
|
|
409
|
+
// Nothing was woken. There is no native injector for this peer and no onReceive
|
|
410
|
+
// hook, so the message reached the store and stopped there.
|
|
411
|
+
//
|
|
412
|
+
// This branch used to log "WakeMonitor hook completed" as well, which read as a
|
|
413
|
+
// delivered wake. On 2026-09-15 three messages landed on an agent whose config had
|
|
414
|
+
// neither `wake` nor `onReceive`; the log said "hook completed" four milliseconds
|
|
415
|
+
// after "Message received" for each of them, and an operator spent an hour looking
|
|
416
|
+
// for a broken responder that had never been configured. A log line that cannot
|
|
417
|
+
// distinguish "woke somebody" from "nobody to wake" is worse than no line at all.
|
|
418
|
+
this.log("warn", "WakeMonitor: hook not configured, message stored only", {
|
|
419
|
+
msgId: payload.msgId,
|
|
420
|
+
conversationId: payload.conversationId,
|
|
421
|
+
from: payload.from,
|
|
422
|
+
});
|
|
423
|
+
await settleEffect("stored-only", { error: "wake-no-responder" });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
} catch (err) {
|
|
427
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
428
|
+
this.log("warn", "WakeMonitor hook error", { error: e.message, msgId: payload.msgId, attempt: claim?.attempts ?? null });
|
|
429
|
+
if (!durable) {
|
|
430
|
+
// Legacy behaviour: the failure is logged and the cursor moves on.
|
|
431
|
+
await this.advanceCursor(payload);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const attempts = claim.attempts;
|
|
435
|
+
const retryable = e.retryable !== false && attempts < this.maxAttempts;
|
|
436
|
+
if (retryable) {
|
|
437
|
+
const delayMs = wakeRetryBackoffMs(attempts, this.retryBackoffMs, this.retryBackoffMaxMs);
|
|
438
|
+
await settleEffect("failed", { error: e.message, nextAttemptAt: new Date(this.now() + delayMs).toISOString() });
|
|
439
|
+
this.log("warn", "WakeMonitor wake scheduled for retry", { msgId: payload.msgId, attempt: attempts, retryInMs: delayMs });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
// Out of attempts, or the error says a retry cannot help: settle visibly. The
|
|
443
|
+
// delivery is done with — the cursor may pass it — but somebody gets told.
|
|
444
|
+
await settleEffect("dlq", { error: e.message });
|
|
445
|
+
await this.notify?.(payload, "wake-dlq");
|
|
446
|
+
this.log("error", "WakeMonitor wake dead-lettered", { msgId: payload.msgId, attempts, error: e.message, retryable: e.retryable !== false });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const replyMsgId = result?.replyMsgId ?? result?.relay?.msgId ?? undefined;
|
|
450
|
+
await settleEffect("handled", replyMsgId ? { replyMsgId } : {});
|
|
451
|
+
};
|
|
452
|
+
if (prepareOnly) return execute;
|
|
453
|
+
await execute();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
batchOptions(payload) {
|
|
457
|
+
const peer = this.peerFor(payload);
|
|
458
|
+
if (peer.mode !== "codex_app_server") return null;
|
|
459
|
+
if (peer.steer_batch_window_ms === undefined && peer.steer_max_per_turn === undefined) return null;
|
|
460
|
+
return { windowMs: peer.steer_batch_window_ms ?? 0, limit: peer.steer_max_per_turn ?? 20 };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
batchKeyFor(payload) {
|
|
464
|
+
return JSON.stringify([payload.from, payload.conversationId, payload.channelId, payload.senderMemberId, payload.addresseeMemberId]);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async processLaneBatch(first, previousBatch) {
|
|
468
|
+
if (!this.deliveries?.assignWakeBatch || !this.deliveries?.settleWakeBatch) throw new Error("wake-batching-requires-durable-store");
|
|
469
|
+
const options = this.batchOptions(first) ?? { windowMs: 0, limit: 100 };
|
|
470
|
+
const key = this.batchKeyFor(first);
|
|
471
|
+
const lane = this.laneKeyFor(first);
|
|
472
|
+
let payloads;
|
|
473
|
+
if (previousBatch) {
|
|
474
|
+
payloads = previousBatch.messages.map(payloadFromDeliveryRow);
|
|
475
|
+
const ids = new Set(payloads.map((p) => p.msgId));
|
|
476
|
+
this.queue = this.queue.filter((p) => !ids.has(p.msgId));
|
|
477
|
+
} else {
|
|
478
|
+
// Messages remain pending in SQLite throughout the quiet window. A stopped
|
|
479
|
+
// session therefore leaves a recoverable queue, never an in-memory-only batch.
|
|
480
|
+
while (options.windowMs > 0) {
|
|
481
|
+
const same = this.queue.filter((p) => this.batchKeyFor(p) === key);
|
|
482
|
+
if (same.length + 1 >= options.limit) break;
|
|
483
|
+
const latest = Math.max(this.enqueuedAt.get(first.msgId) ?? 0, ...same.map((p) => this.enqueuedAt.get(p.msgId) ?? 0));
|
|
484
|
+
const remaining = latest + options.windowMs - Date.now();
|
|
485
|
+
if (remaining <= 0) break;
|
|
486
|
+
await delay(remaining);
|
|
487
|
+
}
|
|
488
|
+
payloads = [first];
|
|
489
|
+
for (let i = 0; i < this.queue.length && payloads.length < options.limit;) {
|
|
490
|
+
const candidate = this.queue[i];
|
|
491
|
+
if (this.laneKeyFor(candidate) !== lane) { i += 1; continue; }
|
|
492
|
+
// Never step over another conversation/member in the same static thread.
|
|
493
|
+
if (this.batchKeyFor(candidate) !== key || await this.deliveries.getWakeBatch(candidate.msgId)) break;
|
|
494
|
+
payloads.push(...this.queue.splice(i, 1));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
for (const payload of payloads) {
|
|
498
|
+
this.queuedKeys.delete(this.keyFor(payload));
|
|
499
|
+
this.enqueuedAt.delete(this.keyFor(payload));
|
|
500
|
+
}
|
|
501
|
+
const ready = [];
|
|
502
|
+
for (const payload of payloads) {
|
|
503
|
+
const run = await this.processPayload(payload, { prepareOnly: true });
|
|
504
|
+
if (run) ready.push({ payload, run });
|
|
505
|
+
}
|
|
506
|
+
if (ready.length === 0) return;
|
|
507
|
+
if (previousBatch && ready.length !== payloads.length) {
|
|
508
|
+
// A previously grouped member is now muted/not claimable. Do not replay its
|
|
509
|
+
// content by borrowing another member's permission or silently regroup IDs.
|
|
510
|
+
await this.deliveries.settleWakeBatch(ready.map(({ payload }) => ({ msgId: payload.msgId, input: { status: "muted", error: "batch-member-ineligible", now: this.nowIso() } })));
|
|
511
|
+
await this.advanceCursor(first);
|
|
512
|
+
this.log("warn", "WakeMonitor batch blocked by member eligibility", { batchId: previousBatch.batchId });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// The breaker limits wake effects, so a batch counts once, not once per letter.
|
|
516
|
+
if (await this.isLoopBreakerBlocked(ready[0].payload, this.now())) {
|
|
517
|
+
await this.deliveries.settleWakeBatch(ready.map(({ payload }) => ({ msgId: payload.msgId, input: { status: "muted", error: "loop-breaker", now: this.nowIso() } })));
|
|
518
|
+
await this.advanceCursor(first);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (ready.length === 1 && !previousBatch) { await ready[0].run(); return; }
|
|
522
|
+
let batch;
|
|
523
|
+
try {
|
|
524
|
+
batch = previousBatch ?? await this.deliveries.assignWakeBatch(ready.map(({ payload }) => payload.msgId));
|
|
525
|
+
} catch (error) {
|
|
526
|
+
await this.deliveries.settleWakeBatch(ready.map(({ payload }) => ({ msgId: payload.msgId, input: { status: "failed", error: String(error), nextAttemptAt: new Date(this.now() + this.retryBackoffMs).toISOString(), now: this.nowIso() } })));
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
const combined = {
|
|
530
|
+
...ready[0].payload,
|
|
531
|
+
leaseToken: ready.at(-1).payload.leaseToken,
|
|
532
|
+
msgId: batch.batchId,
|
|
533
|
+
batchMsgIds: ready.map(({ payload }) => payload.msgId),
|
|
534
|
+
text: ready.map(({ payload }) => `[Murmur message ${payload.msgId}]\n${payload.text}`).join("\n\n"),
|
|
535
|
+
};
|
|
536
|
+
this.log("info", "WakeMonitor batch dispatch", { msgId: batch.batchId, msgIds: combined.batchMsgIds, count: ready.length, remaining: this.queue.filter((p) => this.laneKeyFor(p) === lane).length });
|
|
537
|
+
// All pre-effect audit/eligibility/lease gates ran for every individual member.
|
|
538
|
+
let outcome;
|
|
539
|
+
try {
|
|
540
|
+
if (!this.injector) throw new Error(`wake-native-injector-missing:${first.from}`);
|
|
541
|
+
const result = await this.injector(combined, this.peerFor(first));
|
|
542
|
+
const replyMsgId = result?.replyMsgId ?? result?.relay?.msgId;
|
|
543
|
+
outcome = { status: "handled", ...(replyMsgId ? { replyMsgId } : {}), now: this.nowIso() };
|
|
544
|
+
} catch (err) {
|
|
545
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
546
|
+
// One effect has one retry deadline and terminal decision. Per-member clocks
|
|
547
|
+
// or prior attempts must not leave half a persisted batch ineligible to retry.
|
|
548
|
+
const attempts = Math.max(...ready.map(({ payload }) => payload.attempt));
|
|
549
|
+
const retryable = error.retryable !== false && attempts < this.maxAttempts;
|
|
550
|
+
const now = this.now();
|
|
551
|
+
outcome = { status: retryable ? "failed" : "dlq", error: error.message, now: new Date(now).toISOString(),
|
|
552
|
+
...(retryable ? { nextAttemptAt: new Date(now + wakeRetryBackoffMs(attempts, this.retryBackoffMs, this.retryBackoffMaxMs)).toISOString() } : {}) };
|
|
553
|
+
this.log("warn", "WakeMonitor batch effect failed", { batchId: batch.batchId, attempts, status: outcome.status, error: error.message });
|
|
554
|
+
}
|
|
555
|
+
await this.deliveries.settleWakeBatch(ready.map(({ payload }) => ({ msgId: payload.msgId, input: outcome })));
|
|
556
|
+
await this.advanceCursor(first);
|
|
557
|
+
if (outcome.status === "dlq") for (const { payload } of ready) await this.notify?.(payload, "wake-dlq");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
pruneSeen(now = this.now()) {
|
|
561
|
+
for (const [key, wokeAt] of this.seen.entries()) {
|
|
562
|
+
if (now - wokeAt >= this.cooldownMs) this.seen.delete(key);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async isLoopBreakerBlocked(payload, now) {
|
|
567
|
+
const sender = payload.from || "unknown";
|
|
568
|
+
const suspendedUntil = this.suspendedSenders.get(sender);
|
|
569
|
+
if (suspendedUntil !== undefined) {
|
|
570
|
+
if (now < suspendedUntil) {
|
|
571
|
+
this.suspendedSenders.set(sender, now + this.loopBreaker.windowMs);
|
|
572
|
+
await this.notify?.(payload, "loop-breaker");
|
|
573
|
+
this.log("warn", "WakeMonitor loop-breaker suspended wake", { sender, msgId: payload.msgId, suspendedUntil: this.suspendedSenders.get(sender) });
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
this.suspendedSenders.delete(sender);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const since = now - this.loopBreaker.windowMs;
|
|
580
|
+
const window = (this.senderWindows.get(sender) || []).filter((ts) => ts > since);
|
|
581
|
+
if (window.length >= this.loopBreaker.maxWakes) {
|
|
582
|
+
this.suspendedSenders.set(sender, now + this.loopBreaker.windowMs);
|
|
583
|
+
this.senderWindows.set(sender, window);
|
|
584
|
+
await this.notify?.(payload, "loop-breaker");
|
|
585
|
+
this.log("warn", "WakeMonitor loop-breaker tripped", { sender, count: window.length + 1, msgId: payload.msgId });
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
window.push(now);
|
|
590
|
+
this.senderWindows.set(sender, window);
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async audit(payload) {
|
|
595
|
+
if (!this.auditHook) return "allow";
|
|
596
|
+
const verdict = await this.auditHook(payload);
|
|
597
|
+
return verdict === "allow" || verdict === "require_approval" || verdict === "deny" ? verdict : "deny";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async advanceCursor(payload) {
|
|
601
|
+
if (this.deliveries) {
|
|
602
|
+
// The cursor is not a counter we bump: it is the highest contiguous settled
|
|
603
|
+
// delivery, read back from the rows. A gap holds it; nothing skips it.
|
|
604
|
+
this.cursor = await this.deliveries.wakeCursor();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const cursor = Number(payload?.cursor);
|
|
608
|
+
if (Number.isFinite(cursor) && cursor > this.cursor) this.cursor = cursor;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
keyFor(payload) {
|
|
612
|
+
return payload.msgId;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Which payloads must never run together. A Codex peer pinned to one static thread
|
|
617
|
+
* takes one turn at a time whatever the conversation; everything else is serialised
|
|
618
|
+
* per (peer, conversation), which is also how #108 scopes Codex threads.
|
|
619
|
+
*/
|
|
620
|
+
laneKeyFor(payload) {
|
|
621
|
+
const peer = this.peerFor(payload);
|
|
622
|
+
if (peer.mode === "codex_app_server" && peer.threadId) return `peer:${payload.from}`;
|
|
623
|
+
return `conv:${payload.from}|${payload.conversationId ?? ""}`;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Can this monitor wake anybody at all?
|
|
628
|
+
*
|
|
629
|
+
* `configured: false` means every inbound message will be stored and nothing else —
|
|
630
|
+
* the zero-responder state that reads like a working contour until somebody waits
|
|
631
|
+
* half an hour for a reply. Surfaced by the daemon on startup so it is visible
|
|
632
|
+
* before the first message arrives, not only in the per-message log afterwards.
|
|
633
|
+
*/
|
|
634
|
+
responderStatus() {
|
|
635
|
+
const peers = ensureObject(this.peers);
|
|
636
|
+
const nativePeers = Object.keys(peers).filter(
|
|
637
|
+
(agentId) => (validMode(peers[agentId]?.mode) ? peers[agentId].mode : this.mode) === "codex_app_server",
|
|
638
|
+
);
|
|
639
|
+
const hook = Boolean(this.hook);
|
|
640
|
+
// A native peer without an injector wired in is not a responder, it is an error waiting
|
|
641
|
+
// to be thrown on the first message — do not count it as configured.
|
|
642
|
+
const native = Boolean(this.injector) && (this.mode === "codex_app_server" || nativePeers.length > 0);
|
|
643
|
+
return { configured: hook || native, hook, native, nativePeers };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
peerFor(payload) {
|
|
647
|
+
const peer = ensureObject(this.peers?.[payload.from]);
|
|
648
|
+
return {
|
|
649
|
+
...peer,
|
|
650
|
+
mode: validMode(peer.mode) ? peer.mode : this.mode,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|