jeo-code 0.11.2 → 0.11.4
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 +51 -1
- package/README.ja.md +62 -33
- package/README.ko.md +65 -35
- package/README.md +61 -32
- package/README.zh.md +64 -35
- 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,148 @@
|
|
|
1
|
+
import { version } from "../../../package.json";
|
|
2
|
+
|
|
3
|
+
/** Native Discord bot REST surface. Tokens are headers only; errors never include response bodies. */
|
|
4
|
+
export interface DiscordApiOptions {
|
|
5
|
+
fetchImpl?: typeof fetch;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
8
|
+
requestTimeoutMs?: number;
|
|
9
|
+
maxRetries?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class DiscordApiError extends Error {
|
|
13
|
+
constructor(message: string, readonly status?: number, readonly retryAfterMs?: number, readonly sentMessages: { id: string }[] = []) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "DiscordApiError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiscordGatewayInfo {
|
|
20
|
+
url: string;
|
|
21
|
+
session_start_limit: { remaining: number; reset_after: number; total: number; max_concurrency: number };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
25
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
26
|
+
const abort = () => { clearTimeout(timer); reject(new DiscordApiError("Discord request stopped")); };
|
|
27
|
+
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
|
|
28
|
+
if (signal.aborted) abort();
|
|
29
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
30
|
+
return promise;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class DiscordApi {
|
|
34
|
+
private readonly controller = new AbortController();
|
|
35
|
+
private cooldownUntil = 0;
|
|
36
|
+
private fatalStatus: number | undefined;
|
|
37
|
+
private queue = Promise.resolve();
|
|
38
|
+
private queued = 0;
|
|
39
|
+
|
|
40
|
+
constructor(private readonly token: string, private readonly options: DiscordApiOptions = {}) {}
|
|
41
|
+
|
|
42
|
+
stop(): void { this.controller.abort(); }
|
|
43
|
+
|
|
44
|
+
private request<T>(route: string, body?: unknown): Promise<T> {
|
|
45
|
+
if (this.queued >= 128) return Promise.reject(new DiscordApiError("Discord request queue full"));
|
|
46
|
+
this.queued++;
|
|
47
|
+
const result = this.queue.then(() => this.perform<T>(route, body));
|
|
48
|
+
this.queue = result.then(() => {}, () => {}).finally(() => { this.queued--; });
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async perform<T>(route: string, body?: unknown): Promise<T> {
|
|
53
|
+
const now = this.options.now ?? Date.now;
|
|
54
|
+
const retries = Math.max(0, Math.min(this.options.maxRetries ?? 2, 3));
|
|
55
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
56
|
+
if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
|
|
57
|
+
if (this.fatalStatus) throw new DiscordApiError("Discord authentication or permission denied", this.fatalStatus);
|
|
58
|
+
const wait = this.cooldownUntil - now();
|
|
59
|
+
if (wait > 30_000) throw new DiscordApiError("Discord rate limit cooldown active", 429, wait);
|
|
60
|
+
if (wait > 0) await (this.options.sleep ?? delay)(wait, this.controller.signal);
|
|
61
|
+
if (this.controller.signal.aborted) throw new DiscordApiError("Discord request stopped");
|
|
62
|
+
const timeout = AbortSignal.timeout(Math.max(1, Math.min(this.options.requestTimeoutMs ?? 10_000, 30_000)));
|
|
63
|
+
let response: Response;
|
|
64
|
+
let data: unknown;
|
|
65
|
+
try {
|
|
66
|
+
response = await (this.options.fetchImpl ?? fetch)(`https://discord.com/api/v10${route}`, {
|
|
67
|
+
method: body === undefined ? "GET" : "POST",
|
|
68
|
+
headers: { Authorization: `Bot ${this.token}`, "Content-Type": "application/json", "User-Agent": `DiscordBot (https://github.com/akillness/jeo-code, ${version})` },
|
|
69
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
70
|
+
signal: AbortSignal.any([this.controller.signal, timeout]),
|
|
71
|
+
redirect: "error",
|
|
72
|
+
});
|
|
73
|
+
if (response.status === 401 || response.status === 403) this.fatalStatus = response.status;
|
|
74
|
+
data = await response.json().catch(error => {
|
|
75
|
+
if (response.ok) throw error;
|
|
76
|
+
return null; // HTTP denial/cooldown headers remain authoritative without a JSON body.
|
|
77
|
+
});
|
|
78
|
+
} catch {
|
|
79
|
+
// Never retry an uncertain POST: Discord may already have accepted it.
|
|
80
|
+
if (this.fatalStatus) throw new DiscordApiError("Discord authentication or permission denied", this.fatalStatus);
|
|
81
|
+
throw new DiscordApiError("Discord request failed or timed out");
|
|
82
|
+
}
|
|
83
|
+
const record = data && typeof data === "object" ? data as Record<string, unknown> : {};
|
|
84
|
+
if (response.status === 429 || response.headers.get("X-RateLimit-Remaining") === "0") {
|
|
85
|
+
const raw = Number(response.status === 429
|
|
86
|
+
? record.retry_after ?? response.headers.get("Retry-After")
|
|
87
|
+
: response.headers.get("X-RateLimit-Reset-After"));
|
|
88
|
+
const ms = Number.isFinite(raw) && raw > 0 ? raw * 1000 : 30_000;
|
|
89
|
+
// One serialized bot lane conservatively honors both route and global cooldowns.
|
|
90
|
+
this.cooldownUntil = Math.max(this.cooldownUntil, now() + ms);
|
|
91
|
+
}
|
|
92
|
+
if (response.status === 429 && attempt < retries) continue;
|
|
93
|
+
if (!response.ok) throw new DiscordApiError(`Discord HTTP ${response.status}`, response.status, Math.max(0, this.cooldownUntil - now()));
|
|
94
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new DiscordApiError("Invalid Discord response");
|
|
95
|
+
return data as T;
|
|
96
|
+
}
|
|
97
|
+
throw new DiscordApiError("Discord retry limit reached");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async getMe(): Promise<{ id: string; username: string; bot: boolean }> {
|
|
101
|
+
const me = await this.request<{ id: string; username: string; bot: boolean }>("/users/@me");
|
|
102
|
+
if (!/^\d+$/.test(me.id) || typeof me.id !== "string" || typeof me.username !== "string" || me.bot !== true) {
|
|
103
|
+
throw new DiscordApiError("Discord bot identity required");
|
|
104
|
+
}
|
|
105
|
+
return me;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async getChannel(channelId: string): Promise<{ id: string; type: number }> {
|
|
109
|
+
this.checkId(channelId);
|
|
110
|
+
const channel = await this.request<{ id: string; type: number }>(`/channels/${channelId}`);
|
|
111
|
+
if (channel.id !== channelId || !Number.isInteger(channel.type)) throw new DiscordApiError("Invalid Discord channel");
|
|
112
|
+
return channel;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async getGatewayBot(): Promise<DiscordGatewayInfo> {
|
|
116
|
+
const gateway = await this.request<DiscordGatewayInfo>("/gateway/bot");
|
|
117
|
+
const limit = gateway.session_start_limit;
|
|
118
|
+
if (typeof gateway.url !== "string" || !limit || !Number.isSafeInteger(limit.remaining) || limit.remaining < 0 ||
|
|
119
|
+
!Number.isFinite(limit.reset_after) || limit.reset_after < 0 || !Number.isSafeInteger(limit.total) || limit.total < 1 ||
|
|
120
|
+
!Number.isSafeInteger(limit.max_concurrency) || limit.max_concurrency < 1) throw new DiscordApiError("Invalid Discord gateway response");
|
|
121
|
+
return gateway;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async sendMessage(channelId: string, content: string): Promise<{ id: string }[]> {
|
|
125
|
+
this.checkId(channelId);
|
|
126
|
+
if (!content.trim()) return [];
|
|
127
|
+
if (content.length > 256_000) throw new DiscordApiError("Discord message exceeds relay limit");
|
|
128
|
+
const messages: { id: string }[] = [];
|
|
129
|
+
for (let offset = 0; offset < content.length;) {
|
|
130
|
+
let end = Math.min(offset + 2000, content.length);
|
|
131
|
+
if (end < content.length && /[\uD800-\uDBFF]/.test(content[end - 1]!)) end--;
|
|
132
|
+
const message = await this.request<{ id: string }>(`/channels/${channelId}/messages`, {
|
|
133
|
+
content: content.slice(offset, end), allowed_mentions: { parse: [], replied_user: false },
|
|
134
|
+
}).catch(error => {
|
|
135
|
+
if (error instanceof DiscordApiError) throw new DiscordApiError(error.message, error.status, error.retryAfterMs, messages);
|
|
136
|
+
throw new DiscordApiError("Discord delivery failed", undefined, undefined, messages);
|
|
137
|
+
});
|
|
138
|
+
if (typeof message.id !== "string" || !/^\d+$/.test(message.id)) throw new DiscordApiError("Invalid Discord message ID", undefined, undefined, messages);
|
|
139
|
+
messages.push(message);
|
|
140
|
+
offset = end;
|
|
141
|
+
}
|
|
142
|
+
return messages;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private checkId(id: string): void {
|
|
146
|
+
if (typeof id !== "string" || !/^\d{1,20}$/.test(id)) throw new DiscordApiError("Invalid Discord ID");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -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
|
+
}
|