jeo-code 0.11.2 → 0.11.3

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.
@@ -0,0 +1,443 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { DiscordApi, DiscordApiError, type DiscordGatewayInfo } from "./discord-api";
4
+ import { notifySessionsDir } from "./paths";
5
+ import { isPidAlive } from "./daemon-control";
6
+ import { parseSessionEndpoint } from "./session-discovery";
7
+
8
+ export interface DiscordDaemonConfig {
9
+ botToken: string;
10
+ channelId: string;
11
+ allowedUserIds: string[];
12
+ }
13
+
14
+ export interface DiscordDaemonDependencies {
15
+ api?: Pick<DiscordApi, "getMe" | "getChannel" | "getGatewayBot" | "sendMessage" | "stop">;
16
+ WebSocketImpl?: typeof WebSocket;
17
+ GatewayWebSocketImpl?: typeof WebSocket;
18
+ readdir?: (dir: string) => Promise<string[]>;
19
+ readFile?: (file: string) => Promise<string>;
20
+ isPidAlive?: (pid: number) => boolean;
21
+ now?: () => number;
22
+ random?: () => number;
23
+ setTimeout?: (callback: () => void, ms: number) => Timer;
24
+ clearTimeout?: (timer: Timer) => void;
25
+ scanIntervalMs?: number;
26
+ ackTimeoutMs?: number;
27
+ onError?: (message: string) => void;
28
+ onFatal?: (message: string) => void;
29
+ }
30
+
31
+ interface AgentState { id: string; status: string; role: string; task: string; result?: string }
32
+ interface DiscordSession {
33
+ sessionId: string;
34
+ cwd: string;
35
+ pid: number;
36
+ ws: WebSocket;
37
+ verified: boolean;
38
+ records: AgentState[];
39
+ }
40
+
41
+ function object(value: unknown): value is Record<string, unknown> {
42
+ return !!value && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+
45
+ /** Only Discord's official gateway hosts may receive the bot token. */
46
+ function gatewayUrl(raw: string): string {
47
+ const url = new URL(raw);
48
+ if (url.protocol !== "wss:" || !(url.hostname === "gateway.discord.gg" || /^gateway-[a-z0-9-]+\.discord\.gg$/.test(url.hostname)) ||
49
+ url.username || url.password || url.port || url.pathname !== "/" || url.hash) throw new Error("Invalid Discord gateway URL");
50
+ url.search = "?v=10&encoding=json";
51
+ return url.href;
52
+ }
53
+
54
+ const HELP = "Discord controls:\n/sessions\n/subagents\n/send <unique-session> <text>\n/steer <unique-session> <subagent-id> <text>\n/cancel <unique-session> <subagent-id>\nReply to a session notification to send that session text. Other slash commands are not executed.";
55
+
56
+ /** One provider adapter inside the existing daemon; local commands use SessionNotifyEndpoint only. */
57
+ export class DiscordDaemon {
58
+ readonly sessions = new Map<string, DiscordSession>();
59
+ private readonly api: NonNullable<DiscordDaemonDependencies["api"]>;
60
+ private readonly timers = new Set<Timer>();
61
+ private readonly pending = new Map<string, { sessionId: string; finish: (ok: boolean) => void }>();
62
+ private readonly replies = new Map<string, { sessionId: string; at: number }>();
63
+ private readonly seen = new Set<string>();
64
+ private readonly allowed: Set<string>;
65
+ private stopped = true;
66
+ private started = false;
67
+ private scanning = false;
68
+ private gateway: WebSocket | undefined;
69
+ private gatewayInfo: DiscordGatewayInfo | undefined;
70
+ private heartbeatTimer: Timer | undefined;
71
+ private handshakeTimer: Timer | undefined;
72
+ private reconnectTimer: Timer | undefined;
73
+ private acked = true;
74
+ private sequence: number | null = null;
75
+ private gatewaySession: string | undefined;
76
+ private resumeUrl: string | undefined;
77
+ private reconnects = 0;
78
+ private identifyAfter = 0;
79
+ private gatewayLimitResetAt = 0;
80
+ private botId = "";
81
+ private intents = 0;
82
+ private outgoing = Promise.resolve();
83
+ private queued = 0;
84
+
85
+ constructor(private readonly config: DiscordDaemonConfig, private readonly deps: DiscordDaemonDependencies = {}) {
86
+ if (!config.botToken.trim() || !/^\d{1,20}$/.test(config.channelId) || !Array.isArray(config.allowedUserIds) ||
87
+ !config.allowedUserIds.length || config.allowedUserIds.some(id => typeof id !== "string" || !/^\d{1,20}$/.test(id))) {
88
+ throw new Error("Discord requires a bot token, channel ID and explicit allowed user IDs");
89
+ }
90
+ this.allowed = new Set(config.allowedUserIds);
91
+ this.api = deps.api ?? new DiscordApi(config.botToken);
92
+ }
93
+
94
+ private later(callback: () => void, ms: number): Timer {
95
+ const timer = (this.deps.setTimeout ?? setTimeout)(() => {
96
+ this.timers.delete(timer);
97
+ if (!this.stopped) callback();
98
+ }, ms);
99
+ this.timers.add(timer);
100
+ return timer;
101
+ }
102
+
103
+ private clear(timer: Timer | undefined): void {
104
+ if (timer === undefined) return;
105
+ (this.deps.clearTimeout ?? clearTimeout)(timer);
106
+ this.timers.delete(timer);
107
+ }
108
+
109
+ async start(): Promise<void> {
110
+ if (this.started) {
111
+ if (this.stopped) throw new Error("Discord daemon instance has stopped");
112
+ return;
113
+ }
114
+ this.started = true;
115
+ this.stopped = false;
116
+ try {
117
+ const me = await this.api.getMe();
118
+ if (this.stopped) return;
119
+ if (!me.bot) throw new Error("Discord bot identity required");
120
+ this.botId = me.id;
121
+ const channel = await this.api.getChannel(this.config.channelId);
122
+ if (this.stopped) return;
123
+ if (![0, 1, 5, 10, 11, 12].includes(channel.type)) throw new Error("Unsupported Discord channel type");
124
+ this.intents = channel.type === 1 ? 1 << 12 : (1 << 9) | (1 << 15);
125
+ this.gatewayInfo = await this.api.getGatewayBot();
126
+ if (this.stopped) return;
127
+ gatewayUrl(this.gatewayInfo.url);
128
+ this.gatewayLimitResetAt = (this.deps.now ?? Date.now)() + this.gatewayInfo.session_start_limit.reset_after;
129
+ if (this.gatewayInfo.session_start_limit.remaining < 1) throw new Error("Discord session start limit exhausted; retry after reset");
130
+ this.connectGateway();
131
+ await this.scanSessions();
132
+ if (!this.stopped) this.scheduleScan();
133
+ } catch (error) {
134
+ this.stop();
135
+ if (error instanceof DiscordApiError) throw error;
136
+ throw new Error("Discord startup failed: verify channel, bot permissions and gateway configuration");
137
+ }
138
+ }
139
+
140
+ stop(): void {
141
+ this.stopped = true;
142
+ for (const timer of this.timers) (this.deps.clearTimeout ?? clearTimeout)(timer);
143
+ this.timers.clear();
144
+ const gateway = this.gateway;
145
+ this.gateway = undefined;
146
+ try { gateway?.close(1000); } catch {}
147
+ for (const session of this.sessions.values()) { try { session.ws.close(); } catch {} }
148
+ this.sessions.clear();
149
+ for (const request of this.pending.values()) request.finish(false);
150
+ this.pending.clear();
151
+ this.replies.clear();
152
+ this.seen.clear();
153
+ this.api.stop();
154
+ }
155
+
156
+ private fail(message: string): void {
157
+ if (this.stopped) return;
158
+ this.stop();
159
+ try { this.deps.onError?.(message); }
160
+ finally { this.deps.onFatal?.(message); }
161
+ }
162
+
163
+ private connectGateway(): void {
164
+ if (this.stopped || !this.gatewayInfo) return;
165
+ this.reconnectTimer = undefined;
166
+ try {
167
+ const WS = this.deps.GatewayWebSocketImpl ?? this.deps.WebSocketImpl ?? WebSocket;
168
+ const ws = new WS(gatewayUrl(this.resumeUrl ?? this.gatewayInfo.url));
169
+ this.gateway = ws;
170
+ this.acked = true;
171
+ let hello = false;
172
+ this.handshakeTimer = this.later(() => this.reconnect(), 30_000);
173
+ ws.onmessage = async event => {
174
+ if (this.stopped || this.gateway !== ws || typeof event.data !== "string" || event.data.length > 1_048_576) return;
175
+ let payload: unknown;
176
+ try { payload = JSON.parse(event.data); } catch { return; }
177
+ if (!object(payload) || !Number.isInteger(payload.op)) return;
178
+ if (payload.op === 10) {
179
+ if (hello || !object(payload.d) || typeof payload.d.heartbeat_interval !== "number" ||
180
+ !Number.isFinite(payload.d.heartbeat_interval) || payload.d.heartbeat_interval < 1_000 || payload.d.heartbeat_interval > 300_000) return;
181
+ hello = true;
182
+ const interval = payload.d.heartbeat_interval;
183
+ const beat = () => {
184
+ if (this.gateway !== ws) return;
185
+ if (!this.acked) { this.reconnect(); return; }
186
+ this.acked = false;
187
+ this.gatewaySend({ op: 1, d: this.sequence });
188
+ if (this.gateway === ws) this.heartbeatTimer = this.later(beat, interval);
189
+ };
190
+ this.heartbeatTimer = this.later(beat, interval * (this.deps.random ?? Math.random)());
191
+ if (this.gatewaySession && this.sequence !== null) {
192
+ this.gatewaySend({ op: 6, d: { token: this.config.botToken, session_id: this.gatewaySession, seq: this.sequence } });
193
+ } else {
194
+ const identify = async () => {
195
+ if (this.gateway !== ws || !this.gatewayInfo) return;
196
+ if ((this.deps.now ?? Date.now)() >= this.gatewayLimitResetAt) {
197
+ let info: DiscordGatewayInfo;
198
+ try { info = await this.api.getGatewayBot(); } catch {
199
+ if (!this.stopped && this.gateway === ws) this.fail("Discord gateway budget refresh failed");
200
+ return;
201
+ }
202
+ if (this.stopped || this.gateway !== ws) return;
203
+ this.gatewayInfo = info;
204
+ this.gatewayLimitResetAt = (this.deps.now ?? Date.now)() + info.session_start_limit.reset_after;
205
+ }
206
+ if (this.gatewayInfo.session_start_limit.remaining < 1) { this.fail("Discord session start limit exhausted"); return; }
207
+ this.gatewayInfo.session_start_limit.remaining--;
208
+ this.identifyAfter = (this.deps.now ?? Date.now)() + 5_000;
209
+ this.gatewaySend({ op: 2, d: { token: this.config.botToken, intents: this.intents,
210
+ properties: { os: process.platform, browser: "jeo", device: "jeo" } } });
211
+ };
212
+ const wait = this.identifyAfter - (this.deps.now ?? Date.now)();
213
+ if (wait > 0) this.later(() => { void identify(); }, wait); else await identify();
214
+ }
215
+ } else if (payload.op === 11) {
216
+ this.acked = true;
217
+ } else if (payload.op === 1) {
218
+ this.acked = false;
219
+ this.gatewaySend({ op: 1, d: this.sequence });
220
+ } else if (payload.op === 7) {
221
+ this.reconnect();
222
+ } else if (payload.op === 9 && typeof payload.d === "boolean") {
223
+ if (!payload.d) { this.gatewaySession = undefined; this.resumeUrl = undefined; this.sequence = null; }
224
+ this.reconnect();
225
+ } else if (payload.op === 0 && hello && typeof payload.s === "number" && Number.isSafeInteger(payload.s) && payload.s >= 0 && typeof payload.t === "string") {
226
+ if (this.sequence !== null && payload.s <= this.sequence) return;
227
+ this.sequence = payload.s;
228
+ if (payload.t === "READY") {
229
+ if (!object(payload.d) || typeof payload.d.session_id !== "string" || !payload.d.session_id || typeof payload.d.resume_gateway_url !== "string") { this.reconnect(); return; }
230
+ try { this.resumeUrl = gatewayUrl(payload.d.resume_gateway_url); } catch { this.fail("Invalid Discord resume gateway"); return; }
231
+ this.gatewaySession = payload.d.session_id;
232
+ this.reconnects = 0;
233
+ this.clear(this.handshakeTimer);
234
+ } else if (payload.t === "RESUMED") {
235
+ this.reconnects = 0;
236
+ this.clear(this.handshakeTimer);
237
+ } else if (payload.t === "MESSAGE_CREATE" && this.gatewaySession) {
238
+ await this.handleMessage(payload.d).catch(() => this.deps.onError?.("Discord command relay failed"));
239
+ await this.outgoing;
240
+ }
241
+ }
242
+ };
243
+ ws.onclose = event => {
244
+ if (this.stopped || this.gateway !== ws) return;
245
+ if ([4004, 4010, 4011, 4012, 4013, 4014].includes(event.code)) { this.fail(`Discord gateway stopped (${event.code}); verify bot token and intents`); return; }
246
+ if ([4007, 4009].includes(event.code)) { this.gatewaySession = undefined; this.resumeUrl = undefined; this.sequence = null; }
247
+ this.reconnect();
248
+ };
249
+ ws.onerror = () => { if (this.gateway === ws) this.reconnect(); };
250
+ } catch { this.reconnect(); }
251
+ }
252
+
253
+ private gatewaySend(payload: unknown): void {
254
+ try {
255
+ if (this.gateway?.readyState !== 1) { this.reconnect(); return; }
256
+ this.gateway.send(JSON.stringify(payload));
257
+ } catch { this.reconnect(); }
258
+ }
259
+
260
+ private reconnect(): void {
261
+ if (this.stopped || this.reconnectTimer !== undefined) return;
262
+ this.clear(this.heartbeatTimer);
263
+ this.clear(this.handshakeTimer);
264
+ const ws = this.gateway;
265
+ this.gateway = undefined;
266
+ try { ws?.close(4000); } catch {}
267
+ if (++this.reconnects > 8) { this.fail("Discord gateway reconnect limit reached"); return; }
268
+ const wait = Math.min(1_000 * 2 ** (this.reconnects - 1), 30_000);
269
+ this.reconnectTimer = this.later(() => this.connectGateway(), wait);
270
+ }
271
+
272
+ private scheduleScan(): void {
273
+ this.later(() => { void this.scanSessions().finally(() => { if (!this.stopped) this.scheduleScan(); }); }, this.deps.scanIntervalMs ?? 3_000);
274
+ }
275
+
276
+ async scanSessions(): Promise<void> {
277
+ if (this.stopped || this.scanning) return;
278
+ this.scanning = true;
279
+ try {
280
+ const dir = notifySessionsDir();
281
+ const files = await (this.deps.readdir ?? (p => fs.readdir(p)))(dir).catch(() => []);
282
+ for (const file of files) {
283
+ if (this.stopped) return;
284
+ if (!/^[a-zA-Z0-9_-]+\.json$/.test(file)) continue;
285
+ const id = file.slice(0, -5);
286
+ if (this.sessions.has(id)) continue;
287
+ const raw = await (this.deps.readFile ?? (p => fs.readFile(p, "utf8")))(path.join(dir, file)).catch(() => "");
288
+ if (this.stopped) return;
289
+ const info = parseSessionEndpoint(raw);
290
+ if (!info || !(this.deps.isPidAlive ?? isPidAlive)(info.pid)) continue;
291
+ const url = new URL(info.url);
292
+ url.searchParams.set("token", info.token);
293
+ try {
294
+ const WS = this.deps.WebSocketImpl ?? WebSocket;
295
+ const ws = new WS(url.href);
296
+ const session: DiscordSession = { sessionId: id, cwd: info.cwd, pid: info.pid, ws, verified: false, records: [] };
297
+ this.sessions.set(id, session);
298
+ const timeout = this.later(() => { if (!session.verified) { this.disconnectSession(session); try { ws.close(); } catch {} } }, 10_000);
299
+ ws.onmessage = event => {
300
+ if (this.stopped || this.sessions.get(id) !== session || typeof event.data !== "string" || event.data.length > 1_048_576) return;
301
+ let frame: unknown;
302
+ try { frame = JSON.parse(event.data); } catch { return; }
303
+ if (!object(frame)) return;
304
+ if (!session.verified) {
305
+ if (frame.type !== "snapshot" || frame.sessionId !== id || frame.pid !== info.pid) {
306
+ this.disconnectSession(session); try { ws.close(); } catch {} return;
307
+ }
308
+ session.verified = true;
309
+ this.clear(timeout);
310
+ }
311
+ this.onSessionFrame(session, frame);
312
+ };
313
+ ws.onclose = () => { this.clear(timeout); this.disconnectSession(session); };
314
+ ws.onerror = () => { this.clear(timeout); this.disconnectSession(session); try { ws.close(); } catch {} };
315
+ } catch { this.deps.onError?.("Local session connection failed"); }
316
+ }
317
+ } finally { this.scanning = false; }
318
+ }
319
+
320
+ private disconnectSession(session: DiscordSession): void {
321
+ if (this.sessions.get(session.sessionId) !== session) return;
322
+ this.sessions.delete(session.sessionId);
323
+ for (const request of this.pending.values()) if (request.sessionId === session.sessionId) request.finish(false);
324
+ }
325
+
326
+ private onSessionFrame(session: DiscordSession, frame: Record<string, unknown>): void {
327
+ if (frame.type === "ack" && typeof frame.reqId === "string" && typeof frame.ok === "boolean") {
328
+ const request = this.pending.get(frame.reqId);
329
+ if (request?.sessionId === session.sessionId) request.finish(frame.ok);
330
+ return;
331
+ }
332
+ if (frame.sessionId !== session.sessionId) return;
333
+ const header = `[${session.sessionId}]`;
334
+ if (frame.type === "snapshot" && frame.pid === session.pid && Array.isArray(frame.subagents)) {
335
+ const records = frame.subagents.filter((r): r is AgentState => object(r) && typeof r.id === "string" &&
336
+ typeof r.role === "string" && typeof r.task === "string" && typeof r.status === "string" &&
337
+ ["running", "completed", "failed", "cancelled"].includes(r.status) && (r.result === undefined || typeof r.result === "string"));
338
+ const previous = new Map(session.records.map(r => [r.id, r.status]));
339
+ for (const record of records) {
340
+ const before = previous.get(record.id);
341
+ if ((!before && record.status === "running") || (before === "running" && record.status !== "running")) {
342
+ this.emit(`${header} ${record.role} '${record.id}' ${record.status}\n${record.status === "running" ? record.task : record.result ?? ""}`, session.sessionId);
343
+ }
344
+ }
345
+ session.records = records;
346
+ } else if (frame.type === "identity_header" && typeof frame.repo === "string") {
347
+ this.emit(`${header} ${frame.repo}${typeof frame.branch === "string" ? ` (${frame.branch})` : ""}`, session.sessionId);
348
+ } else if (frame.type === "context_update" && ["turn_start", "turn_end"].includes(String(frame.phase)) && typeof frame.summary === "string") {
349
+ this.emit(`${header} ${frame.phase}\n${frame.summary}`, session.sessionId);
350
+ } else if (frame.type === "turn_stream" && frame.phase === "finalized" && typeof frame.text === "string" && frame.text.trim()) {
351
+ this.emit(`${header}\n${frame.text}`, session.sessionId);
352
+ }
353
+ }
354
+
355
+ private emit(text: string, sessionId?: string): void {
356
+ if (this.stopped) return;
357
+ if (this.queued >= 128) { this.deps.onError?.("Discord outbound queue full"); return; }
358
+ this.queued++;
359
+ this.outgoing = this.outgoing.then(async () => {
360
+ if (this.stopped) return;
361
+ const messages = await this.api.sendMessage(this.config.channelId, text);
362
+ this.rememberReplies(messages, sessionId);
363
+ }).catch(error => {
364
+ if (this.stopped) return;
365
+ if (error instanceof DiscordApiError) this.rememberReplies(error.sentMessages, sessionId);
366
+ if (error instanceof DiscordApiError && (error.status === 401 || error.status === 403)) this.fail("Discord authentication or permission denied");
367
+ else this.deps.onError?.("Discord notification delivery failed");
368
+ }).finally(() => { this.queued--; });
369
+ }
370
+
371
+ private rememberReplies(messages: { id: string }[], sessionId?: string): void {
372
+ if (this.stopped || !sessionId) return;
373
+ for (const message of messages) {
374
+ this.replies.set(message.id, { sessionId, at: (this.deps.now ?? Date.now)() });
375
+ if (this.replies.size > 1024) this.replies.delete(this.replies.keys().next().value!);
376
+ }
377
+ }
378
+
379
+ private findSession(prefix: string): DiscordSession | undefined {
380
+ const live = [...this.sessions.values()].filter(s => s.verified && s.ws.readyState === 1);
381
+ const exact = live.find(s => s.sessionId === prefix);
382
+ if (exact) return exact;
383
+ const matches = live.filter(s => s.sessionId.startsWith(prefix) || s.sessionId.replace(/-/g, "").startsWith(prefix));
384
+ return matches.length === 1 ? matches[0] : undefined;
385
+ }
386
+
387
+ private send(session: DiscordSession, frame: unknown): boolean {
388
+ if (this.stopped || !session.verified || session.ws.readyState !== 1) return false;
389
+ try { session.ws.send(JSON.stringify(frame)); return true; } catch { return false; }
390
+ }
391
+
392
+ private request(session: DiscordSession, frame: Record<string, unknown>): Promise<boolean> {
393
+ if (this.pending.size >= 128) return Promise.resolve(false);
394
+ const reqId = crypto.randomUUID();
395
+ const { promise, resolve } = Promise.withResolvers<boolean>();
396
+ const timer = this.later(() => finish(false), this.deps.ackTimeoutMs ?? 5_000);
397
+ const finish = (ok: boolean) => { this.clear(timer); this.pending.delete(reqId); resolve(ok); };
398
+ this.pending.set(reqId, { sessionId: session.sessionId, finish });
399
+ if (!this.send(session, { ...frame, reqId })) finish(false);
400
+ return promise;
401
+ }
402
+
403
+ private async handleMessage(message: unknown): Promise<void> {
404
+ if (this.stopped || !object(message) || message.channel_id !== this.config.channelId ||
405
+ typeof message.id !== "string" || !/^\d{1,20}$/.test(message.id) || !object(message.author) ||
406
+ typeof message.author.id !== "string" || !this.allowed.has(message.author.id) || message.author.id === this.botId ||
407
+ message.author.bot === true || (message.author.bot !== undefined && message.author.bot !== false) ||
408
+ "webhook_id" in message || ![0, 19].includes(message.type as number) ||
409
+ typeof message.content !== "string" || !message.content.trim() || message.content.length > 4_000 || this.seen.has(message.id)) return;
410
+ this.seen.add(message.id);
411
+ if (this.seen.size > 2048) this.seen.delete(this.seen.values().next().value!);
412
+ const text = message.content.trim();
413
+ if (text === "/sessions" || text === "/subagents") {
414
+ const sessions = [...this.sessions.values()].filter(s => s.verified && s.ws.readyState === 1);
415
+ this.emit(sessions.length ? sessions.map(s => `[${s.sessionId}] ${s.cwd}${text === "/subagents" ? s.records.map(r => `\n ${r.id}: ${r.status} ${r.role} — ${r.task}`).join("") : ""}`).join("\n") : "No connected sessions.");
416
+ return;
417
+ }
418
+ if (text === "/help") { this.emit(HELP); return; }
419
+ const command = /^\/(send|steer|cancel)\s+(\S+)\s+([\s\S]+)$/.exec(text);
420
+ if (command) {
421
+ const session = this.findSession(command[2]!);
422
+ if (!session) { this.emit("Session not found or prefix ambiguous. Use /sessions and a unique ID."); return; }
423
+ const body = command[3]!.trim();
424
+ if (command[1] === "send") {
425
+ if (body.startsWith("/")) { this.emit("Remote slash commands are disabled; send ordinary prompt text."); return; }
426
+ this.emit(this.send(session, { type: "user_message", text: body }) ? "Forwarded to session (not an execution acknowledgement)." : "Session unavailable.", session.sessionId);
427
+ } else {
428
+ const args = command[1] === "steer" ? /^(\S+)\s+([\s\S]+)$/.exec(body) : /^(\S+)$/.exec(body);
429
+ if (!args) { this.emit(HELP); return; }
430
+ const ok = await this.request(session, command[1] === "steer" ? { type: "steer", id: args[1], message: args[2] } : { type: "cancel", ids: [args[1]] });
431
+ this.emit(ok ? "Session acknowledged command." : "Command not acknowledged (unavailable, rejected or timed out).", session.sessionId);
432
+ }
433
+ return;
434
+ }
435
+ if (text.startsWith("/")) { this.emit(HELP); return; }
436
+ const reference = object(message.message_reference) ? message.message_reference : undefined;
437
+ const reply = reference && typeof reference.message_id === "string" && (reference.channel_id === undefined || reference.channel_id === this.config.channelId)
438
+ ? this.replies.get(reference.message_id) : undefined;
439
+ const session = reply && (this.deps.now ?? Date.now)() - reply.at < 86_400_000 ? this.findSession(reply.sessionId) : undefined;
440
+ if (!session) { this.emit("Reply target is unknown or expired. Use /send <unique-session> <text> after /sessions."); return; }
441
+ this.emit(this.send(session, { type: "user_message", text }) ? "Forwarded to session (not an execution acknowledgement)." : "Session unavailable.", session.sessionId);
442
+ }
443
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Path helpers for the local Telegram notification daemon (gjc `notifications/`
2
+ * Path helpers for the shared Telegram/Discord notification daemon (`notifications/`
3
3
  * daemon-paths parity, scoped to jeo's single daemon kind). Everything lives under
4
4
  * `<jeoHome>/notifications` — the SAME root `~/.jeo` (or `JEO_CONFIG_DIR`) used by
5
5
  * `state.ts` for `config.json`, so one override env var moves both.
@@ -0,0 +1,16 @@
1
+ /** Validate private discovery data before dialing an authenticated loopback socket. */
2
+ export function parseSessionEndpoint(raw: string): { url: string; token: string; pid: number; cwd: string } | undefined {
3
+ try {
4
+ const value = JSON.parse(raw);
5
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
6
+ const { url, token, pid, cwd } = value;
7
+ if (typeof url !== "string" || typeof token !== "string" || !token.trim() ||
8
+ !Number.isSafeInteger(pid) || pid <= 0 || typeof cwd !== "string" || !cwd) return undefined;
9
+ const parsed = new URL(url);
10
+ if (parsed.protocol !== "ws:" || parsed.hostname !== "127.0.0.1" || !parsed.port ||
11
+ parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return undefined;
12
+ return { url: parsed.origin, token, pid, cwd };
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
@@ -8,10 +8,10 @@
8
8
  * - subagent visibility/control (unchanged since 0.7.34): `snapshot` on
9
9
  * connect + on change, `steer`/`cancel`/`list` requests — applied to
10
10
  * whichever `SubagentRegistry` is currently ATTACHED (see below);
11
- * - main-session mirroring (new): `identity_header` (once, at start),
11
+ * - main-session mirroring: `identity_header` (cached and replayed on connect),
12
12
  * `context_update` (turn start/end), `turn_stream` (finalized reply) sent
13
13
  * OUT, and `user_message`/`config_command` received IN via callbacks the
14
- * caller registers.
14
+ * caller registers. `user_message` ACKs report callback acceptance.
15
15
  *
16
16
  * `SubagentRegistry` instances are themselves per-turn (a fresh one every
17
17
  * `runTurn` call, see launch.ts), so this endpoint does NOT own one at
@@ -47,9 +47,10 @@ import { notifySessionEndpointPath, notifySessionsDir } from "./paths";
47
47
  import type { SubagentRecord, SubagentRegistry } from "../subagent-registry";
48
48
 
49
49
  const SNAPSHOT_POLL_MS = 1_200;
50
+ const MAX_INBOUND_BYTES = 64 * 1024;
50
51
 
51
- /** Session identity, pushed once as an `identity_header` frame right after
52
- * `start()` — lets the daemon name/rename the session's forum topic. */
52
+ /** Session identity, cached and replayed as an `identity_header` on connection
53
+ * so a late or reconnecting daemon can name the session's forum topic. */
53
54
  export interface SessionIdentity {
54
55
  repo: string;
55
56
  branch?: string;
@@ -80,11 +81,10 @@ export class SessionNotifyEndpoint {
80
81
  private registry: SubagentRegistry | undefined;
81
82
  readonly sessionId: string;
82
83
  private readonly token: string;
84
+ private identityFrame: string | undefined;
83
85
 
84
- /** Registered by the caller (launch.ts) to route inbound frames; both are
85
- * no-ops (frame silently dropped) until wired — matches the existing
86
- * best-effort posture of this whole module. */
87
- onUserMessage: ((msg: RemoteUserMessage) => void) | undefined;
86
+ /** Registered by launch.ts; absent or rejected handlers return a failed ACK. */
87
+ onUserMessage: ((msg: RemoteUserMessage) => void | boolean | Promise<void | boolean>) | undefined;
88
88
  onConfigCommand: ((cmd: RemoteConfigCommand) => void) | undefined;
89
89
 
90
90
  constructor(
@@ -94,6 +94,7 @@ export class SessionNotifyEndpoint {
94
94
  * falls back to a random id for session-less contexts. */
95
95
  sessionId: string = crypto.randomUUID(),
96
96
  ) {
97
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) throw new Error("Invalid notification session ID");
97
98
  this.sessionId = sessionId;
98
99
  this.token = crypto.randomUUID();
99
100
  }
@@ -136,15 +137,10 @@ export class SessionNotifyEndpoint {
136
137
  }
137
138
  }
138
139
 
139
- /** Push the one-time identity header. Safe to call even with no socket
140
- * connected yet — silently dropped, matching every other push here (the
141
- * daemon reconnects and gets a fresh `snapshot`, but identity is a single
142
- * point-in-time fact with no polling fallback, so a session started before
143
- * the daemon connects will not retroactively announce itself; acceptable
144
- * since the common case is the daemon already scanning when a session
145
- * starts). */
140
+ /** Cache identity so a late or reconnecting daemon receives it as well. */
146
141
  sendIdentity(identity: SessionIdentity): void {
147
- this.broadcast(JSON.stringify({ type: "identity_header", sessionId: this.sessionId, ...identity }));
142
+ this.identityFrame = JSON.stringify({ type: "identity_header", sessionId: this.sessionId, ...identity });
143
+ this.broadcast(this.identityFrame);
148
144
  }
149
145
 
150
146
  /** Push a turn-boundary summary (`phase: "turn_start" | "turn_end"`). */
@@ -157,13 +153,16 @@ export class SessionNotifyEndpoint {
157
153
  this.broadcast(JSON.stringify({ type: "turn_stream", sessionId: this.sessionId, phase: "finalized", text }));
158
154
  }
159
155
 
160
- private handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): void {
156
+ private async handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): Promise<void> {
157
+ if (Buffer.byteLength(raw) > MAX_INBOUND_BYTES) return;
161
158
  let msg: Record<string, unknown>;
162
159
  try {
163
160
  msg = JSON.parse(String(raw));
164
161
  } catch {
165
162
  return;
166
163
  }
164
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
165
+ if (msg.sessionId !== undefined && msg.sessionId !== this.sessionId) return;
167
166
  if (msg.type === "list") {
168
167
  try {
169
168
  ws.send(JSON.stringify(this.snapshot()));
@@ -171,9 +170,9 @@ export class SessionNotifyEndpoint {
171
170
  return;
172
171
  }
173
172
  if (msg.type === "steer") {
174
- const id = String(msg.id ?? "");
175
- const message = String(msg.message ?? "");
176
- const ok = id && message ? (this.registry?.steer(id, message) ?? false) : false;
173
+ const id = typeof msg.id === "string" ? msg.id : "";
174
+ const message = typeof msg.message === "string" ? msg.message : "";
175
+ const ok = !!(id && message && this.registry?.steer(id, message));
177
176
  try {
178
177
  ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
179
178
  } catch {}
@@ -188,9 +187,18 @@ export class SessionNotifyEndpoint {
188
187
  } catch {}
189
188
  return;
190
189
  }
191
- if (msg.type === "user_message" && typeof msg.text === "string") {
192
- const imagePaths = Array.isArray(msg.imagePaths) ? msg.imagePaths.filter((x): x is string => typeof x === "string") : undefined;
193
- this.onUserMessage?.({ text: msg.text, imagePaths });
190
+ if (msg.type === "user_message") {
191
+ let ok = false;
192
+ const validPaths = msg.imagePaths === undefined || (Array.isArray(msg.imagePaths) && msg.imagePaths.every(x => typeof x === "string"));
193
+ if (typeof msg.text === "string" && !msg.text.trimStart().startsWith("/") && validPaths &&
194
+ (msg.text.trim() || (msg.imagePaths as string[] | undefined)?.length) && this.onUserMessage) {
195
+ try {
196
+ ok = (await this.onUserMessage({ text: msg.text, imagePaths: msg.imagePaths as string[] | undefined })) !== false;
197
+ } catch {}
198
+ }
199
+ try {
200
+ ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
201
+ } catch {}
194
202
  return;
195
203
  }
196
204
  if (msg.type === "config_command") {
@@ -217,13 +225,15 @@ export class SessionNotifyEndpoint {
217
225
  return new Response("upgrade failed", { status: 500 });
218
226
  },
219
227
  websocket: {
228
+ maxPayloadLength: MAX_INBOUND_BYTES,
220
229
  open: ws => {
221
230
  this.sockets.add(ws);
222
231
  try {
223
232
  ws.send(JSON.stringify(this.snapshot()));
233
+ if (this.identityFrame) ws.send(this.identityFrame);
224
234
  } catch {}
225
235
  },
226
- message: (ws, raw) => this.handleMessage(ws, raw),
236
+ message: (ws, raw) => { void this.handleMessage(ws, raw); },
227
237
  close: ws => {
228
238
  this.sockets.delete(ws);
229
239
  },
@@ -246,6 +256,7 @@ export class SessionNotifyEndpoint {
246
256
  // active handles refusing to exit.
247
257
  (this.pollTimer as { unref?: () => void }).unref?.();
248
258
  await fs.mkdir(notifySessionsDir(), { recursive: true, mode: 0o700 });
259
+ await fs.chmod(notifySessionsDir(), 0o700);
249
260
  const payload = {
250
261
  url: `ws://127.0.0.1:${this.server.port}`,
251
262
  token: this.token,
@@ -253,7 +264,14 @@ export class SessionNotifyEndpoint {
253
264
  cwd: this.cwd,
254
265
  startedAt: Date.now(),
255
266
  };
256
- await fs.writeFile(notifySessionEndpointPath(this.sessionId), JSON.stringify(payload), { mode: 0o600 });
267
+ const discoveryPath = notifySessionEndpointPath(this.sessionId);
268
+ const temporaryPath = `${discoveryPath}.${crypto.randomUUID()}.tmp`;
269
+ try {
270
+ await fs.writeFile(temporaryPath, JSON.stringify(payload), { mode: 0o600, flag: "wx" });
271
+ await fs.rename(temporaryPath, discoveryPath);
272
+ } finally {
273
+ await fs.unlink(temporaryPath).catch(() => {});
274
+ }
257
275
  }
258
276
 
259
277
  async stop(): Promise<void> {