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.
- package/CHANGELOG.md +35 -1
- package/README.ja.md +59 -27
- package/README.ko.md +62 -29
- package/README.md +58 -26
- package/README.zh.md +61 -29
- package/package.json +1 -1
- package/src/agent/config-schema.ts +20 -7
- package/src/agent/debug-session.ts +7 -3
- package/src/agent/engine.ts +46 -102
- package/src/agent/loop-guards.ts +11 -13
- package/src/agent/notify/daemon-control.ts +109 -24
- package/src/agent/notify/discord-api.ts +148 -0
- package/src/agent/notify/discord-daemon.ts +443 -0
- package/src/agent/notify/paths.ts +1 -1
- package/src/agent/notify/session-discovery.ts +16 -0
- package/src/agent/notify/session-endpoint.ts +43 -25
- package/src/agent/notify/slack-api.ts +177 -0
- package/src/agent/notify/slack-daemon.ts +361 -0
- package/src/agent/notify/telegram-daemon.ts +215 -83
- package/src/agent/state.ts +13 -1
- package/src/ai/model-discovery.ts +13 -21
- package/src/autopilot.ts +14 -5
- package/src/cli/runner.ts +4 -4
- package/src/commands/approve.ts +21 -16
- package/src/commands/daemon.ts +5 -3
- package/src/commands/launch/agents-slash.ts +8 -3
- package/src/commands/launch.ts +36 -44
- package/src/commands/notify.ts +215 -83
- package/src/commands/ralplan.ts +2 -1
- package/src/commands/team.ts +17 -12
- package/src/commands/ultragoal.ts +2 -1
|
@@ -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
|
|
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
|
|
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,
|
|
52
|
-
*
|
|
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
|
|
85
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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 =
|
|
175
|
-
const message =
|
|
176
|
-
const ok = id && message
|
|
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"
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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> {
|