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,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
|
+
}
|