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,177 @@
|
|
|
1
|
+
export interface SlackApiOptions {
|
|
2
|
+
fetchImpl?: typeof fetch;
|
|
3
|
+
now?: () => number;
|
|
4
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
5
|
+
requestTimeoutMs?: number;
|
|
6
|
+
maxRetries?: number;
|
|
7
|
+
setTimeout?: (callback: () => void, ms: number) => Timer;
|
|
8
|
+
clearTimeout?: (timer: Timer) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SlackIdentity { team_id: string; user_id: string; bot_id: string; app_id: string }
|
|
12
|
+
export interface SlackChannel {
|
|
13
|
+
id: string;
|
|
14
|
+
is_im?: boolean;
|
|
15
|
+
is_member?: boolean;
|
|
16
|
+
is_archived?: boolean;
|
|
17
|
+
context_team_id?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface SlackMessage { ts: string }
|
|
20
|
+
|
|
21
|
+
export class SlackApiError extends Error {
|
|
22
|
+
constructor(message: string, readonly status?: number, readonly retryAfterMs?: number, readonly sentMessages: SlackMessage[] = []) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "SlackApiError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isSlackChannelId(id: unknown): id is string { return typeof id === "string" && /^[CGD][A-Z0-9]+$/.test(id); }
|
|
29
|
+
export function isSlackUserId(id: unknown): id is string { return typeof id === "string" && /^[UW][A-Z0-9]+$/.test(id); }
|
|
30
|
+
export function isSlackTimestamp(value: unknown): value is string { return typeof value === "string" && /^\d{1,20}\.\d{6}$/.test(value); }
|
|
31
|
+
function object(value: unknown): value is Record<string, unknown> { return !!value && typeof value === "object" && !Array.isArray(value); }
|
|
32
|
+
|
|
33
|
+
/** Ticket-bearing URLs are restricted before any WebSocket constructor sees them. */
|
|
34
|
+
export function slackSocketUrl(raw: string): string {
|
|
35
|
+
let url: URL;
|
|
36
|
+
try { url = new URL(raw); } catch { throw new SlackApiError("Invalid Slack Socket Mode URL"); }
|
|
37
|
+
if (url.protocol !== "wss:" || !/^wss(?:-[a-z0-9-]+)?\.slack\.com$/.test(url.hostname) ||
|
|
38
|
+
url.username || url.password || url.port || url.hash || url.pathname !== "/link/" || !url.searchParams.get("ticket")) {
|
|
39
|
+
throw new SlackApiError("Invalid Slack Socket Mode URL");
|
|
40
|
+
}
|
|
41
|
+
return url.href;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
45
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
46
|
+
const abort = () => { clearTimeout(timer); reject(new SlackApiError("Slack request stopped")); };
|
|
47
|
+
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
|
|
48
|
+
if (signal.aborted) abort();
|
|
49
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
50
|
+
return promise;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Tokens are headers only. Neither upstream response bodies nor thrown network errors are logged. */
|
|
54
|
+
export class SlackApi {
|
|
55
|
+
private readonly controller = new AbortController();
|
|
56
|
+
private queue = Promise.resolve();
|
|
57
|
+
private queued = 0;
|
|
58
|
+
private cooldownUntil = 0;
|
|
59
|
+
private nextMessageAt = 0;
|
|
60
|
+
private fatalStatus: number | undefined;
|
|
61
|
+
|
|
62
|
+
constructor(private readonly botToken: string, private readonly appToken: string, private readonly options: SlackApiOptions = {}) {
|
|
63
|
+
if (typeof botToken !== "string" || !/^(?:xoxe\.)?xoxb-\S+$/.test(botToken) ||
|
|
64
|
+
typeof appToken !== "string" || !/^(?:xoxe\.)?xapp-\S+$/.test(appToken)) throw new SlackApiError("Slack requires bot and app-level access tokens");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stop(): void { this.controller.abort(); }
|
|
68
|
+
|
|
69
|
+
private request(method: string, body: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
70
|
+
if (this.queued >= 128) return Promise.reject(new SlackApiError("Slack request queue full"));
|
|
71
|
+
this.queued++;
|
|
72
|
+
const result = this.queue.then(() => this.perform(method, body));
|
|
73
|
+
this.queue = result.then(() => {}, () => {}).finally(() => { this.queued--; });
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async perform(method: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
78
|
+
const now = this.options.now ?? Date.now;
|
|
79
|
+
const retries = Number.isFinite(this.options.maxRetries) ? Math.max(0, Math.min(Math.floor(this.options.maxRetries!), 3)) : 2;
|
|
80
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
81
|
+
if (this.controller.signal.aborted) throw new SlackApiError("Slack request stopped");
|
|
82
|
+
if (this.fatalStatus) throw new SlackApiError("Slack authentication or permission denied", this.fatalStatus);
|
|
83
|
+
const wait = Math.max(this.cooldownUntil, method === "chat.postMessage" ? this.nextMessageAt : 0) - now();
|
|
84
|
+
if (wait > 30_000) throw new SlackApiError("Slack rate limit cooldown active", 429, wait);
|
|
85
|
+
if (wait > 0) await (this.options.sleep ?? delay)(wait, this.controller.signal);
|
|
86
|
+
if (this.controller.signal.aborted) throw new SlackApiError("Slack request stopped");
|
|
87
|
+
const timeout = new AbortController();
|
|
88
|
+
const timer = (this.options.setTimeout ?? setTimeout)(() => timeout.abort(), Math.max(1, Math.min(this.options.requestTimeoutMs ?? 10_000, 30_000)));
|
|
89
|
+
let response: Response;
|
|
90
|
+
let data: unknown;
|
|
91
|
+
try {
|
|
92
|
+
const read = method === "conversations.info" || method === "bots.info";
|
|
93
|
+
const query = new URLSearchParams(Object.entries(body).map(([key, value]): [string, string] => [key, String(value)]));
|
|
94
|
+
response = await (this.options.fetchImpl ?? fetch)(`https://slack.com/api/${method}${read ? `?${query}` : ""}`, {
|
|
95
|
+
method: read ? "GET" : "POST",
|
|
96
|
+
headers: { Authorization: `Bearer ${method === "apps.connections.open" ? this.appToken : this.botToken}`, "Content-Type": "application/json; charset=utf-8" },
|
|
97
|
+
body: read ? undefined : JSON.stringify(body),
|
|
98
|
+
signal: AbortSignal.any([this.controller.signal, timeout.signal]),
|
|
99
|
+
redirect: "error",
|
|
100
|
+
});
|
|
101
|
+
if (response.status === 401 || response.status === 403) this.fatalStatus = response.status;
|
|
102
|
+
data = await response.json().catch(error => { if (response.ok) throw error; return null; });
|
|
103
|
+
} catch {
|
|
104
|
+
// An uncertain POST is never retried: Slack may have accepted the message already.
|
|
105
|
+
throw new SlackApiError(this.fatalStatus ? "Slack authentication or permission denied" : "Slack request failed or timed out", this.fatalStatus);
|
|
106
|
+
} finally { (this.options.clearTimeout ?? clearTimeout)(timer); }
|
|
107
|
+
if (response.status === 429) {
|
|
108
|
+
const seconds = Number(response.headers.get("Retry-After"));
|
|
109
|
+
const ms = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 30_000;
|
|
110
|
+
this.cooldownUntil = Math.max(this.cooldownUntil, now() + ms);
|
|
111
|
+
if (attempt < retries) continue;
|
|
112
|
+
throw new SlackApiError("Slack rate limited", 429, ms);
|
|
113
|
+
}
|
|
114
|
+
if (!response.ok) throw new SlackApiError(`Slack HTTP ${response.status}`, response.status);
|
|
115
|
+
if (!object(data)) throw new SlackApiError("Invalid Slack response");
|
|
116
|
+
if (data.ok !== true) {
|
|
117
|
+
if (["invalid_auth", "not_authed", "token_revoked", "token_expired", "account_inactive"].includes(String(data.error))) this.fatalStatus = 401;
|
|
118
|
+
if (["missing_scope", "no_permission", "access_denied", "not_allowed_token_type"].includes(String(data.error))) this.fatalStatus = 403;
|
|
119
|
+
throw new SlackApiError(this.fatalStatus ? "Slack authentication or permission denied" : "Slack API request rejected", this.fatalStatus);
|
|
120
|
+
}
|
|
121
|
+
if (method === "chat.postMessage") this.nextMessageAt = now() + 1000;
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
throw new SlackApiError("Slack retry limit reached");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async getMe(): Promise<SlackIdentity> {
|
|
128
|
+
const me = await this.request("auth.test");
|
|
129
|
+
if (typeof me.team_id !== "string" || !/^T[A-Z0-9]+$/.test(me.team_id) || !isSlackUserId(me.user_id) ||
|
|
130
|
+
typeof me.bot_id !== "string" || !/^B[A-Z0-9]+$/.test(me.bot_id)) throw new SlackApiError("Slack bot identity required");
|
|
131
|
+
const result = await this.request("bots.info", { bot: me.bot_id });
|
|
132
|
+
const bot = result.bot;
|
|
133
|
+
if (!object(bot) || bot.id !== me.bot_id || bot.user_id !== me.user_id || bot.deleted === true ||
|
|
134
|
+
typeof bot.app_id !== "string" || !/^A[A-Z0-9]+$/.test(bot.app_id)) throw new SlackApiError("Slack bot app identity required");
|
|
135
|
+
return { team_id: me.team_id, user_id: me.user_id, bot_id: me.bot_id, app_id: bot.app_id };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async getChannel(channelId: string): Promise<SlackChannel> {
|
|
139
|
+
if (!isSlackChannelId(channelId)) throw new SlackApiError("Invalid Slack channel ID");
|
|
140
|
+
const result = await this.request("conversations.info", { channel: channelId });
|
|
141
|
+
const channel = result.channel;
|
|
142
|
+
if (!object(channel) || channel.id !== channelId || channel.is_archived === true ||
|
|
143
|
+
(channel.is_im !== true && channel.is_member !== true)) throw new SlackApiError("Slack channel is unavailable or bot is not a member");
|
|
144
|
+
if (channel.context_team_id !== undefined && (typeof channel.context_team_id !== "string" || !/^T[A-Z0-9]+$/.test(channel.context_team_id))) throw new SlackApiError("Invalid Slack channel workspace");
|
|
145
|
+
return channel as unknown as SlackChannel;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async openConnection(): Promise<{ url: string }> {
|
|
149
|
+
const result = await this.request("apps.connections.open");
|
|
150
|
+
if (typeof result.url !== "string") throw new SlackApiError("Invalid Slack Socket Mode response");
|
|
151
|
+
return { url: slackSocketUrl(result.url) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async sendMessage(channelId: string, text: string, threadTs?: string): Promise<SlackMessage[]> {
|
|
155
|
+
if (!isSlackChannelId(channelId) || (threadTs !== undefined && !isSlackTimestamp(threadTs))) throw new SlackApiError("Invalid Slack message destination");
|
|
156
|
+
if (!text.trim()) return [];
|
|
157
|
+
if (text.length > 256_000) throw new SlackApiError("Slack message exceeds relay limit");
|
|
158
|
+
const sent: SlackMessage[] = [];
|
|
159
|
+
for (let offset = 0; offset < text.length;) {
|
|
160
|
+
let end = Math.min(offset + 4000, text.length);
|
|
161
|
+
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]!)) end--;
|
|
162
|
+
// Escape explicit mention syntax as well as disabling automatic link/name parsing.
|
|
163
|
+
const chunk = text.slice(offset, end).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
164
|
+
try {
|
|
165
|
+
const result = await this.request("chat.postMessage", { channel: channelId, text: chunk, thread_ts: threadTs,
|
|
166
|
+
mrkdwn: false, parse: "none", link_names: false, unfurl_links: false, unfurl_media: false });
|
|
167
|
+
if (!isSlackTimestamp(result.ts) || result.channel !== channelId) throw new SlackApiError("Invalid Slack message response");
|
|
168
|
+
sent.push({ ts: result.ts });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new SlackApiError(error instanceof SlackApiError ? error.message : "Slack delivery failed",
|
|
171
|
+
error instanceof SlackApiError ? error.status : undefined, error instanceof SlackApiError ? error.retryAfterMs : undefined, sent);
|
|
172
|
+
}
|
|
173
|
+
offset = end;
|
|
174
|
+
}
|
|
175
|
+
return sent;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { SlackApi, SlackApiError, isSlackChannelId, isSlackUserId, isSlackTimestamp, slackSocketUrl, type SlackIdentity, type SlackMessage } from "./slack-api";
|
|
4
|
+
import { notifySessionsDir } from "./paths";
|
|
5
|
+
import { isPidAlive } from "./daemon-control";
|
|
6
|
+
import { parseSessionEndpoint } from "./session-discovery";
|
|
7
|
+
|
|
8
|
+
export interface SlackDaemonConfig {
|
|
9
|
+
botToken: string;
|
|
10
|
+
appToken: string;
|
|
11
|
+
channelId: string;
|
|
12
|
+
allowedUserIds: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface SlackDaemonDependencies {
|
|
15
|
+
api?: Pick<SlackApi, "getMe" | "getChannel" | "openConnection" | "sendMessage" | "stop">;
|
|
16
|
+
WebSocketImpl?: typeof WebSocket;
|
|
17
|
+
SocketWebSocketImpl?: typeof WebSocket;
|
|
18
|
+
readdir?: (dir: string) => Promise<string[]>;
|
|
19
|
+
readFile?: (file: string) => Promise<string>;
|
|
20
|
+
isPidAlive?: (pid: number) => boolean;
|
|
21
|
+
now?: () => number;
|
|
22
|
+
setTimeout?: (callback: () => void, ms: number) => Timer;
|
|
23
|
+
clearTimeout?: (timer: Timer) => void;
|
|
24
|
+
scanIntervalMs?: number;
|
|
25
|
+
ackTimeoutMs?: number;
|
|
26
|
+
handshakeTimeoutMs?: number;
|
|
27
|
+
maxReconnects?: number;
|
|
28
|
+
onError?: (message: string) => void;
|
|
29
|
+
onFatal?: (message: string) => void;
|
|
30
|
+
}
|
|
31
|
+
interface AgentState { id: string; status: string; role: string; task: string; result?: string }
|
|
32
|
+
interface SlackSession { sessionId: string; cwd: string; pid: number; ws: WebSocket; verified: boolean; records: AgentState[] }
|
|
33
|
+
function object(value: unknown): value is Record<string, unknown> { return !!value && typeof value === "object" && !Array.isArray(value); }
|
|
34
|
+
const HELP = "Slack controls: send @bot /command; registered Slack slash commands and delivered plain /commands also work.\n/sessions\n/subagents\n/send <unique-session> <text>\n/steer <unique-session> <subagent-id> <text>\n/cancel <unique-session> <subagent-id>\nReply in a session notification thread to send that session ordinary text. Other slash commands are not executed.";
|
|
35
|
+
|
|
36
|
+
/** Socket acknowledgements confirm receipt only; all local controls separately require a matching session ACK. */
|
|
37
|
+
export class SlackDaemon {
|
|
38
|
+
readonly sessions = new Map<string, SlackSession>();
|
|
39
|
+
private readonly api: NonNullable<SlackDaemonDependencies["api"]>;
|
|
40
|
+
private readonly timers = new Set<Timer>();
|
|
41
|
+
private readonly pending = new Map<string, { session: SlackSession; finish: (ok: boolean) => void }>();
|
|
42
|
+
private readonly replies = new Map<string, { sessionId: string; at: number }>();
|
|
43
|
+
private readonly seen = new Set<string>();
|
|
44
|
+
private readonly allowed: Set<string>;
|
|
45
|
+
private stopped = true;
|
|
46
|
+
private started = false;
|
|
47
|
+
private scanning = false;
|
|
48
|
+
private ready = false;
|
|
49
|
+
private socket: WebSocket | undefined;
|
|
50
|
+
private identity: SlackIdentity | undefined;
|
|
51
|
+
private handshakeTimer: Timer | undefined;
|
|
52
|
+
private reconnectTimer: Timer | undefined;
|
|
53
|
+
private stabilityTimer: Timer | undefined;
|
|
54
|
+
private reconnects = 0;
|
|
55
|
+
private outgoing = Promise.resolve();
|
|
56
|
+
private queued = 0;
|
|
57
|
+
|
|
58
|
+
constructor(private readonly config: SlackDaemonConfig, private readonly deps: SlackDaemonDependencies = {}) {
|
|
59
|
+
if (typeof config.botToken !== "string" || !config.botToken.trim() || typeof config.appToken !== "string" || !config.appToken.trim() ||
|
|
60
|
+
!isSlackChannelId(config.channelId) || !Array.isArray(config.allowedUserIds) || !config.allowedUserIds.length ||
|
|
61
|
+
config.allowedUserIds.some(id => !isSlackUserId(id))) throw new Error("Slack requires bot/app tokens, a channel ID and explicit allowed human user IDs");
|
|
62
|
+
this.allowed = new Set(config.allowedUserIds);
|
|
63
|
+
this.api = deps.api ?? new SlackApi(config.botToken, config.appToken);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private later(callback: () => void, ms: number): Timer {
|
|
67
|
+
const timer = (this.deps.setTimeout ?? setTimeout)(() => { this.timers.delete(timer); if (!this.stopped) callback(); }, ms);
|
|
68
|
+
this.timers.add(timer);
|
|
69
|
+
return timer;
|
|
70
|
+
}
|
|
71
|
+
private clear(timer: Timer | undefined): void {
|
|
72
|
+
if (timer === undefined) return;
|
|
73
|
+
(this.deps.clearTimeout ?? clearTimeout)(timer);
|
|
74
|
+
this.timers.delete(timer);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async start(): Promise<void> {
|
|
78
|
+
if (this.started) { if (this.stopped) throw new Error("Slack daemon instance has stopped"); return; }
|
|
79
|
+
this.started = true;
|
|
80
|
+
this.stopped = false;
|
|
81
|
+
try {
|
|
82
|
+
this.identity = await this.api.getMe();
|
|
83
|
+
if (this.stopped) return;
|
|
84
|
+
const channel = await this.api.getChannel(this.config.channelId);
|
|
85
|
+
if (this.stopped) return;
|
|
86
|
+
if (channel.id !== this.config.channelId || channel.is_archived === true || (channel.is_im !== true && channel.is_member !== true) ||
|
|
87
|
+
(channel.context_team_id !== undefined && channel.context_team_id !== this.identity.team_id)) throw new Error("Slack channel identity mismatch");
|
|
88
|
+
await this.connectSocket();
|
|
89
|
+
if (!this.stopped) this.scheduleScan();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
this.stop();
|
|
92
|
+
if (error instanceof SlackApiError) throw error;
|
|
93
|
+
throw new Error("Slack startup failed: verify bot, app, channel and permissions");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
stop(): void {
|
|
98
|
+
this.stopped = true;
|
|
99
|
+
this.ready = false;
|
|
100
|
+
for (const timer of this.timers) (this.deps.clearTimeout ?? clearTimeout)(timer);
|
|
101
|
+
this.timers.clear();
|
|
102
|
+
const socket = this.socket;
|
|
103
|
+
this.socket = undefined;
|
|
104
|
+
try { socket?.close(1000); } catch {}
|
|
105
|
+
for (const session of this.sessions.values()) { try { session.ws.close(); } catch {} }
|
|
106
|
+
this.sessions.clear();
|
|
107
|
+
for (const request of this.pending.values()) request.finish(false);
|
|
108
|
+
this.pending.clear();
|
|
109
|
+
this.replies.clear();
|
|
110
|
+
this.seen.clear();
|
|
111
|
+
this.api.stop();
|
|
112
|
+
}
|
|
113
|
+
private fail(message: string): void {
|
|
114
|
+
if (this.stopped) return;
|
|
115
|
+
this.stop();
|
|
116
|
+
try { this.deps.onError?.(message); } finally { this.deps.onFatal?.(message); }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async connectSocket(): Promise<void> {
|
|
120
|
+
if (this.stopped) return;
|
|
121
|
+
this.reconnectTimer = undefined;
|
|
122
|
+
try {
|
|
123
|
+
const connection = await this.api.openConnection();
|
|
124
|
+
if (this.stopped) return;
|
|
125
|
+
let url: string;
|
|
126
|
+
try { url = slackSocketUrl(connection.url); } catch { this.fail("Invalid Slack Socket Mode URL"); return; }
|
|
127
|
+
const WS = this.deps.SocketWebSocketImpl ?? this.deps.WebSocketImpl ?? WebSocket;
|
|
128
|
+
const ws = new WS(url);
|
|
129
|
+
this.socket = ws;
|
|
130
|
+
this.handshakeTimer = this.later(() => this.reconnect(), this.deps.handshakeTimeoutMs ?? 30_000);
|
|
131
|
+
ws.onmessage = async event => {
|
|
132
|
+
if (this.stopped || this.socket !== ws || typeof event.data !== "string" || event.data.length > 1_048_576) return;
|
|
133
|
+
let frame: unknown;
|
|
134
|
+
try { frame = JSON.parse(event.data); } catch { return; }
|
|
135
|
+
if (!object(frame)) return;
|
|
136
|
+
if (typeof frame.envelope_id === "string" && frame.envelope_id.length > 0 && frame.envelope_id.length <= 256) {
|
|
137
|
+
try { ws.send(JSON.stringify({ envelope_id: frame.envelope_id })); } catch { this.reconnect(); return; }
|
|
138
|
+
}
|
|
139
|
+
if (frame.type === "hello") {
|
|
140
|
+
if (this.ready) return;
|
|
141
|
+
if (!object(frame.connection_info) || frame.connection_info.app_id !== this.identity?.app_id) { this.fail("Slack app token does not match bot identity"); return; }
|
|
142
|
+
this.ready = true;
|
|
143
|
+
this.clear(this.handshakeTimer);
|
|
144
|
+
// Reset only after a stable connection, not a rapid hello/close reconnect loop.
|
|
145
|
+
this.stabilityTimer = this.later(() => { this.reconnects = 0; }, 60_000);
|
|
146
|
+
await this.scanSessions();
|
|
147
|
+
} else if (frame.type === "disconnect") {
|
|
148
|
+
if (frame.reason === "link_disabled") this.fail("Slack Socket Mode disabled");
|
|
149
|
+
else this.reconnect();
|
|
150
|
+
} else if (this.ready && typeof frame.envelope_id === "string" && frame.envelope_id.length > 0 && frame.envelope_id.length <= 256) {
|
|
151
|
+
await this.handleEnvelope(frame).catch(() => { if (!this.stopped) this.deps.onError?.("Slack command relay failed"); });
|
|
152
|
+
await this.outgoing;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
ws.onclose = () => { if (!this.stopped && this.socket === ws) this.reconnect(); };
|
|
156
|
+
ws.onerror = () => { if (!this.stopped && this.socket === ws) this.reconnect(); };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (this.stopped) return;
|
|
159
|
+
if (error instanceof SlackApiError && (error.status === 401 || error.status === 403)) this.fail("Slack authentication or permission denied");
|
|
160
|
+
else this.reconnect();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
private reconnect(): void {
|
|
164
|
+
if (this.stopped || this.reconnectTimer !== undefined) return;
|
|
165
|
+
this.ready = false;
|
|
166
|
+
this.clear(this.handshakeTimer);
|
|
167
|
+
this.clear(this.stabilityTimer);
|
|
168
|
+
const ws = this.socket;
|
|
169
|
+
this.socket = undefined;
|
|
170
|
+
try { ws?.close(1000); } catch {}
|
|
171
|
+
const max = Math.max(0, Math.min(this.deps.maxReconnects ?? 8, 8));
|
|
172
|
+
if (++this.reconnects > max) { this.fail("Slack Socket Mode reconnect limit reached"); return; }
|
|
173
|
+
this.reconnectTimer = this.later(() => { void this.connectSocket(); }, Math.min(1000 * 2 ** (this.reconnects - 1), 30_000));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private scheduleScan(): void {
|
|
177
|
+
this.later(() => { void this.scanSessions().finally(() => { if (!this.stopped) this.scheduleScan(); }); }, this.deps.scanIntervalMs ?? 3000);
|
|
178
|
+
}
|
|
179
|
+
async scanSessions(): Promise<void> {
|
|
180
|
+
if (this.stopped || !this.ready || this.scanning) return;
|
|
181
|
+
this.scanning = true;
|
|
182
|
+
try {
|
|
183
|
+
const dir = notifySessionsDir();
|
|
184
|
+
const files = await (this.deps.readdir ?? (p => fs.readdir(p)))(dir).catch(() => []);
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
if (this.stopped || !this.ready) return;
|
|
187
|
+
if (!/^[a-zA-Z0-9_-]+\.json$/.test(file)) continue;
|
|
188
|
+
const id = file.slice(0, -5);
|
|
189
|
+
if (this.sessions.has(id)) continue;
|
|
190
|
+
const raw = await (this.deps.readFile ?? (p => fs.readFile(p, "utf8")))(path.join(dir, file)).catch(() => "");
|
|
191
|
+
if (this.stopped || !this.ready) return;
|
|
192
|
+
const info = parseSessionEndpoint(raw);
|
|
193
|
+
if (!info || !(this.deps.isPidAlive ?? isPidAlive)(info.pid)) continue;
|
|
194
|
+
const url = new URL(info.url);
|
|
195
|
+
url.searchParams.set("token", info.token);
|
|
196
|
+
try {
|
|
197
|
+
const WS = this.deps.WebSocketImpl ?? WebSocket;
|
|
198
|
+
const ws = new WS(url.href);
|
|
199
|
+
const session: SlackSession = { sessionId: id, cwd: info.cwd, pid: info.pid, ws, verified: false, records: [] };
|
|
200
|
+
this.sessions.set(id, session);
|
|
201
|
+
const timeout = this.later(() => { if (!session.verified) { this.disconnectSession(session); try { ws.close(); } catch {} } }, 10_000);
|
|
202
|
+
ws.onmessage = event => {
|
|
203
|
+
if (this.stopped || this.sessions.get(id) !== session || typeof event.data !== "string" || event.data.length > 1_048_576) return;
|
|
204
|
+
let frame: unknown;
|
|
205
|
+
try { frame = JSON.parse(event.data); } catch { return; }
|
|
206
|
+
if (!object(frame)) return;
|
|
207
|
+
if (!session.verified) {
|
|
208
|
+
if (frame.type !== "snapshot" || frame.sessionId !== id || frame.pid !== info.pid || !Array.isArray(frame.subagents)) {
|
|
209
|
+
this.clear(timeout); this.disconnectSession(session); try { ws.close(); } catch {} return;
|
|
210
|
+
}
|
|
211
|
+
session.verified = true;
|
|
212
|
+
this.clear(timeout);
|
|
213
|
+
}
|
|
214
|
+
this.onSessionFrame(session, frame);
|
|
215
|
+
};
|
|
216
|
+
ws.onclose = () => { this.clear(timeout); this.disconnectSession(session); };
|
|
217
|
+
ws.onerror = () => { this.clear(timeout); this.disconnectSession(session); try { ws.close(); } catch {} };
|
|
218
|
+
} catch { this.deps.onError?.("Local session connection failed"); }
|
|
219
|
+
}
|
|
220
|
+
} finally { this.scanning = false; }
|
|
221
|
+
}
|
|
222
|
+
private disconnectSession(session: SlackSession): void {
|
|
223
|
+
if (this.sessions.get(session.sessionId) !== session) return;
|
|
224
|
+
this.sessions.delete(session.sessionId);
|
|
225
|
+
for (const request of this.pending.values()) if (request.session === session) request.finish(false);
|
|
226
|
+
}
|
|
227
|
+
private onSessionFrame(session: SlackSession, frame: Record<string, unknown>): void {
|
|
228
|
+
if (frame.type === "ack" && typeof frame.reqId === "string" && typeof frame.ok === "boolean") {
|
|
229
|
+
const request = this.pending.get(frame.reqId);
|
|
230
|
+
if (request?.session === session) request.finish(frame.ok);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (frame.sessionId !== session.sessionId) return;
|
|
234
|
+
const header = `[${session.sessionId}]`;
|
|
235
|
+
if (frame.type === "snapshot" && frame.pid === session.pid && Array.isArray(frame.subagents)) {
|
|
236
|
+
const records = frame.subagents.filter((r): r is AgentState => object(r) && typeof r.id === "string" && typeof r.role === "string" &&
|
|
237
|
+
typeof r.task === "string" && typeof r.status === "string" && ["running", "completed", "failed", "cancelled"].includes(r.status) &&
|
|
238
|
+
(r.result === undefined || typeof r.result === "string"));
|
|
239
|
+
const previous = new Map(session.records.map(r => [r.id, r.status]));
|
|
240
|
+
for (const record of records) {
|
|
241
|
+
const before = previous.get(record.id);
|
|
242
|
+
if ((!before && record.status === "running") || (before === "running" && record.status !== "running")) {
|
|
243
|
+
this.emit(`${header} ${record.role} '${record.id}' ${record.status}\n${record.status === "running" ? record.task : record.result ?? ""}`, session.sessionId);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
session.records = records;
|
|
247
|
+
} else if (frame.type === "identity_header" && typeof frame.repo === "string") {
|
|
248
|
+
this.emit(`${header} ${frame.repo}${typeof frame.branch === "string" ? ` (${frame.branch})` : ""}`, session.sessionId);
|
|
249
|
+
} else if (frame.type === "context_update" && ["turn_start", "turn_end"].includes(String(frame.phase)) && typeof frame.summary === "string") {
|
|
250
|
+
this.emit(`${header} ${frame.phase}\n${frame.summary}`, session.sessionId);
|
|
251
|
+
} else if (frame.type === "turn_stream" && frame.phase === "finalized" && typeof frame.text === "string" && frame.text.trim()) {
|
|
252
|
+
this.emit(`${header}\n${frame.text}`, session.sessionId);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
private emit(text: string, sessionId?: string, threadTs?: string): void {
|
|
256
|
+
if (this.stopped) return;
|
|
257
|
+
if (this.queued >= 128) { this.deps.onError?.("Slack outbound queue full"); return; }
|
|
258
|
+
this.queued++;
|
|
259
|
+
this.outgoing = this.outgoing.then(async () => {
|
|
260
|
+
if (this.stopped) return;
|
|
261
|
+
const messages = await this.api.sendMessage(this.config.channelId, text, threadTs);
|
|
262
|
+
this.rememberReplies(messages, sessionId, threadTs);
|
|
263
|
+
}).catch(error => {
|
|
264
|
+
if (this.stopped) return;
|
|
265
|
+
if (error instanceof SlackApiError) this.rememberReplies(error.sentMessages, sessionId, threadTs);
|
|
266
|
+
if (error instanceof SlackApiError && (error.status === 401 || error.status === 403)) this.fail("Slack authentication or permission denied");
|
|
267
|
+
else this.deps.onError?.("Slack notification delivery failed");
|
|
268
|
+
}).finally(() => { this.queued--; });
|
|
269
|
+
}
|
|
270
|
+
private rememberReplies(messages: SlackMessage[], sessionId?: string, threadTs?: string): void {
|
|
271
|
+
if (this.stopped || !sessionId) return;
|
|
272
|
+
for (const message of messages) {
|
|
273
|
+
this.replies.set(threadTs ?? message.ts, { sessionId, at: (this.deps.now ?? Date.now)() });
|
|
274
|
+
if (this.replies.size > 1024) this.replies.delete(this.replies.keys().next().value!);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
private findSession(prefix: string): SlackSession | undefined {
|
|
278
|
+
const live = [...this.sessions.values()].filter(s => s.verified && s.ws.readyState === 1);
|
|
279
|
+
const exact = live.find(s => s.sessionId === prefix);
|
|
280
|
+
if (exact) return exact;
|
|
281
|
+
const matches = live.filter(s => s.sessionId.startsWith(prefix) || s.sessionId.replace(/-/g, "").startsWith(prefix));
|
|
282
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
283
|
+
}
|
|
284
|
+
private request(session: SlackSession, frame: Record<string, unknown>): Promise<boolean> {
|
|
285
|
+
if (this.stopped || !session.verified || session.ws.readyState !== 1 || this.pending.size >= 128) return Promise.resolve(false);
|
|
286
|
+
const reqId = crypto.randomUUID();
|
|
287
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
288
|
+
const timer = this.later(() => finish(false), this.deps.ackTimeoutMs ?? 5000);
|
|
289
|
+
const finish = (ok: boolean) => { this.clear(timer); this.pending.delete(reqId); resolve(ok); };
|
|
290
|
+
this.pending.set(reqId, { session, finish });
|
|
291
|
+
try { session.ws.send(JSON.stringify({ ...frame, reqId })); } catch { finish(false); }
|
|
292
|
+
return promise;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async handleEnvelope(frame: Record<string, unknown>): Promise<void> {
|
|
296
|
+
const payload = frame.payload;
|
|
297
|
+
if (!object(payload) || payload.team_id !== this.identity?.team_id) return;
|
|
298
|
+
const keys = [`envelope:${frame.envelope_id}`];
|
|
299
|
+
let text: string;
|
|
300
|
+
let threadTs: string | undefined;
|
|
301
|
+
if (frame.type === "events_api") {
|
|
302
|
+
const event = payload.event;
|
|
303
|
+
if (payload.type !== "event_callback" || payload.api_app_id !== this.identity?.app_id || typeof payload.event_id !== "string" ||
|
|
304
|
+
!payload.event_id || payload.event_id.length > 256 || !object(event) || !["message", "app_mention"].includes(String(event.type)) ||
|
|
305
|
+
event.channel !== this.config.channelId || !isSlackUserId(event.user) || !this.allowed.has(event.user) || event.user === this.identity?.user_id ||
|
|
306
|
+
"bot_id" in event || "bot_profile" in event || "subtype" in event || "app_id" in event ||
|
|
307
|
+
(event.team !== undefined && event.team !== this.identity?.team_id) || !isSlackTimestamp(event.ts) ||
|
|
308
|
+
typeof event.text !== "string" || event.text.length > 4000 || !event.text.trim() ||
|
|
309
|
+
(event.thread_ts !== undefined && !isSlackTimestamp(event.thread_ts))) return;
|
|
310
|
+
text = event.text;
|
|
311
|
+
threadTs = event.thread_ts as string | undefined;
|
|
312
|
+
keys.push(`event:${payload.event_id}`, `message:${event.channel}:${event.ts}`);
|
|
313
|
+
} else if (frame.type === "slash_commands") {
|
|
314
|
+
if (payload.channel_id !== this.config.channelId || !isSlackUserId(payload.user_id) || !this.allowed.has(payload.user_id) ||
|
|
315
|
+
payload.user_id === this.identity?.user_id || (payload.api_app_id !== undefined && payload.api_app_id !== this.identity?.app_id) ||
|
|
316
|
+
typeof payload.command !== "string" || !/^\/(sessions|subagents|send|steer|cancel|help)$/.test(payload.command) ||
|
|
317
|
+
typeof payload.text !== "string" || payload.text.length > 4000 || typeof payload.trigger_id !== "string" || !payload.trigger_id || payload.trigger_id.length > 256) return;
|
|
318
|
+
text = `${payload.command} ${payload.text}`;
|
|
319
|
+
keys.push(`trigger:${payload.trigger_id}`);
|
|
320
|
+
} else return;
|
|
321
|
+
if (keys.some(key => this.seen.has(key))) return;
|
|
322
|
+
for (const key of keys) { this.seen.add(key); if (this.seen.size > 6144) this.seen.delete(this.seen.values().next().value!); }
|
|
323
|
+
text = text.trim();
|
|
324
|
+
const mention = `<@${this.identity!.user_id}>`;
|
|
325
|
+
const addressed = text.startsWith(mention);
|
|
326
|
+
if (text.startsWith(mention)) text = text.slice(mention.length).trim();
|
|
327
|
+
text = text.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
328
|
+
if (!text) return;
|
|
329
|
+
if (text === "/sessions" || text === "/subagents") {
|
|
330
|
+
const sessions = [...this.sessions.values()].filter(s => s.verified && s.ws.readyState === 1);
|
|
331
|
+
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.", undefined, threadTs);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (text === "/help") { this.emit(HELP, undefined, threadTs); return; }
|
|
335
|
+
const command = /^\/(send|steer|cancel)\s+(\S+)\s+([\s\S]+)$/.exec(text);
|
|
336
|
+
if (command) {
|
|
337
|
+
const session = this.findSession(command[2]!);
|
|
338
|
+
if (!session) { this.emit("Session not found or prefix ambiguous. Use /sessions and a unique ID.", undefined, threadTs); return; }
|
|
339
|
+
const body = command[3]!.trim();
|
|
340
|
+
let local: Record<string, unknown>;
|
|
341
|
+
if (command[1] === "send") {
|
|
342
|
+
if (body.startsWith("/")) { this.emit("Remote slash commands are disabled; send ordinary prompt text.", undefined, threadTs); return; }
|
|
343
|
+
local = { type: "user_message", text: body };
|
|
344
|
+
} else {
|
|
345
|
+
const args = command[1] === "steer" ? /^(\S+)\s+([\s\S]+)$/.exec(body) : /^(\S+)$/.exec(body);
|
|
346
|
+
if (!args) { this.emit(HELP, undefined, threadTs); return; }
|
|
347
|
+
local = command[1] === "steer" ? { type: "steer", id: args[1], message: args[2] } : { type: "cancel", ids: [args[1]] };
|
|
348
|
+
}
|
|
349
|
+
const ok = await this.request(session, local);
|
|
350
|
+
this.emit(ok ? "Session acknowledged command (accepted, not completed)." : "Command not acknowledged (unavailable, rejected or timed out).", session.sessionId, threadTs);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (text.startsWith("/")) { this.emit(HELP, undefined, threadTs); return; }
|
|
354
|
+
const reply = threadTs ? this.replies.get(threadTs) : undefined;
|
|
355
|
+
if (!addressed && !reply) return;
|
|
356
|
+
const session = reply && (this.deps.now ?? Date.now)() - reply.at < 86_400_000 ? this.findSession(reply.sessionId) : undefined;
|
|
357
|
+
if (!session) { this.emit("Reply target is unknown or expired. Use /send <unique-session> <text> after /sessions.", undefined, threadTs); return; }
|
|
358
|
+
const ok = await this.request(session, { type: "user_message", text });
|
|
359
|
+
this.emit(ok ? "Session acknowledged command (accepted, not completed)." : "Command not acknowledged (unavailable, rejected or timed out).", session.sessionId, threadTs);
|
|
360
|
+
}
|
|
361
|
+
}
|