afk-code 0.1.0 → 0.1.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/LICENSE +21 -0
- package/README.md +64 -97
- package/dist/cli/index.js +1972 -0
- package/package.json +13 -9
- package/slack-manifest.json +3 -3
- package/src/cli/discord.ts +0 -183
- package/src/cli/index.ts +0 -83
- package/src/cli/run.ts +0 -126
- package/src/cli/slack.ts +0 -193
- package/src/discord/channel-manager.ts +0 -191
- package/src/discord/discord-app.ts +0 -359
- package/src/discord/types.ts +0 -4
- package/src/slack/channel-manager.ts +0 -175
- package/src/slack/index.ts +0 -58
- package/src/slack/message-formatter.ts +0 -91
- package/src/slack/session-manager.ts +0 -567
- package/src/slack/slack-app.ts +0 -443
- package/src/slack/types.ts +0 -6
- package/src/types/index.ts +0 -6
- package/src/utils/image-extractor.ts +0 -72
|
@@ -0,0 +1,1972 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/slack/session-manager.ts
|
|
13
|
+
import { watch } from "fs";
|
|
14
|
+
import { readdir, readFile, stat, unlink } from "fs/promises";
|
|
15
|
+
import { createServer } from "net";
|
|
16
|
+
import { createHash } from "crypto";
|
|
17
|
+
function hash(data) {
|
|
18
|
+
return createHash("md5").update(data).digest("hex");
|
|
19
|
+
}
|
|
20
|
+
var DAEMON_SOCKET2, SessionManager;
|
|
21
|
+
var init_session_manager = __esm({
|
|
22
|
+
"src/slack/session-manager.ts"() {
|
|
23
|
+
"use strict";
|
|
24
|
+
DAEMON_SOCKET2 = "/tmp/afk-code-daemon.sock";
|
|
25
|
+
SessionManager = class {
|
|
26
|
+
sessions = /* @__PURE__ */ new Map();
|
|
27
|
+
claimedFiles = /* @__PURE__ */ new Set();
|
|
28
|
+
events;
|
|
29
|
+
server = null;
|
|
30
|
+
constructor(events) {
|
|
31
|
+
this.events = events;
|
|
32
|
+
}
|
|
33
|
+
async start() {
|
|
34
|
+
try {
|
|
35
|
+
await unlink(DAEMON_SOCKET2);
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
this.server = createServer((socket) => {
|
|
39
|
+
let messageBuffer = "";
|
|
40
|
+
socket.on("data", (data) => {
|
|
41
|
+
messageBuffer += data.toString();
|
|
42
|
+
const lines = messageBuffer.split("\n");
|
|
43
|
+
messageBuffer = lines.pop() || "";
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (!line.trim()) continue;
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(line);
|
|
48
|
+
this.handleSessionMessage(socket, parsed);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error("[SessionManager] Error parsing message:", error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
socket.on("error", (error) => {
|
|
55
|
+
console.error("[SessionManager] Socket error:", error);
|
|
56
|
+
});
|
|
57
|
+
socket.on("close", () => {
|
|
58
|
+
for (const [id, session] of this.sessions) {
|
|
59
|
+
if (session.socket === socket) {
|
|
60
|
+
console.log(`[SessionManager] Session disconnected: ${id}`);
|
|
61
|
+
this.stopWatching(session);
|
|
62
|
+
this.sessions.delete(id);
|
|
63
|
+
this.events.onSessionEnd(id);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
this.server.listen(DAEMON_SOCKET2, () => {
|
|
70
|
+
console.log(`[SessionManager] Listening on ${DAEMON_SOCKET2}`);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
stop() {
|
|
74
|
+
for (const session of this.sessions.values()) {
|
|
75
|
+
this.stopWatching(session);
|
|
76
|
+
}
|
|
77
|
+
this.sessions.clear();
|
|
78
|
+
if (this.server) {
|
|
79
|
+
this.server.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
sendInput(sessionId, text) {
|
|
83
|
+
const session = this.sessions.get(sessionId);
|
|
84
|
+
if (!session) {
|
|
85
|
+
console.error(`[SessionManager] Session not found: ${sessionId}`);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
session.socket.write(JSON.stringify({ type: "input", text }) + "\n");
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(`[SessionManager] Failed to send input to ${sessionId}:`, err);
|
|
92
|
+
this.stopWatching(session);
|
|
93
|
+
this.sessions.delete(sessionId);
|
|
94
|
+
this.events.onSessionEnd(sessionId);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
try {
|
|
99
|
+
session.socket.write(JSON.stringify({ type: "input", text: "\r" }) + "\n");
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
}, 50);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
getSession(sessionId) {
|
|
106
|
+
const session = this.sessions.get(sessionId);
|
|
107
|
+
if (!session) return void 0;
|
|
108
|
+
return {
|
|
109
|
+
id: session.id,
|
|
110
|
+
name: session.name,
|
|
111
|
+
cwd: session.cwd,
|
|
112
|
+
projectDir: session.projectDir,
|
|
113
|
+
status: session.status,
|
|
114
|
+
startedAt: session.startedAt
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
getAllSessions() {
|
|
118
|
+
return Array.from(this.sessions.values()).map((s) => ({
|
|
119
|
+
id: s.id,
|
|
120
|
+
name: s.name,
|
|
121
|
+
cwd: s.cwd,
|
|
122
|
+
projectDir: s.projectDir,
|
|
123
|
+
status: s.status,
|
|
124
|
+
startedAt: s.startedAt
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
async handleSessionMessage(socket, message) {
|
|
128
|
+
switch (message.type) {
|
|
129
|
+
case "session_start": {
|
|
130
|
+
const initialFileStats = await this.snapshotJsonlFiles(message.projectDir);
|
|
131
|
+
const session = {
|
|
132
|
+
id: message.id,
|
|
133
|
+
name: message.name || message.command?.join(" ") || "Session",
|
|
134
|
+
cwd: message.cwd,
|
|
135
|
+
projectDir: message.projectDir,
|
|
136
|
+
socket,
|
|
137
|
+
status: "running",
|
|
138
|
+
seenMessages: /* @__PURE__ */ new Set(),
|
|
139
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
140
|
+
slugFound: false,
|
|
141
|
+
lastTodosHash: "",
|
|
142
|
+
inPlanMode: false,
|
|
143
|
+
initialFileStats
|
|
144
|
+
};
|
|
145
|
+
this.sessions.set(message.id, session);
|
|
146
|
+
console.log(`[SessionManager] Session started: ${message.id} - ${session.name}`);
|
|
147
|
+
console.log(`[SessionManager] Snapshot: ${initialFileStats.size} existing JSONL files`);
|
|
148
|
+
this.events.onSessionStart({
|
|
149
|
+
id: session.id,
|
|
150
|
+
name: session.name,
|
|
151
|
+
cwd: session.cwd,
|
|
152
|
+
projectDir: session.projectDir,
|
|
153
|
+
status: session.status,
|
|
154
|
+
startedAt: session.startedAt
|
|
155
|
+
});
|
|
156
|
+
this.startWatching(session);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "session_end": {
|
|
160
|
+
const session = this.sessions.get(message.sessionId);
|
|
161
|
+
if (session) {
|
|
162
|
+
console.log(`[SessionManager] Session ended: ${message.sessionId}`);
|
|
163
|
+
this.stopWatching(session);
|
|
164
|
+
this.sessions.delete(message.sessionId);
|
|
165
|
+
this.events.onSessionEnd(message.sessionId);
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async snapshotJsonlFiles(projectDir) {
|
|
172
|
+
const stats = /* @__PURE__ */ new Map();
|
|
173
|
+
try {
|
|
174
|
+
const files = await readdir(projectDir);
|
|
175
|
+
for (const f of files) {
|
|
176
|
+
if (f.endsWith(".jsonl") && !f.startsWith("agent-")) {
|
|
177
|
+
const path = `${projectDir}/${f}`;
|
|
178
|
+
const fileStat = await stat(path);
|
|
179
|
+
stats.set(path, fileStat.mtimeMs);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
}
|
|
184
|
+
return stats;
|
|
185
|
+
}
|
|
186
|
+
async hasConversationMessages(path) {
|
|
187
|
+
try {
|
|
188
|
+
const content = await readFile(path, "utf-8");
|
|
189
|
+
return content.includes('"type":"user"') || content.includes('"type":"assistant"');
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async findActiveJsonlFile(session) {
|
|
195
|
+
try {
|
|
196
|
+
const files = await readdir(session.projectDir);
|
|
197
|
+
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-"));
|
|
198
|
+
const allPaths = jsonlFiles.map((f) => `${session.projectDir}/${f}`).filter((path) => !this.claimedFiles.has(path));
|
|
199
|
+
if (allPaths.length === 0) return null;
|
|
200
|
+
const fileStats = await Promise.all(
|
|
201
|
+
allPaths.map(async (path) => {
|
|
202
|
+
const fileStat = await stat(path);
|
|
203
|
+
return { path, mtime: fileStat.mtimeMs };
|
|
204
|
+
})
|
|
205
|
+
);
|
|
206
|
+
fileStats.sort((a, b) => b.mtime - a.mtime);
|
|
207
|
+
for (const { path, mtime } of fileStats) {
|
|
208
|
+
const initialMtime = session.initialFileStats.get(path);
|
|
209
|
+
if (initialMtime !== void 0 && mtime > initialMtime) {
|
|
210
|
+
if (await this.hasConversationMessages(path)) {
|
|
211
|
+
console.log(`[SessionManager] Found modified JSONL (--continue): ${path}`);
|
|
212
|
+
return path;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const { path } of fileStats) {
|
|
217
|
+
const initialMtime = session.initialFileStats.get(path);
|
|
218
|
+
if (initialMtime === void 0) {
|
|
219
|
+
if (await this.hasConversationMessages(path)) {
|
|
220
|
+
console.log(`[SessionManager] Found new JSONL: ${path}`);
|
|
221
|
+
return path;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async processJsonlUpdates(session) {
|
|
231
|
+
if (!session.watchedFile) return;
|
|
232
|
+
try {
|
|
233
|
+
const content = await readFile(session.watchedFile, "utf-8");
|
|
234
|
+
const lines = content.split("\n").filter(Boolean);
|
|
235
|
+
for (const line of lines) {
|
|
236
|
+
const lineHash = hash(line);
|
|
237
|
+
if (session.seenMessages.has(lineHash)) continue;
|
|
238
|
+
session.seenMessages.add(lineHash);
|
|
239
|
+
if (!session.slugFound) {
|
|
240
|
+
const slug = this.extractSlug(line);
|
|
241
|
+
if (slug) {
|
|
242
|
+
session.slugFound = true;
|
|
243
|
+
session.name = slug;
|
|
244
|
+
console.log(`[SessionManager] Session ${session.id} name: ${slug}`);
|
|
245
|
+
this.events.onSessionUpdate(session.id, slug);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const todos = this.extractTodos(line);
|
|
249
|
+
if (todos) {
|
|
250
|
+
const todosHash = hash(JSON.stringify(todos));
|
|
251
|
+
if (todosHash !== session.lastTodosHash) {
|
|
252
|
+
session.lastTodosHash = todosHash;
|
|
253
|
+
this.events.onTodos(session.id, todos);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const planModeStatus = this.detectPlanMode(line);
|
|
257
|
+
if (planModeStatus !== null && planModeStatus !== session.inPlanMode) {
|
|
258
|
+
session.inPlanMode = planModeStatus;
|
|
259
|
+
console.log(`[SessionManager] Session ${session.id} plan mode: ${planModeStatus}`);
|
|
260
|
+
this.events.onPlanModeChange(session.id, planModeStatus);
|
|
261
|
+
}
|
|
262
|
+
const toolCalls = this.extractToolCalls(line);
|
|
263
|
+
for (const tool of toolCalls) {
|
|
264
|
+
this.events.onToolCall(session.id, tool);
|
|
265
|
+
}
|
|
266
|
+
const toolResults = this.extractToolResults(line);
|
|
267
|
+
for (const result of toolResults) {
|
|
268
|
+
this.events.onToolResult(session.id, result);
|
|
269
|
+
}
|
|
270
|
+
const parsed = this.parseJsonlLine(line);
|
|
271
|
+
if (parsed) {
|
|
272
|
+
const messageTime = new Date(parsed.timestamp);
|
|
273
|
+
if (messageTime < session.startedAt) continue;
|
|
274
|
+
this.events.onMessage(session.id, parsed.role, parsed.content);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error("[SessionManager] Error processing JSONL:", err);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async startWatching(session) {
|
|
282
|
+
const jsonlFile = await this.findActiveJsonlFile(session);
|
|
283
|
+
if (jsonlFile) {
|
|
284
|
+
session.watchedFile = jsonlFile;
|
|
285
|
+
this.claimedFiles.add(jsonlFile);
|
|
286
|
+
console.log(`[SessionManager] Watching: ${jsonlFile}`);
|
|
287
|
+
await this.processJsonlUpdates(session);
|
|
288
|
+
} else {
|
|
289
|
+
console.log(`[SessionManager] Waiting for JSONL changes in ${session.projectDir}`);
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
session.watcher = watch(session.projectDir, { recursive: false }, async (_, filename) => {
|
|
293
|
+
if (!filename?.endsWith(".jsonl")) return;
|
|
294
|
+
if (!session.watchedFile) {
|
|
295
|
+
const newFile = await this.findActiveJsonlFile(session);
|
|
296
|
+
if (newFile) {
|
|
297
|
+
session.watchedFile = newFile;
|
|
298
|
+
this.claimedFiles.add(newFile);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const filePath = `${session.projectDir}/${filename}`;
|
|
302
|
+
if (session.watchedFile && filePath === session.watchedFile) {
|
|
303
|
+
await this.processJsonlUpdates(session);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.error("[SessionManager] Error setting up watcher:", err);
|
|
308
|
+
}
|
|
309
|
+
const pollInterval = setInterval(async () => {
|
|
310
|
+
if (!this.sessions.has(session.id)) {
|
|
311
|
+
clearInterval(pollInterval);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (!session.watchedFile) {
|
|
315
|
+
const newFile = await this.findActiveJsonlFile(session);
|
|
316
|
+
if (newFile) {
|
|
317
|
+
session.watchedFile = newFile;
|
|
318
|
+
this.claimedFiles.add(newFile);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (session.watchedFile) {
|
|
322
|
+
await this.processJsonlUpdates(session);
|
|
323
|
+
}
|
|
324
|
+
}, 1e3);
|
|
325
|
+
}
|
|
326
|
+
stopWatching(session) {
|
|
327
|
+
if (session.watcher) {
|
|
328
|
+
session.watcher.close();
|
|
329
|
+
}
|
|
330
|
+
if (session.watchedFile) {
|
|
331
|
+
this.claimedFiles.delete(session.watchedFile);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
detectPlanMode(line) {
|
|
335
|
+
try {
|
|
336
|
+
const data = JSON.parse(line);
|
|
337
|
+
if (data.type !== "user") return null;
|
|
338
|
+
const content = data.message?.content;
|
|
339
|
+
if (typeof content !== "string") return null;
|
|
340
|
+
if (content.includes("<system-reminder>") && content.includes("Plan mode is active")) {
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
if (content.includes("Exited Plan Mode") || content.includes("exited plan mode")) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
} catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
extractToolCalls(line) {
|
|
352
|
+
try {
|
|
353
|
+
const data = JSON.parse(line);
|
|
354
|
+
if (data.type !== "assistant") return [];
|
|
355
|
+
const content = data.message?.content;
|
|
356
|
+
if (!Array.isArray(content)) return [];
|
|
357
|
+
const tools = [];
|
|
358
|
+
for (const block of content) {
|
|
359
|
+
if (block.type === "tool_use" && block.id && block.name) {
|
|
360
|
+
tools.push({
|
|
361
|
+
id: block.id,
|
|
362
|
+
name: block.name,
|
|
363
|
+
input: block.input || {}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return tools;
|
|
368
|
+
} catch {
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
extractToolResults(line) {
|
|
373
|
+
try {
|
|
374
|
+
const data = JSON.parse(line);
|
|
375
|
+
if (data.type !== "user") return [];
|
|
376
|
+
const content = data.message?.content;
|
|
377
|
+
if (!Array.isArray(content)) return [];
|
|
378
|
+
const results = [];
|
|
379
|
+
for (const block of content) {
|
|
380
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
381
|
+
let text = "";
|
|
382
|
+
if (typeof block.content === "string") {
|
|
383
|
+
text = block.content;
|
|
384
|
+
} else if (Array.isArray(block.content)) {
|
|
385
|
+
text = block.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
386
|
+
}
|
|
387
|
+
results.push({
|
|
388
|
+
toolUseId: block.tool_use_id,
|
|
389
|
+
content: text,
|
|
390
|
+
isError: block.is_error === true
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return results;
|
|
395
|
+
} catch {
|
|
396
|
+
return [];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
extractSlug(line) {
|
|
400
|
+
try {
|
|
401
|
+
const data = JSON.parse(line);
|
|
402
|
+
if (data.slug && typeof data.slug === "string") {
|
|
403
|
+
return data.slug;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
} catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
extractTodos(line) {
|
|
411
|
+
try {
|
|
412
|
+
const data = JSON.parse(line);
|
|
413
|
+
if (data.todos && Array.isArray(data.todos) && data.todos.length > 0) {
|
|
414
|
+
return data.todos.map((t) => ({
|
|
415
|
+
content: t.content || "",
|
|
416
|
+
status: t.status || "pending",
|
|
417
|
+
activeForm: t.activeForm
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
parseJsonlLine(line) {
|
|
426
|
+
try {
|
|
427
|
+
const data = JSON.parse(line);
|
|
428
|
+
if (data.type !== "user" && data.type !== "assistant") return null;
|
|
429
|
+
if (data.isMeta || data.subtype) return null;
|
|
430
|
+
const message = data.message;
|
|
431
|
+
if (!message || !message.role) return null;
|
|
432
|
+
let content = "";
|
|
433
|
+
if (typeof message.content === "string") {
|
|
434
|
+
content = message.content;
|
|
435
|
+
} else if (Array.isArray(message.content)) {
|
|
436
|
+
for (const block of message.content) {
|
|
437
|
+
if (block.type === "text" && block.text) {
|
|
438
|
+
content += block.text;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (!content.trim()) return null;
|
|
443
|
+
return {
|
|
444
|
+
role: message.role,
|
|
445
|
+
content: content.trim(),
|
|
446
|
+
timestamp: data.timestamp || (/* @__PURE__ */ new Date()).toISOString()
|
|
447
|
+
};
|
|
448
|
+
} catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// src/slack/channel-manager.ts
|
|
457
|
+
function sanitizeChannelName(name) {
|
|
458
|
+
return name.toLowerCase().replace(/[^a-z0-9-_\s]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 70);
|
|
459
|
+
}
|
|
460
|
+
var ChannelManager;
|
|
461
|
+
var init_channel_manager = __esm({
|
|
462
|
+
"src/slack/channel-manager.ts"() {
|
|
463
|
+
"use strict";
|
|
464
|
+
ChannelManager = class {
|
|
465
|
+
channels = /* @__PURE__ */ new Map();
|
|
466
|
+
channelToSession = /* @__PURE__ */ new Map();
|
|
467
|
+
client;
|
|
468
|
+
userId;
|
|
469
|
+
constructor(client, userId) {
|
|
470
|
+
this.client = client;
|
|
471
|
+
this.userId = userId;
|
|
472
|
+
}
|
|
473
|
+
async createChannel(sessionId, sessionName, cwd) {
|
|
474
|
+
if (this.channels.has(sessionId)) {
|
|
475
|
+
return this.channels.get(sessionId);
|
|
476
|
+
}
|
|
477
|
+
const folderName = cwd.split("/").filter(Boolean).pop() || "session";
|
|
478
|
+
const baseName = `afk-${sanitizeChannelName(folderName)}`;
|
|
479
|
+
let channelName = baseName;
|
|
480
|
+
let suffix = 1;
|
|
481
|
+
let result;
|
|
482
|
+
while (true) {
|
|
483
|
+
const nameToTry = channelName.length > 80 ? channelName.slice(0, 80) : channelName;
|
|
484
|
+
try {
|
|
485
|
+
result = await this.client.conversations.create({
|
|
486
|
+
name: nameToTry,
|
|
487
|
+
is_private: true
|
|
488
|
+
});
|
|
489
|
+
channelName = nameToTry;
|
|
490
|
+
break;
|
|
491
|
+
} catch (err) {
|
|
492
|
+
if (err.data?.error === "name_taken") {
|
|
493
|
+
suffix++;
|
|
494
|
+
channelName = `${baseName}-${suffix}`;
|
|
495
|
+
} else {
|
|
496
|
+
throw err;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (!result?.channel?.id) {
|
|
501
|
+
console.error("[ChannelManager] Failed to create channel - no ID returned");
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
const mapping = {
|
|
505
|
+
sessionId,
|
|
506
|
+
channelId: result.channel.id,
|
|
507
|
+
channelName,
|
|
508
|
+
sessionName,
|
|
509
|
+
status: "running",
|
|
510
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
511
|
+
};
|
|
512
|
+
this.channels.set(sessionId, mapping);
|
|
513
|
+
this.channelToSession.set(result.channel.id, sessionId);
|
|
514
|
+
try {
|
|
515
|
+
await this.client.conversations.setTopic({
|
|
516
|
+
channel: result.channel.id,
|
|
517
|
+
topic: `Claude Code session: ${sessionName}`
|
|
518
|
+
});
|
|
519
|
+
} catch (err) {
|
|
520
|
+
console.error("[ChannelManager] Failed to set topic:", err.message);
|
|
521
|
+
}
|
|
522
|
+
if (this.userId) {
|
|
523
|
+
try {
|
|
524
|
+
await this.client.conversations.invite({
|
|
525
|
+
channel: result.channel.id,
|
|
526
|
+
users: this.userId
|
|
527
|
+
});
|
|
528
|
+
console.log(`[ChannelManager] Invited user to channel`);
|
|
529
|
+
} catch (err) {
|
|
530
|
+
if (err.data?.error !== "already_in_channel") {
|
|
531
|
+
console.error("[ChannelManager] Failed to invite user:", err.message);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
console.log(`[ChannelManager] Created channel #${channelName} for session ${sessionId}`);
|
|
536
|
+
return mapping;
|
|
537
|
+
}
|
|
538
|
+
async archiveChannel(sessionId) {
|
|
539
|
+
const mapping = this.channels.get(sessionId);
|
|
540
|
+
if (!mapping) return false;
|
|
541
|
+
try {
|
|
542
|
+
const timestamp = Date.now().toString(36);
|
|
543
|
+
const archivedName = `${mapping.channelName}-archived-${timestamp}`.slice(0, 80);
|
|
544
|
+
await this.client.conversations.rename({
|
|
545
|
+
channel: mapping.channelId,
|
|
546
|
+
name: archivedName
|
|
547
|
+
});
|
|
548
|
+
await this.client.conversations.archive({
|
|
549
|
+
channel: mapping.channelId
|
|
550
|
+
});
|
|
551
|
+
console.log(`[ChannelManager] Archived channel #${mapping.channelName}`);
|
|
552
|
+
return true;
|
|
553
|
+
} catch (err) {
|
|
554
|
+
console.error("[ChannelManager] Failed to archive channel:", err.message);
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
getChannel(sessionId) {
|
|
559
|
+
return this.channels.get(sessionId);
|
|
560
|
+
}
|
|
561
|
+
getSessionByChannel(channelId) {
|
|
562
|
+
return this.channelToSession.get(channelId);
|
|
563
|
+
}
|
|
564
|
+
updateStatus(sessionId, status) {
|
|
565
|
+
const mapping = this.channels.get(sessionId);
|
|
566
|
+
if (mapping) {
|
|
567
|
+
mapping.status = status;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
updateName(sessionId, name) {
|
|
571
|
+
const mapping = this.channels.get(sessionId);
|
|
572
|
+
if (mapping) {
|
|
573
|
+
mapping.sessionName = name;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
getAllActive() {
|
|
577
|
+
return Array.from(this.channels.values()).filter((c) => c.status !== "ended");
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// src/slack/message-formatter.ts
|
|
584
|
+
function markdownToSlack(markdown) {
|
|
585
|
+
let text = markdown;
|
|
586
|
+
text = text.replace(/\*\*(.+?)\*\*/g, "*$1*");
|
|
587
|
+
text = text.replace(/^#{1,6}\s+(.+)$/gm, "*$1*");
|
|
588
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>");
|
|
589
|
+
text = text.replace(/~~(.+?)~~/g, "~$1~");
|
|
590
|
+
return text;
|
|
591
|
+
}
|
|
592
|
+
function chunkMessage(text, maxLength = 39e3) {
|
|
593
|
+
if (text.length <= maxLength) return [text];
|
|
594
|
+
const chunks = [];
|
|
595
|
+
let remaining = text;
|
|
596
|
+
while (remaining.length > 0) {
|
|
597
|
+
if (remaining.length <= maxLength) {
|
|
598
|
+
chunks.push(remaining);
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
let breakPoint = remaining.lastIndexOf("\n", maxLength);
|
|
602
|
+
if (breakPoint === -1 || breakPoint < maxLength / 2) {
|
|
603
|
+
breakPoint = remaining.lastIndexOf(" ", maxLength);
|
|
604
|
+
}
|
|
605
|
+
if (breakPoint === -1 || breakPoint < maxLength / 2) {
|
|
606
|
+
breakPoint = maxLength;
|
|
607
|
+
}
|
|
608
|
+
chunks.push(remaining.slice(0, breakPoint));
|
|
609
|
+
remaining = remaining.slice(breakPoint).trimStart();
|
|
610
|
+
}
|
|
611
|
+
return chunks;
|
|
612
|
+
}
|
|
613
|
+
function formatSessionStatus(status) {
|
|
614
|
+
const icons = {
|
|
615
|
+
running: ":hourglass_flowing_sand:",
|
|
616
|
+
idle: ":white_check_mark:",
|
|
617
|
+
ended: ":stop_sign:"
|
|
618
|
+
};
|
|
619
|
+
const labels = {
|
|
620
|
+
running: "Running",
|
|
621
|
+
idle: "Idle",
|
|
622
|
+
ended: "Ended"
|
|
623
|
+
};
|
|
624
|
+
return `${icons[status]} ${labels[status]}`;
|
|
625
|
+
}
|
|
626
|
+
function formatTodos(todos) {
|
|
627
|
+
if (todos.length === 0) return "";
|
|
628
|
+
const icons = {
|
|
629
|
+
pending: ":white_circle:",
|
|
630
|
+
in_progress: ":large_blue_circle:",
|
|
631
|
+
completed: ":white_check_mark:"
|
|
632
|
+
};
|
|
633
|
+
return todos.map((t) => {
|
|
634
|
+
const icon = icons[t.status] || ":white_circle:";
|
|
635
|
+
const text = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
|
|
636
|
+
return `${icon} ${text}`;
|
|
637
|
+
}).join("\n");
|
|
638
|
+
}
|
|
639
|
+
var init_message_formatter = __esm({
|
|
640
|
+
"src/slack/message-formatter.ts"() {
|
|
641
|
+
"use strict";
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// src/utils/image-extractor.ts
|
|
646
|
+
import { existsSync, statSync } from "fs";
|
|
647
|
+
import { resolve } from "path";
|
|
648
|
+
import { homedir as homedir2 } from "os";
|
|
649
|
+
function extractImagePaths(content, cwd) {
|
|
650
|
+
const images = [];
|
|
651
|
+
const seen = /* @__PURE__ */ new Set();
|
|
652
|
+
PATH_PATTERN.lastIndex = 0;
|
|
653
|
+
let match;
|
|
654
|
+
while ((match = PATH_PATTERN.exec(content)) !== null) {
|
|
655
|
+
const originalPath = (match[1] || match[2]).trim();
|
|
656
|
+
if (!originalPath || seen.has(originalPath)) continue;
|
|
657
|
+
seen.add(originalPath);
|
|
658
|
+
let resolvedPath = originalPath;
|
|
659
|
+
if (resolvedPath.startsWith("~/")) {
|
|
660
|
+
resolvedPath = resolve(homedir2(), resolvedPath.slice(2));
|
|
661
|
+
} else if (resolvedPath.startsWith("./") || resolvedPath.startsWith("../")) {
|
|
662
|
+
resolvedPath = resolve(cwd || process.cwd(), resolvedPath);
|
|
663
|
+
} else if (!resolvedPath.startsWith("/")) {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
if (existsSync(resolvedPath)) {
|
|
668
|
+
const stat2 = statSync(resolvedPath);
|
|
669
|
+
if (stat2.isFile()) {
|
|
670
|
+
const ext = resolvedPath.toLowerCase().slice(resolvedPath.lastIndexOf("."));
|
|
671
|
+
if (IMAGE_EXTENSIONS.has(ext)) {
|
|
672
|
+
images.push({ originalPath, resolvedPath });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
} catch {
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return images;
|
|
680
|
+
}
|
|
681
|
+
var IMAGE_EXTENSIONS, PATH_PATTERN;
|
|
682
|
+
var init_image_extractor = __esm({
|
|
683
|
+
"src/utils/image-extractor.ts"() {
|
|
684
|
+
"use strict";
|
|
685
|
+
IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
686
|
+
".png",
|
|
687
|
+
".jpg",
|
|
688
|
+
".jpeg",
|
|
689
|
+
".gif",
|
|
690
|
+
".webp",
|
|
691
|
+
".svg",
|
|
692
|
+
".bmp",
|
|
693
|
+
".ico",
|
|
694
|
+
".tiff",
|
|
695
|
+
".tif"
|
|
696
|
+
]);
|
|
697
|
+
PATH_PATTERN = /(?:["'`]([^"'`\n]+\.(?:png|jpe?g|gif|webp|svg|bmp|ico|tiff?))|(?:^|[\s(])([~./][^\s)"'`\n]*\.(?:png|jpe?g|gif|webp|svg|bmp|ico|tiff?)))/gi;
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// src/slack/slack-app.ts
|
|
702
|
+
var slack_app_exports = {};
|
|
703
|
+
__export(slack_app_exports, {
|
|
704
|
+
createSlackApp: () => createSlackApp
|
|
705
|
+
});
|
|
706
|
+
import { App, LogLevel } from "@slack/bolt";
|
|
707
|
+
import { createReadStream } from "fs";
|
|
708
|
+
function createSlackApp(config) {
|
|
709
|
+
const app = new App({
|
|
710
|
+
token: config.botToken,
|
|
711
|
+
appToken: config.appToken,
|
|
712
|
+
socketMode: true,
|
|
713
|
+
logLevel: LogLevel.INFO
|
|
714
|
+
});
|
|
715
|
+
const channelManager = new ChannelManager(app.client, config.userId);
|
|
716
|
+
const messageQueue = new MessageQueue();
|
|
717
|
+
const slackSentMessages = /* @__PURE__ */ new Set();
|
|
718
|
+
const sessionManager = new SessionManager({
|
|
719
|
+
onSessionStart: async (session) => {
|
|
720
|
+
const channel = await channelManager.createChannel(session.id, session.name, session.cwd);
|
|
721
|
+
if (channel) {
|
|
722
|
+
await messageQueue.add(
|
|
723
|
+
() => app.client.chat.postMessage({
|
|
724
|
+
channel: channel.channelId,
|
|
725
|
+
text: `${formatSessionStatus(session.status)} *Session started*
|
|
726
|
+
\`${session.cwd}\``,
|
|
727
|
+
mrkdwn: true
|
|
728
|
+
})
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
onSessionEnd: async (sessionId) => {
|
|
733
|
+
const channel = channelManager.getChannel(sessionId);
|
|
734
|
+
if (channel) {
|
|
735
|
+
channelManager.updateStatus(sessionId, "ended");
|
|
736
|
+
await messageQueue.add(
|
|
737
|
+
() => app.client.chat.postMessage({
|
|
738
|
+
channel: channel.channelId,
|
|
739
|
+
text: ":stop_sign: *Session ended* - this channel will be archived"
|
|
740
|
+
})
|
|
741
|
+
);
|
|
742
|
+
await channelManager.archiveChannel(sessionId);
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
onSessionUpdate: async (sessionId, name) => {
|
|
746
|
+
const channel = channelManager.getChannel(sessionId);
|
|
747
|
+
if (channel) {
|
|
748
|
+
channelManager.updateName(sessionId, name);
|
|
749
|
+
try {
|
|
750
|
+
await app.client.conversations.setTopic({
|
|
751
|
+
channel: channel.channelId,
|
|
752
|
+
topic: `Claude Code session: ${name}`
|
|
753
|
+
});
|
|
754
|
+
} catch (err) {
|
|
755
|
+
console.error("[Slack] Failed to update channel topic:", err);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
onSessionStatus: async (sessionId, status) => {
|
|
760
|
+
const channel = channelManager.getChannel(sessionId);
|
|
761
|
+
if (channel) {
|
|
762
|
+
channelManager.updateStatus(sessionId, status);
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
onMessage: async (sessionId, role, content) => {
|
|
766
|
+
const channel = channelManager.getChannel(sessionId);
|
|
767
|
+
if (channel) {
|
|
768
|
+
const formatted = markdownToSlack(content);
|
|
769
|
+
if (role === "user") {
|
|
770
|
+
const contentKey = content.trim();
|
|
771
|
+
if (slackSentMessages.has(contentKey)) {
|
|
772
|
+
slackSentMessages.delete(contentKey);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const chunks = chunkMessage(formatted);
|
|
776
|
+
for (const chunk of chunks) {
|
|
777
|
+
try {
|
|
778
|
+
const userInfo = await app.client.users.info({ user: config.userId });
|
|
779
|
+
const userName = userInfo.user?.real_name || userInfo.user?.name || "User";
|
|
780
|
+
const userIcon = userInfo.user?.profile?.image_72;
|
|
781
|
+
await messageQueue.add(
|
|
782
|
+
() => app.client.chat.postMessage({
|
|
783
|
+
channel: channel.channelId,
|
|
784
|
+
text: chunk,
|
|
785
|
+
username: userName,
|
|
786
|
+
icon_url: userIcon,
|
|
787
|
+
mrkdwn: true
|
|
788
|
+
})
|
|
789
|
+
);
|
|
790
|
+
} catch (err) {
|
|
791
|
+
console.error("[Slack] Failed to post message:", err);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
} else {
|
|
795
|
+
const chunks = chunkMessage(formatted);
|
|
796
|
+
for (const chunk of chunks) {
|
|
797
|
+
try {
|
|
798
|
+
await messageQueue.add(
|
|
799
|
+
() => app.client.chat.postMessage({
|
|
800
|
+
channel: channel.channelId,
|
|
801
|
+
text: chunk,
|
|
802
|
+
username: "Claude Code",
|
|
803
|
+
icon_url: "https://claude.ai/favicon.ico",
|
|
804
|
+
mrkdwn: true
|
|
805
|
+
})
|
|
806
|
+
);
|
|
807
|
+
} catch (err) {
|
|
808
|
+
console.error("[Slack] Failed to post message:", err);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const session = sessionManager.getSession(sessionId);
|
|
812
|
+
const images = extractImagePaths(content, session?.cwd);
|
|
813
|
+
for (const image of images) {
|
|
814
|
+
try {
|
|
815
|
+
console.log(`[Slack] Uploading image: ${image.resolvedPath}`);
|
|
816
|
+
await messageQueue.add(
|
|
817
|
+
() => app.client.files.uploadV2({
|
|
818
|
+
channel_id: channel.channelId,
|
|
819
|
+
file: createReadStream(image.resolvedPath),
|
|
820
|
+
filename: image.resolvedPath.split("/").pop() || "image",
|
|
821
|
+
initial_comment: `\u{1F4CE} ${image.originalPath}`
|
|
822
|
+
})
|
|
823
|
+
);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
console.error("[Slack] Failed to upload image:", err);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
onTodos: async (sessionId, todos) => {
|
|
832
|
+
const channel = channelManager.getChannel(sessionId);
|
|
833
|
+
if (channel && todos.length > 0) {
|
|
834
|
+
const todosText = formatTodos(todos);
|
|
835
|
+
try {
|
|
836
|
+
await messageQueue.add(
|
|
837
|
+
() => app.client.chat.postMessage({
|
|
838
|
+
channel: channel.channelId,
|
|
839
|
+
text: `*Tasks:*
|
|
840
|
+
${todosText}`,
|
|
841
|
+
mrkdwn: true
|
|
842
|
+
})
|
|
843
|
+
);
|
|
844
|
+
} catch (err) {
|
|
845
|
+
console.error("[Slack] Failed to post todos:", err);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
onToolCall: async (_sessionId, _tool) => {
|
|
850
|
+
},
|
|
851
|
+
onToolResult: async (_sessionId, _result) => {
|
|
852
|
+
},
|
|
853
|
+
onPlanModeChange: async (sessionId, inPlanMode) => {
|
|
854
|
+
const channel = channelManager.getChannel(sessionId);
|
|
855
|
+
if (!channel) return;
|
|
856
|
+
const emoji = inPlanMode ? ":clipboard:" : ":hammer:";
|
|
857
|
+
const status = inPlanMode ? "Planning mode - Claude is designing a solution" : "Execution mode - Claude is implementing";
|
|
858
|
+
try {
|
|
859
|
+
await messageQueue.add(
|
|
860
|
+
() => app.client.chat.postMessage({
|
|
861
|
+
channel: channel.channelId,
|
|
862
|
+
text: `${emoji} ${status}`,
|
|
863
|
+
mrkdwn: true
|
|
864
|
+
})
|
|
865
|
+
);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
console.error("[Slack] Failed to post plan mode change:", err);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
app.message(async ({ message, say }) => {
|
|
872
|
+
if ("subtype" in message && message.subtype) return;
|
|
873
|
+
if (!("text" in message) || !message.text) return;
|
|
874
|
+
if (!("channel" in message) || !message.channel) return;
|
|
875
|
+
if ("bot_id" in message && message.bot_id) return;
|
|
876
|
+
if ("thread_ts" in message && message.thread_ts) return;
|
|
877
|
+
const sessionId = channelManager.getSessionByChannel(message.channel);
|
|
878
|
+
if (!sessionId) return;
|
|
879
|
+
const channel = channelManager.getChannel(sessionId);
|
|
880
|
+
if (!channel || channel.status === "ended") {
|
|
881
|
+
await say(":warning: This session has ended.");
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
console.log(`[Slack] Sending input to session ${sessionId}: ${message.text.slice(0, 50)}...`);
|
|
885
|
+
slackSentMessages.add(message.text.trim());
|
|
886
|
+
const sent = sessionManager.sendInput(sessionId, message.text);
|
|
887
|
+
if (!sent) {
|
|
888
|
+
slackSentMessages.delete(message.text.trim());
|
|
889
|
+
await say(":warning: Failed to send input - session not connected.");
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
app.command("/afk", async ({ command: command2, ack, respond }) => {
|
|
893
|
+
await ack();
|
|
894
|
+
const subcommand = command2.text.trim().split(" ")[0];
|
|
895
|
+
if (subcommand === "sessions" || !subcommand) {
|
|
896
|
+
const active = channelManager.getAllActive();
|
|
897
|
+
if (active.length === 0) {
|
|
898
|
+
await respond("No active sessions. Start a session with `afk-code run -- claude`");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const text = active.map((c) => `<#${c.channelId}> - ${formatSessionStatus(c.status)}`).join("\n");
|
|
902
|
+
await respond({
|
|
903
|
+
text: `*Active Sessions:*
|
|
904
|
+
${text}`,
|
|
905
|
+
mrkdwn: true
|
|
906
|
+
});
|
|
907
|
+
} else {
|
|
908
|
+
await respond("Unknown command. Available: `/afk sessions`");
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
app.command("/background", async ({ command: command2, ack, respond }) => {
|
|
912
|
+
await ack();
|
|
913
|
+
const sessionId = channelManager.getSessionByChannel(command2.channel_id);
|
|
914
|
+
if (!sessionId) {
|
|
915
|
+
await respond(":warning: This channel is not associated with an active session.");
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const channel = channelManager.getChannel(sessionId);
|
|
919
|
+
if (!channel || channel.status === "ended") {
|
|
920
|
+
await respond(":warning: This session has ended.");
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const sent = sessionManager.sendInput(sessionId, "");
|
|
924
|
+
if (sent) {
|
|
925
|
+
await respond(":arrow_heading_down: Sent background command (Ctrl+B)");
|
|
926
|
+
} else {
|
|
927
|
+
await respond(":warning: Failed to send command - session not connected.");
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
app.command("/interrupt", async ({ command: command2, ack, respond }) => {
|
|
931
|
+
await ack();
|
|
932
|
+
const sessionId = channelManager.getSessionByChannel(command2.channel_id);
|
|
933
|
+
if (!sessionId) {
|
|
934
|
+
await respond(":warning: This channel is not associated with an active session.");
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const channel = channelManager.getChannel(sessionId);
|
|
938
|
+
if (!channel || channel.status === "ended") {
|
|
939
|
+
await respond(":warning: This session has ended.");
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const sent = sessionManager.sendInput(sessionId, "\x1B");
|
|
943
|
+
if (sent) {
|
|
944
|
+
await respond(":stop_sign: Sent interrupt (Escape)");
|
|
945
|
+
} else {
|
|
946
|
+
await respond(":warning: Failed to send command - session not connected.");
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
app.command("/mode", async ({ command: command2, ack, respond }) => {
|
|
950
|
+
await ack();
|
|
951
|
+
const sessionId = channelManager.getSessionByChannel(command2.channel_id);
|
|
952
|
+
if (!sessionId) {
|
|
953
|
+
await respond(":warning: This channel is not associated with an active session.");
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const channel = channelManager.getChannel(sessionId);
|
|
957
|
+
if (!channel || channel.status === "ended") {
|
|
958
|
+
await respond(":warning: This session has ended.");
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const sent = sessionManager.sendInput(sessionId, "\x1B[Z");
|
|
962
|
+
if (sent) {
|
|
963
|
+
await respond(":arrows_counterclockwise: Sent mode toggle (Shift+Tab)");
|
|
964
|
+
} else {
|
|
965
|
+
await respond(":warning: Failed to send command - session not connected.");
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
app.event("app_home_opened", async ({ event, client }) => {
|
|
969
|
+
const active = channelManager.getAllActive();
|
|
970
|
+
const blocks = [
|
|
971
|
+
{
|
|
972
|
+
type: "header",
|
|
973
|
+
text: { type: "plain_text", text: "AFK Code Sessions", emoji: true }
|
|
974
|
+
},
|
|
975
|
+
{ type: "divider" }
|
|
976
|
+
];
|
|
977
|
+
if (active.length === 0) {
|
|
978
|
+
blocks.push({
|
|
979
|
+
type: "section",
|
|
980
|
+
text: {
|
|
981
|
+
type: "mrkdwn",
|
|
982
|
+
text: "_No active sessions_\n\nStart a session with `afk-code run -- claude`"
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
} else {
|
|
986
|
+
for (const c of active) {
|
|
987
|
+
blocks.push({
|
|
988
|
+
type: "section",
|
|
989
|
+
text: {
|
|
990
|
+
type: "mrkdwn",
|
|
991
|
+
text: `*${c.sessionName}*
|
|
992
|
+
${formatSessionStatus(c.status)}
|
|
993
|
+
<#${c.channelId}>`
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
try {
|
|
999
|
+
await client.views.publish({
|
|
1000
|
+
user_id: event.user,
|
|
1001
|
+
view: {
|
|
1002
|
+
type: "home",
|
|
1003
|
+
blocks
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
console.error("[Slack] Failed to publish home view:", err);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
return { app, sessionManager, channelManager };
|
|
1011
|
+
}
|
|
1012
|
+
var MessageQueue;
|
|
1013
|
+
var init_slack_app = __esm({
|
|
1014
|
+
"src/slack/slack-app.ts"() {
|
|
1015
|
+
"use strict";
|
|
1016
|
+
init_session_manager();
|
|
1017
|
+
init_channel_manager();
|
|
1018
|
+
init_message_formatter();
|
|
1019
|
+
init_image_extractor();
|
|
1020
|
+
MessageQueue = class {
|
|
1021
|
+
queue = [];
|
|
1022
|
+
processing = false;
|
|
1023
|
+
minDelay = 350;
|
|
1024
|
+
// ms between messages (Slack allows ~1/sec but be safe)
|
|
1025
|
+
async add(fn) {
|
|
1026
|
+
return new Promise((resolve2, reject) => {
|
|
1027
|
+
this.queue.push(async () => {
|
|
1028
|
+
try {
|
|
1029
|
+
const result = await fn();
|
|
1030
|
+
resolve2(result);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
reject(err);
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
this.process();
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
async process() {
|
|
1039
|
+
if (this.processing) return;
|
|
1040
|
+
this.processing = true;
|
|
1041
|
+
while (this.queue.length > 0) {
|
|
1042
|
+
const fn = this.queue.shift();
|
|
1043
|
+
if (fn) {
|
|
1044
|
+
await fn();
|
|
1045
|
+
if (this.queue.length > 0) {
|
|
1046
|
+
await new Promise((r) => setTimeout(r, this.minDelay));
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
this.processing = false;
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
// src/discord/channel-manager.ts
|
|
1057
|
+
import { ChannelType } from "discord.js";
|
|
1058
|
+
function sanitizeChannelName2(name) {
|
|
1059
|
+
return name.toLowerCase().replace(/[^a-z0-9-_\s]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 90);
|
|
1060
|
+
}
|
|
1061
|
+
var ChannelManager2;
|
|
1062
|
+
var init_channel_manager2 = __esm({
|
|
1063
|
+
"src/discord/channel-manager.ts"() {
|
|
1064
|
+
"use strict";
|
|
1065
|
+
ChannelManager2 = class {
|
|
1066
|
+
channels = /* @__PURE__ */ new Map();
|
|
1067
|
+
channelToSession = /* @__PURE__ */ new Map();
|
|
1068
|
+
client;
|
|
1069
|
+
userId;
|
|
1070
|
+
guild = null;
|
|
1071
|
+
category = null;
|
|
1072
|
+
constructor(client, userId) {
|
|
1073
|
+
this.client = client;
|
|
1074
|
+
this.userId = userId;
|
|
1075
|
+
}
|
|
1076
|
+
async initialize() {
|
|
1077
|
+
const guilds = await this.client.guilds.fetch();
|
|
1078
|
+
if (guilds.size === 0) {
|
|
1079
|
+
throw new Error("Bot is not in any servers. Please invite the bot first.");
|
|
1080
|
+
}
|
|
1081
|
+
const guildId = guilds.first().id;
|
|
1082
|
+
this.guild = await this.client.guilds.fetch(guildId);
|
|
1083
|
+
const existingCategory = this.guild.channels.cache.find(
|
|
1084
|
+
(ch) => ch.type === ChannelType.GuildCategory && ch.name.toLowerCase() === "afk code sessions"
|
|
1085
|
+
);
|
|
1086
|
+
if (existingCategory) {
|
|
1087
|
+
this.category = existingCategory;
|
|
1088
|
+
} else {
|
|
1089
|
+
this.category = await this.guild.channels.create({
|
|
1090
|
+
name: "AFK Code Sessions",
|
|
1091
|
+
type: ChannelType.GuildCategory
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
console.log(`[ChannelManager] Using guild: ${this.guild.name}`);
|
|
1095
|
+
console.log(`[ChannelManager] Using category: ${this.category.name}`);
|
|
1096
|
+
}
|
|
1097
|
+
async createChannel(sessionId, sessionName, cwd) {
|
|
1098
|
+
if (!this.guild || !this.category) {
|
|
1099
|
+
console.error("[ChannelManager] Not initialized");
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
if (this.channels.has(sessionId)) {
|
|
1103
|
+
return this.channels.get(sessionId);
|
|
1104
|
+
}
|
|
1105
|
+
const folderName = cwd.split("/").filter(Boolean).pop() || "session";
|
|
1106
|
+
const baseName = `afk-${sanitizeChannelName2(folderName)}`;
|
|
1107
|
+
let channelName = baseName;
|
|
1108
|
+
let suffix = 1;
|
|
1109
|
+
let channel = null;
|
|
1110
|
+
while (true) {
|
|
1111
|
+
const nameToTry = channelName.length > 100 ? channelName.slice(0, 100) : channelName;
|
|
1112
|
+
const existing = this.guild.channels.cache.find(
|
|
1113
|
+
(ch) => ch.name === nameToTry && ch.parentId === this.category.id
|
|
1114
|
+
);
|
|
1115
|
+
if (!existing) {
|
|
1116
|
+
try {
|
|
1117
|
+
channel = await this.guild.channels.create({
|
|
1118
|
+
name: nameToTry,
|
|
1119
|
+
type: ChannelType.GuildText,
|
|
1120
|
+
parent: this.category,
|
|
1121
|
+
topic: `Claude Code session: ${sessionName}`
|
|
1122
|
+
});
|
|
1123
|
+
channelName = nameToTry;
|
|
1124
|
+
break;
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
console.error("[ChannelManager] Failed to create channel:", err.message);
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
} else {
|
|
1130
|
+
suffix++;
|
|
1131
|
+
channelName = `${baseName}-${suffix}`;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
if (!channel) {
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
const mapping = {
|
|
1138
|
+
sessionId,
|
|
1139
|
+
channelId: channel.id,
|
|
1140
|
+
channelName,
|
|
1141
|
+
sessionName,
|
|
1142
|
+
status: "running",
|
|
1143
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1144
|
+
};
|
|
1145
|
+
this.channels.set(sessionId, mapping);
|
|
1146
|
+
this.channelToSession.set(channel.id, sessionId);
|
|
1147
|
+
console.log(`[ChannelManager] Created channel #${channelName} for session ${sessionId}`);
|
|
1148
|
+
return mapping;
|
|
1149
|
+
}
|
|
1150
|
+
async archiveChannel(sessionId) {
|
|
1151
|
+
if (!this.guild) return false;
|
|
1152
|
+
const mapping = this.channels.get(sessionId);
|
|
1153
|
+
if (!mapping) return false;
|
|
1154
|
+
try {
|
|
1155
|
+
const channel = await this.guild.channels.fetch(mapping.channelId);
|
|
1156
|
+
if (channel && channel.type === ChannelType.GuildText) {
|
|
1157
|
+
const timestamp = Date.now().toString(36);
|
|
1158
|
+
const archivedName = `${mapping.channelName}-archived-${timestamp}`.slice(0, 100);
|
|
1159
|
+
await channel.setName(archivedName);
|
|
1160
|
+
console.log(`[ChannelManager] Archived channel #${mapping.channelName}`);
|
|
1161
|
+
}
|
|
1162
|
+
return true;
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
console.error("[ChannelManager] Failed to archive channel:", err.message);
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
getChannel(sessionId) {
|
|
1169
|
+
return this.channels.get(sessionId);
|
|
1170
|
+
}
|
|
1171
|
+
getSessionByChannel(channelId) {
|
|
1172
|
+
return this.channelToSession.get(channelId);
|
|
1173
|
+
}
|
|
1174
|
+
updateStatus(sessionId, status) {
|
|
1175
|
+
const mapping = this.channels.get(sessionId);
|
|
1176
|
+
if (mapping) {
|
|
1177
|
+
mapping.status = status;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
updateName(sessionId, name) {
|
|
1181
|
+
const mapping = this.channels.get(sessionId);
|
|
1182
|
+
if (mapping) {
|
|
1183
|
+
mapping.sessionName = name;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
getAllActive() {
|
|
1187
|
+
return Array.from(this.channels.values()).filter((c) => c.status !== "ended");
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
// src/discord/discord-app.ts
|
|
1194
|
+
var discord_app_exports = {};
|
|
1195
|
+
__export(discord_app_exports, {
|
|
1196
|
+
createDiscordApp: () => createDiscordApp
|
|
1197
|
+
});
|
|
1198
|
+
import { Client, GatewayIntentBits, Events, ChannelType as ChannelType2, AttachmentBuilder, REST, Routes, SlashCommandBuilder } from "discord.js";
|
|
1199
|
+
function createDiscordApp(config) {
|
|
1200
|
+
const client = new Client({
|
|
1201
|
+
intents: [
|
|
1202
|
+
GatewayIntentBits.Guilds,
|
|
1203
|
+
GatewayIntentBits.GuildMessages,
|
|
1204
|
+
GatewayIntentBits.MessageContent
|
|
1205
|
+
]
|
|
1206
|
+
});
|
|
1207
|
+
const channelManager = new ChannelManager2(client, config.userId);
|
|
1208
|
+
const discordSentMessages = /* @__PURE__ */ new Set();
|
|
1209
|
+
const toolCallMessages = /* @__PURE__ */ new Map();
|
|
1210
|
+
const sessionManager = new SessionManager({
|
|
1211
|
+
onSessionStart: async (session) => {
|
|
1212
|
+
const channel = await channelManager.createChannel(session.id, session.name, session.cwd);
|
|
1213
|
+
if (channel) {
|
|
1214
|
+
const discordChannel = await client.channels.fetch(channel.channelId);
|
|
1215
|
+
if (discordChannel?.type === ChannelType2.GuildText) {
|
|
1216
|
+
await discordChannel.send(
|
|
1217
|
+
`${formatSessionStatus(session.status)} **Session started**
|
|
1218
|
+
\`${session.cwd}\``
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
},
|
|
1223
|
+
onSessionEnd: async (sessionId) => {
|
|
1224
|
+
const channel = channelManager.getChannel(sessionId);
|
|
1225
|
+
if (channel) {
|
|
1226
|
+
channelManager.updateStatus(sessionId, "ended");
|
|
1227
|
+
const discordChannel = await client.channels.fetch(channel.channelId);
|
|
1228
|
+
if (discordChannel?.type === ChannelType2.GuildText) {
|
|
1229
|
+
await discordChannel.send("\u{1F6D1} **Session ended** - this channel will be archived");
|
|
1230
|
+
}
|
|
1231
|
+
await channelManager.archiveChannel(sessionId);
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
onSessionUpdate: async (sessionId, name) => {
|
|
1235
|
+
const channel = channelManager.getChannel(sessionId);
|
|
1236
|
+
if (channel) {
|
|
1237
|
+
channelManager.updateName(sessionId, name);
|
|
1238
|
+
try {
|
|
1239
|
+
const discordChannel = await client.channels.fetch(channel.channelId);
|
|
1240
|
+
if (discordChannel?.type === ChannelType2.GuildText) {
|
|
1241
|
+
await discordChannel.setTopic(`Claude Code session: ${name}`);
|
|
1242
|
+
}
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
console.error("[Discord] Failed to update channel topic:", err);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
},
|
|
1248
|
+
onSessionStatus: async (sessionId, status) => {
|
|
1249
|
+
const channel = channelManager.getChannel(sessionId);
|
|
1250
|
+
if (channel) {
|
|
1251
|
+
channelManager.updateStatus(sessionId, status);
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1254
|
+
onMessage: async (sessionId, role, content) => {
|
|
1255
|
+
const channel = channelManager.getChannel(sessionId);
|
|
1256
|
+
if (channel) {
|
|
1257
|
+
const formatted = content;
|
|
1258
|
+
if (role === "user") {
|
|
1259
|
+
const contentKey = content.trim();
|
|
1260
|
+
if (discordSentMessages.has(contentKey)) {
|
|
1261
|
+
discordSentMessages.delete(contentKey);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
const discordChannel = await client.channels.fetch(channel.channelId);
|
|
1265
|
+
if (discordChannel?.type === ChannelType2.GuildText) {
|
|
1266
|
+
const chunks = chunkMessage(formatted);
|
|
1267
|
+
for (const chunk of chunks) {
|
|
1268
|
+
await discordChannel.send(`**User:** ${chunk}`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
} else {
|
|
1272
|
+
const discordChannel = await client.channels.fetch(channel.channelId);
|
|
1273
|
+
if (discordChannel?.type === ChannelType2.GuildText) {
|
|
1274
|
+
const chunks = chunkMessage(formatted);
|
|
1275
|
+
for (const chunk of chunks) {
|
|
1276
|
+
await discordChannel.send(chunk);
|
|
1277
|
+
}
|
|
1278
|
+
const session = sessionManager.getSession(sessionId);
|
|
1279
|
+
const images = extractImagePaths(content, session?.cwd);
|
|
1280
|
+
for (const image of images) {
|
|
1281
|
+
try {
|
|
1282
|
+
console.log(`[Discord] Uploading image: ${image.resolvedPath}`);
|
|
1283
|
+
const attachment = new AttachmentBuilder(image.resolvedPath);
|
|
1284
|
+
await discordChannel.send({
|
|
1285
|
+
content: `\u{1F4CE} ${image.originalPath}`,
|
|
1286
|
+
files: [attachment]
|
|
1287
|
+
});
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
console.error("[Discord] Failed to upload image:", err);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
},
|
|
1296
|
+
onTodos: async (sessionId, todos) => {
|
|
1297
|
+
const channel = channelManager.getChannel(sessionId);
|
|
1298
|
+
if (channel && todos.length > 0) {
|
|
1299
|
+
const todosText = formatTodos(todos);
|
|
1300
|
+
try {
|
|
1301
|
+
const discordChannel = await client.channels.fetch(channel.channelId);
|
|
1302
|
+
if (discordChannel?.type === ChannelType2.GuildText) {
|
|
1303
|
+
await discordChannel.send(`**Tasks:**
|
|
1304
|
+
${todosText}`);
|
|
1305
|
+
}
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
console.error("[Discord] Failed to post todos:", err);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
},
|
|
1311
|
+
onToolCall: async (sessionId, tool) => {
|
|
1312
|
+
const channel = channelManager.getChannel(sessionId);
|
|
1313
|
+
if (!channel) return;
|
|
1314
|
+
let inputSummary = "";
|
|
1315
|
+
if (tool.name === "Bash" && tool.input.command) {
|
|
1316
|
+
inputSummary = `\`${tool.input.command.slice(0, 100)}${tool.input.command.length > 100 ? "..." : ""}\``;
|
|
1317
|
+
} else if (tool.name === "Read" && tool.input.file_path) {
|
|
1318
|
+
inputSummary = `\`${tool.input.file_path}\``;
|
|
1319
|
+
} else if (tool.name === "Edit" && tool.input.file_path) {
|
|
1320
|
+
inputSummary = `\`${tool.input.file_path}\``;
|
|
1321
|
+
} else if (tool.name === "Write" && tool.input.file_path) {
|
|
1322
|
+
inputSummary = `\`${tool.input.file_path}\``;
|
|
1323
|
+
} else if (tool.name === "Grep" && tool.input.pattern) {
|
|
1324
|
+
inputSummary = `\`${tool.input.pattern}\``;
|
|
1325
|
+
} else if (tool.name === "Glob" && tool.input.pattern) {
|
|
1326
|
+
inputSummary = `\`${tool.input.pattern}\``;
|
|
1327
|
+
} else if (tool.name === "Task" && tool.input.description) {
|
|
1328
|
+
inputSummary = tool.input.description;
|
|
1329
|
+
}
|
|
1330
|
+
const text = inputSummary ? `\u{1F527} **${tool.name}**: ${inputSummary}` : `\u{1F527} **${tool.name}**`;
|
|
1331
|
+
try {
|
|
1332
|
+
const discordChannel = await client.channels.fetch(channel.channelId);
|
|
1333
|
+
if (discordChannel?.type === ChannelType2.GuildText) {
|
|
1334
|
+
const message = await discordChannel.send(text);
|
|
1335
|
+
toolCallMessages.set(tool.id, message.id);
|
|
1336
|
+
}
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
console.error("[Discord] Failed to post tool call:", err);
|
|
1339
|
+
}
|
|
1340
|
+
},
|
|
1341
|
+
onToolResult: async (sessionId, result) => {
|
|
1342
|
+
const channel = channelManager.getChannel(sessionId);
|
|
1343
|
+
if (!channel) return;
|
|
1344
|
+
const parentMessageId = toolCallMessages.get(result.toolUseId);
|
|
1345
|
+
if (!parentMessageId) return;
|
|
1346
|
+
const maxLen = 1800;
|
|
1347
|
+
let content = result.content;
|
|
1348
|
+
if (content.length > maxLen) {
|
|
1349
|
+
content = content.slice(0, maxLen) + "\n... (truncated)";
|
|
1350
|
+
}
|
|
1351
|
+
const prefix = result.isError ? "\u274C Error:" : "\u2705 Result:";
|
|
1352
|
+
const text = `${prefix}
|
|
1353
|
+
\`\`\`
|
|
1354
|
+
${content}
|
|
1355
|
+
\`\`\``;
|
|
1356
|
+
try {
|
|
1357
|
+
const discordChannel = await client.channels.fetch(channel.channelId);
|
|
1358
|
+
if (discordChannel?.type === ChannelType2.GuildText) {
|
|
1359
|
+
const parentMessage = await discordChannel.messages.fetch(parentMessageId);
|
|
1360
|
+
if (parentMessage) {
|
|
1361
|
+
let thread = parentMessage.thread;
|
|
1362
|
+
if (!thread) {
|
|
1363
|
+
thread = await parentMessage.startThread({
|
|
1364
|
+
name: "Result",
|
|
1365
|
+
autoArchiveDuration: 60
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
await thread.send(text);
|
|
1369
|
+
}
|
|
1370
|
+
toolCallMessages.delete(result.toolUseId);
|
|
1371
|
+
}
|
|
1372
|
+
} catch (err) {
|
|
1373
|
+
console.error("[Discord] Failed to post tool result:", err);
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
onPlanModeChange: async (sessionId, inPlanMode) => {
|
|
1377
|
+
const channel = channelManager.getChannel(sessionId);
|
|
1378
|
+
if (!channel) return;
|
|
1379
|
+
const emoji = inPlanMode ? "\u{1F4CB}" : "\u{1F528}";
|
|
1380
|
+
const status = inPlanMode ? "Planning mode - Claude is designing a solution" : "Execution mode - Claude is implementing";
|
|
1381
|
+
try {
|
|
1382
|
+
const discordChannel = await client.channels.fetch(channel.channelId);
|
|
1383
|
+
if (discordChannel?.type === ChannelType2.GuildText) {
|
|
1384
|
+
await discordChannel.send(`${emoji} ${status}`);
|
|
1385
|
+
}
|
|
1386
|
+
} catch (err) {
|
|
1387
|
+
console.error("[Discord] Failed to post plan mode change:", err);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
client.on(Events.MessageCreate, async (message) => {
|
|
1392
|
+
if (message.author.bot) return;
|
|
1393
|
+
if (!message.guild) return;
|
|
1394
|
+
const sessionId = channelManager.getSessionByChannel(message.channelId);
|
|
1395
|
+
if (!sessionId) return;
|
|
1396
|
+
const channel = channelManager.getChannel(sessionId);
|
|
1397
|
+
if (!channel || channel.status === "ended") {
|
|
1398
|
+
await message.reply("\u26A0\uFE0F This session has ended.");
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
console.log(`[Discord] Sending input to session ${sessionId}: ${message.content.slice(0, 50)}...`);
|
|
1402
|
+
discordSentMessages.add(message.content.trim());
|
|
1403
|
+
const sent = sessionManager.sendInput(sessionId, message.content);
|
|
1404
|
+
if (!sent) {
|
|
1405
|
+
discordSentMessages.delete(message.content.trim());
|
|
1406
|
+
await message.reply("\u26A0\uFE0F Failed to send input - session not connected.");
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
client.once(Events.ClientReady, async (c) => {
|
|
1410
|
+
console.log(`[Discord] Logged in as ${c.user.tag}`);
|
|
1411
|
+
await channelManager.initialize();
|
|
1412
|
+
const commands = [
|
|
1413
|
+
new SlashCommandBuilder().setName("background").setDescription("Send Claude to background mode (Ctrl+B)"),
|
|
1414
|
+
new SlashCommandBuilder().setName("interrupt").setDescription("Interrupt Claude (Escape)"),
|
|
1415
|
+
new SlashCommandBuilder().setName("mode").setDescription("Toggle Claude mode (Shift+Tab)"),
|
|
1416
|
+
new SlashCommandBuilder().setName("afk").setDescription("List active Claude Code sessions")
|
|
1417
|
+
];
|
|
1418
|
+
try {
|
|
1419
|
+
const rest = new REST({ version: "10" }).setToken(config.botToken);
|
|
1420
|
+
await rest.put(Routes.applicationCommands(c.user.id), {
|
|
1421
|
+
body: commands.map((cmd) => cmd.toJSON())
|
|
1422
|
+
});
|
|
1423
|
+
console.log("[Discord] Slash commands registered");
|
|
1424
|
+
} catch (err) {
|
|
1425
|
+
console.error("[Discord] Failed to register slash commands:", err);
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
client.on(Events.InteractionCreate, async (interaction) => {
|
|
1429
|
+
if (!interaction.isChatInputCommand()) return;
|
|
1430
|
+
const { commandName, channelId } = interaction;
|
|
1431
|
+
if (commandName === "afk") {
|
|
1432
|
+
const active = channelManager.getAllActive();
|
|
1433
|
+
if (active.length === 0) {
|
|
1434
|
+
await interaction.reply("No active sessions. Start a session with `afk-code run -- claude`");
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
const text = active.map((c) => `<#${c.channelId}> - ${formatSessionStatus(c.status)}`).join("\n");
|
|
1438
|
+
await interaction.reply(`**Active Sessions:**
|
|
1439
|
+
${text}`);
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
if (commandName === "background" || commandName === "interrupt" || commandName === "mode") {
|
|
1443
|
+
const sessionId = channelManager.getSessionByChannel(channelId);
|
|
1444
|
+
if (!sessionId) {
|
|
1445
|
+
await interaction.reply("\u26A0\uFE0F This channel is not associated with an active session.");
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
const channel = channelManager.getChannel(sessionId);
|
|
1449
|
+
if (!channel || channel.status === "ended") {
|
|
1450
|
+
await interaction.reply("\u26A0\uFE0F This session has ended.");
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
let key;
|
|
1454
|
+
let message;
|
|
1455
|
+
if (commandName === "background") {
|
|
1456
|
+
key = "";
|
|
1457
|
+
message = "\u2B07\uFE0F Sent background command (Ctrl+B)";
|
|
1458
|
+
} else if (commandName === "interrupt") {
|
|
1459
|
+
key = "\x1B";
|
|
1460
|
+
message = "\u{1F6D1} Sent interrupt (Escape)";
|
|
1461
|
+
} else {
|
|
1462
|
+
key = "\x1B[Z";
|
|
1463
|
+
message = "\u{1F504} Sent mode toggle (Shift+Tab)";
|
|
1464
|
+
}
|
|
1465
|
+
const sent = sessionManager.sendInput(sessionId, key);
|
|
1466
|
+
if (sent) {
|
|
1467
|
+
await interaction.reply(message);
|
|
1468
|
+
} else {
|
|
1469
|
+
await interaction.reply("\u26A0\uFE0F Failed to send command - session not connected.");
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
return { client, sessionManager, channelManager };
|
|
1474
|
+
}
|
|
1475
|
+
var init_discord_app = __esm({
|
|
1476
|
+
"src/discord/discord-app.ts"() {
|
|
1477
|
+
"use strict";
|
|
1478
|
+
init_session_manager();
|
|
1479
|
+
init_channel_manager2();
|
|
1480
|
+
init_message_formatter();
|
|
1481
|
+
init_image_extractor();
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
// src/cli/run.ts
|
|
1486
|
+
import { randomUUID } from "crypto";
|
|
1487
|
+
import { homedir } from "os";
|
|
1488
|
+
import { createConnection } from "net";
|
|
1489
|
+
import * as pty from "node-pty";
|
|
1490
|
+
var DAEMON_SOCKET = "/tmp/afk-code-daemon.sock";
|
|
1491
|
+
function getClaudeProjectDir(cwd) {
|
|
1492
|
+
const encodedPath = cwd.replace(/\//g, "-");
|
|
1493
|
+
return `${homedir()}/.claude/projects/${encodedPath}`;
|
|
1494
|
+
}
|
|
1495
|
+
function connectToDaemon(sessionId, projectDir, cwd, command2, onInput) {
|
|
1496
|
+
return new Promise((resolve2) => {
|
|
1497
|
+
const socket = createConnection(DAEMON_SOCKET);
|
|
1498
|
+
let messageBuffer = "";
|
|
1499
|
+
socket.on("connect", () => {
|
|
1500
|
+
socket.write(JSON.stringify({
|
|
1501
|
+
type: "session_start",
|
|
1502
|
+
id: sessionId,
|
|
1503
|
+
projectDir,
|
|
1504
|
+
cwd,
|
|
1505
|
+
command: command2,
|
|
1506
|
+
name: command2.join(" ")
|
|
1507
|
+
}) + "\n");
|
|
1508
|
+
resolve2({
|
|
1509
|
+
close: () => {
|
|
1510
|
+
socket.write(JSON.stringify({ type: "session_end", sessionId }) + "\n");
|
|
1511
|
+
socket.end();
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
});
|
|
1515
|
+
socket.on("data", (data) => {
|
|
1516
|
+
messageBuffer += data.toString();
|
|
1517
|
+
const lines = messageBuffer.split("\n");
|
|
1518
|
+
messageBuffer = lines.pop() || "";
|
|
1519
|
+
for (const line of lines) {
|
|
1520
|
+
if (!line.trim()) continue;
|
|
1521
|
+
try {
|
|
1522
|
+
const msg = JSON.parse(line);
|
|
1523
|
+
if (msg.type === "input" && msg.text) {
|
|
1524
|
+
onInput(msg.text);
|
|
1525
|
+
}
|
|
1526
|
+
} catch {
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
socket.on("error", (error) => {
|
|
1531
|
+
resolve2(null);
|
|
1532
|
+
});
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
async function run(command2) {
|
|
1536
|
+
const sessionId = randomUUID().slice(0, 8);
|
|
1537
|
+
const cwd = process.cwd();
|
|
1538
|
+
const projectDir = getClaudeProjectDir(cwd);
|
|
1539
|
+
const cols = process.stdout.columns || 80;
|
|
1540
|
+
const rows = process.stdout.rows || 24;
|
|
1541
|
+
const ptyProcess = pty.spawn(command2[0], command2.slice(1), {
|
|
1542
|
+
name: process.env.TERM || "xterm-256color",
|
|
1543
|
+
cols,
|
|
1544
|
+
rows,
|
|
1545
|
+
cwd,
|
|
1546
|
+
env: process.env
|
|
1547
|
+
});
|
|
1548
|
+
const daemon = await connectToDaemon(
|
|
1549
|
+
sessionId,
|
|
1550
|
+
projectDir,
|
|
1551
|
+
cwd,
|
|
1552
|
+
command2,
|
|
1553
|
+
(text) => {
|
|
1554
|
+
ptyProcess.write(text);
|
|
1555
|
+
}
|
|
1556
|
+
);
|
|
1557
|
+
if (process.stdin.isTTY) {
|
|
1558
|
+
process.stdin.setRawMode(true);
|
|
1559
|
+
}
|
|
1560
|
+
ptyProcess.onData((data) => {
|
|
1561
|
+
process.stdout.write(data);
|
|
1562
|
+
});
|
|
1563
|
+
const onStdinData = (data) => {
|
|
1564
|
+
ptyProcess.write(data.toString());
|
|
1565
|
+
};
|
|
1566
|
+
process.stdin.on("data", onStdinData);
|
|
1567
|
+
process.stdout.on("resize", () => {
|
|
1568
|
+
ptyProcess.resize(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
1569
|
+
});
|
|
1570
|
+
await new Promise((resolve2) => {
|
|
1571
|
+
ptyProcess.onExit(() => {
|
|
1572
|
+
process.stdin.removeListener("data", onStdinData);
|
|
1573
|
+
if (process.stdin.isTTY) {
|
|
1574
|
+
process.stdin.setRawMode(false);
|
|
1575
|
+
}
|
|
1576
|
+
if (typeof process.stdin.unref === "function") {
|
|
1577
|
+
process.stdin.unref();
|
|
1578
|
+
}
|
|
1579
|
+
daemon?.close();
|
|
1580
|
+
resolve2();
|
|
1581
|
+
});
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// src/cli/slack.ts
|
|
1586
|
+
import { homedir as homedir3 } from "os";
|
|
1587
|
+
import { mkdir, writeFile, readFile as readFile2, access } from "fs/promises";
|
|
1588
|
+
import * as readline from "readline";
|
|
1589
|
+
var CONFIG_DIR = `${homedir3()}/.afk-code`;
|
|
1590
|
+
var SLACK_CONFIG_FILE = `${CONFIG_DIR}/slack.env`;
|
|
1591
|
+
var MANIFEST_URL = "https://github.com/clharman/afk-code/blob/main/slack-manifest.json";
|
|
1592
|
+
function prompt(question) {
|
|
1593
|
+
const rl = readline.createInterface({
|
|
1594
|
+
input: process.stdin,
|
|
1595
|
+
output: process.stdout
|
|
1596
|
+
});
|
|
1597
|
+
return new Promise((resolve2) => {
|
|
1598
|
+
rl.question(question, (answer) => {
|
|
1599
|
+
rl.close();
|
|
1600
|
+
resolve2(answer.trim());
|
|
1601
|
+
});
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
async function fileExists(path) {
|
|
1605
|
+
try {
|
|
1606
|
+
await access(path);
|
|
1607
|
+
return true;
|
|
1608
|
+
} catch {
|
|
1609
|
+
return false;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
async function slackSetup() {
|
|
1613
|
+
console.log(`
|
|
1614
|
+
\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
|
|
1615
|
+
\u2502 AFK Code Slack Setup \u2502
|
|
1616
|
+
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
|
|
1617
|
+
|
|
1618
|
+
This will guide you through setting up the Slack bot for
|
|
1619
|
+
monitoring Claude Code sessions.
|
|
1620
|
+
|
|
1621
|
+
Step 1: Create a Slack App
|
|
1622
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1623
|
+
1. Go to: https://api.slack.com/apps
|
|
1624
|
+
2. Click "Create New App" \u2192 "From manifest"
|
|
1625
|
+
3. Select your workspace
|
|
1626
|
+
4. Paste the manifest from: ${MANIFEST_URL}
|
|
1627
|
+
(Or copy from slack-manifest.json in this repo)
|
|
1628
|
+
5. Click "Create"
|
|
1629
|
+
|
|
1630
|
+
Step 2: Install the App
|
|
1631
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1632
|
+
1. Go to "Install App" in the sidebar
|
|
1633
|
+
2. Click "Install to Workspace"
|
|
1634
|
+
3. Authorize the app
|
|
1635
|
+
|
|
1636
|
+
Step 3: Get Your Tokens
|
|
1637
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1638
|
+
`);
|
|
1639
|
+
await prompt("Press Enter when you have created and installed the app...");
|
|
1640
|
+
console.log(`
|
|
1641
|
+
Now let's collect your tokens:
|
|
1642
|
+
|
|
1643
|
+
\u2022 Bot Token: "OAuth & Permissions" \u2192 "Bot User OAuth Token" (starts with xoxb-)
|
|
1644
|
+
\u2022 App Token: "Basic Information" \u2192 "App-Level Tokens" \u2192 Generate one with
|
|
1645
|
+
"connections:write" scope (starts with xapp-)
|
|
1646
|
+
\u2022 User ID: Click your profile in Slack \u2192 "..." \u2192 "Copy member ID"
|
|
1647
|
+
`);
|
|
1648
|
+
const botToken = await prompt("Bot Token (xoxb-...): ");
|
|
1649
|
+
if (!botToken.startsWith("xoxb-")) {
|
|
1650
|
+
console.error("Invalid bot token. Should start with xoxb-");
|
|
1651
|
+
process.exit(1);
|
|
1652
|
+
}
|
|
1653
|
+
const appToken = await prompt("App Token (xapp-...): ");
|
|
1654
|
+
if (!appToken.startsWith("xapp-")) {
|
|
1655
|
+
console.error("Invalid app token. Should start with xapp-");
|
|
1656
|
+
process.exit(1);
|
|
1657
|
+
}
|
|
1658
|
+
const userId = await prompt("Your Slack User ID (U...): ");
|
|
1659
|
+
if (!userId.startsWith("U")) {
|
|
1660
|
+
console.error("Invalid user ID. Should start with U");
|
|
1661
|
+
process.exit(1);
|
|
1662
|
+
}
|
|
1663
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
1664
|
+
const envContent = `# AFK Code Slack Configuration
|
|
1665
|
+
SLACK_BOT_TOKEN=${botToken}
|
|
1666
|
+
SLACK_APP_TOKEN=${appToken}
|
|
1667
|
+
SLACK_USER_ID=${userId}
|
|
1668
|
+
`;
|
|
1669
|
+
await writeFile(SLACK_CONFIG_FILE, envContent);
|
|
1670
|
+
console.log(`
|
|
1671
|
+
\u2713 Configuration saved to ${SLACK_CONFIG_FILE}
|
|
1672
|
+
|
|
1673
|
+
To start the Slack bot, run:
|
|
1674
|
+
afk-code slack
|
|
1675
|
+
|
|
1676
|
+
Then start a Claude Code session with:
|
|
1677
|
+
afk-code run -- claude
|
|
1678
|
+
`);
|
|
1679
|
+
}
|
|
1680
|
+
async function loadEnvFile(path) {
|
|
1681
|
+
if (!await fileExists(path)) return {};
|
|
1682
|
+
const content = await readFile2(path, "utf-8");
|
|
1683
|
+
const config = {};
|
|
1684
|
+
for (const line of content.split("\n")) {
|
|
1685
|
+
if (line.startsWith("#") || !line.includes("=")) continue;
|
|
1686
|
+
const [key, ...valueParts] = line.split("=");
|
|
1687
|
+
config[key.trim()] = valueParts.join("=").trim();
|
|
1688
|
+
}
|
|
1689
|
+
return config;
|
|
1690
|
+
}
|
|
1691
|
+
async function slackRun() {
|
|
1692
|
+
const globalConfig = await loadEnvFile(SLACK_CONFIG_FILE);
|
|
1693
|
+
const localConfig = await loadEnvFile(`${process.cwd()}/.env`);
|
|
1694
|
+
const config = {
|
|
1695
|
+
...globalConfig,
|
|
1696
|
+
...localConfig
|
|
1697
|
+
};
|
|
1698
|
+
if (process.env.SLACK_BOT_TOKEN) config.SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN;
|
|
1699
|
+
if (process.env.SLACK_APP_TOKEN) config.SLACK_APP_TOKEN = process.env.SLACK_APP_TOKEN;
|
|
1700
|
+
if (process.env.SLACK_USER_ID) config.SLACK_USER_ID = process.env.SLACK_USER_ID;
|
|
1701
|
+
const required = ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_ID"];
|
|
1702
|
+
const missing = required.filter((key) => !config[key]);
|
|
1703
|
+
if (missing.length > 0) {
|
|
1704
|
+
console.error(`Missing config: ${missing.join(", ")}`);
|
|
1705
|
+
console.error("");
|
|
1706
|
+
console.error("Provide tokens via:");
|
|
1707
|
+
console.error(" - Environment variables (SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_USER_ID)");
|
|
1708
|
+
console.error(" - Local .env file");
|
|
1709
|
+
console.error(' - Run "afk-code slack setup" for guided configuration');
|
|
1710
|
+
process.exit(1);
|
|
1711
|
+
}
|
|
1712
|
+
process.env.SLACK_BOT_TOKEN = config.SLACK_BOT_TOKEN;
|
|
1713
|
+
process.env.SLACK_APP_TOKEN = config.SLACK_APP_TOKEN;
|
|
1714
|
+
process.env.SLACK_USER_ID = config.SLACK_USER_ID;
|
|
1715
|
+
const { createSlackApp: createSlackApp2 } = await Promise.resolve().then(() => (init_slack_app(), slack_app_exports));
|
|
1716
|
+
const localEnvExists = await fileExists(`${process.cwd()}/.env`);
|
|
1717
|
+
const globalEnvExists = await fileExists(SLACK_CONFIG_FILE);
|
|
1718
|
+
const source = localEnvExists ? ".env" : globalEnvExists ? SLACK_CONFIG_FILE : "environment";
|
|
1719
|
+
console.log(`[AFK Code] Loaded config from ${source}`);
|
|
1720
|
+
console.log("[AFK Code] Starting Slack bot...");
|
|
1721
|
+
const slackConfig = {
|
|
1722
|
+
botToken: config.SLACK_BOT_TOKEN,
|
|
1723
|
+
appToken: config.SLACK_APP_TOKEN,
|
|
1724
|
+
signingSecret: "",
|
|
1725
|
+
userId: config.SLACK_USER_ID
|
|
1726
|
+
};
|
|
1727
|
+
const { app, sessionManager } = createSlackApp2(slackConfig);
|
|
1728
|
+
try {
|
|
1729
|
+
await sessionManager.start();
|
|
1730
|
+
console.log("[AFK Code] Session manager started");
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
console.error("[AFK Code] Failed to start session manager:", err);
|
|
1733
|
+
process.exit(1);
|
|
1734
|
+
}
|
|
1735
|
+
try {
|
|
1736
|
+
await app.start();
|
|
1737
|
+
console.log("[AFK Code] Slack bot is running!");
|
|
1738
|
+
console.log("");
|
|
1739
|
+
console.log("Start a Claude Code session with: afk-code run -- claude");
|
|
1740
|
+
console.log("Each session will create a private #afk-* channel");
|
|
1741
|
+
} catch (err) {
|
|
1742
|
+
console.error("[AFK Code] Failed to start Slack app:", err);
|
|
1743
|
+
process.exit(1);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// src/cli/discord.ts
|
|
1748
|
+
import { homedir as homedir4 } from "os";
|
|
1749
|
+
import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile3, access as access2 } from "fs/promises";
|
|
1750
|
+
import * as readline2 from "readline";
|
|
1751
|
+
var CONFIG_DIR2 = `${homedir4()}/.afk-code`;
|
|
1752
|
+
var DISCORD_CONFIG_FILE = `${CONFIG_DIR2}/discord.env`;
|
|
1753
|
+
function prompt2(question) {
|
|
1754
|
+
const rl = readline2.createInterface({
|
|
1755
|
+
input: process.stdin,
|
|
1756
|
+
output: process.stdout
|
|
1757
|
+
});
|
|
1758
|
+
return new Promise((resolve2) => {
|
|
1759
|
+
rl.question(question, (answer) => {
|
|
1760
|
+
rl.close();
|
|
1761
|
+
resolve2(answer.trim());
|
|
1762
|
+
});
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
async function fileExists2(path) {
|
|
1766
|
+
try {
|
|
1767
|
+
await access2(path);
|
|
1768
|
+
return true;
|
|
1769
|
+
} catch {
|
|
1770
|
+
return false;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
async function discordSetup() {
|
|
1774
|
+
console.log(`
|
|
1775
|
+
\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
|
|
1776
|
+
\u2502 AFK Code Discord Setup \u2502
|
|
1777
|
+
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
|
|
1778
|
+
|
|
1779
|
+
This will guide you through setting up the Discord bot for
|
|
1780
|
+
monitoring Claude Code sessions.
|
|
1781
|
+
|
|
1782
|
+
Step 1: Create a Discord Application
|
|
1783
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1784
|
+
1. Go to: https://discord.com/developers/applications
|
|
1785
|
+
2. Click "New Application"
|
|
1786
|
+
3. Give it a name (e.g., "AFK Code")
|
|
1787
|
+
4. Click "Create"
|
|
1788
|
+
|
|
1789
|
+
Step 2: Create a Bot
|
|
1790
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1791
|
+
1. Go to "Bot" in the sidebar
|
|
1792
|
+
2. Click "Add Bot" \u2192 "Yes, do it!"
|
|
1793
|
+
3. Under "Privileged Gateway Intents", enable:
|
|
1794
|
+
\u2022 MESSAGE CONTENT INTENT
|
|
1795
|
+
4. Click "Reset Token" and copy the token
|
|
1796
|
+
|
|
1797
|
+
Step 3: Invite the Bot
|
|
1798
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1799
|
+
1. Go to "OAuth2" \u2192 "URL Generator"
|
|
1800
|
+
2. Select scopes: "bot"
|
|
1801
|
+
3. Select permissions:
|
|
1802
|
+
\u2022 Send Messages
|
|
1803
|
+
\u2022 Manage Channels
|
|
1804
|
+
\u2022 Read Message History
|
|
1805
|
+
4. Copy the URL and open it to invite the bot to your server
|
|
1806
|
+
`);
|
|
1807
|
+
await prompt2("Press Enter when you have created and invited the bot...");
|
|
1808
|
+
console.log(`
|
|
1809
|
+
Now let's collect your credentials:
|
|
1810
|
+
|
|
1811
|
+
\u2022 Bot Token: "Bot" \u2192 "Token" (click "Reset Token" if needed)
|
|
1812
|
+
\u2022 Your User ID: Enable Developer Mode in Discord settings,
|
|
1813
|
+
then right-click your name \u2192 "Copy User ID"
|
|
1814
|
+
`);
|
|
1815
|
+
const botToken = await prompt2("Bot Token: ");
|
|
1816
|
+
if (!botToken || botToken.length < 50) {
|
|
1817
|
+
console.error("Invalid bot token.");
|
|
1818
|
+
process.exit(1);
|
|
1819
|
+
}
|
|
1820
|
+
const userId = await prompt2("Your Discord User ID: ");
|
|
1821
|
+
if (!userId || !/^\d+$/.test(userId)) {
|
|
1822
|
+
console.error("Invalid user ID. Should be a number.");
|
|
1823
|
+
process.exit(1);
|
|
1824
|
+
}
|
|
1825
|
+
await mkdir2(CONFIG_DIR2, { recursive: true });
|
|
1826
|
+
const envContent = `# AFK Code Discord Configuration
|
|
1827
|
+
DISCORD_BOT_TOKEN=${botToken}
|
|
1828
|
+
DISCORD_USER_ID=${userId}
|
|
1829
|
+
`;
|
|
1830
|
+
await writeFile2(DISCORD_CONFIG_FILE, envContent);
|
|
1831
|
+
console.log(`
|
|
1832
|
+
\u2713 Configuration saved to ${DISCORD_CONFIG_FILE}
|
|
1833
|
+
|
|
1834
|
+
To start the Discord bot, run:
|
|
1835
|
+
afk-code discord
|
|
1836
|
+
|
|
1837
|
+
Then start a Claude Code session with:
|
|
1838
|
+
afk-code run -- claude
|
|
1839
|
+
`);
|
|
1840
|
+
}
|
|
1841
|
+
async function loadEnvFile2(path) {
|
|
1842
|
+
if (!await fileExists2(path)) return {};
|
|
1843
|
+
const content = await readFile3(path, "utf-8");
|
|
1844
|
+
const config = {};
|
|
1845
|
+
for (const line of content.split("\n")) {
|
|
1846
|
+
if (line.startsWith("#") || !line.includes("=")) continue;
|
|
1847
|
+
const [key, ...valueParts] = line.split("=");
|
|
1848
|
+
config[key.trim()] = valueParts.join("=").trim();
|
|
1849
|
+
}
|
|
1850
|
+
return config;
|
|
1851
|
+
}
|
|
1852
|
+
async function discordRun() {
|
|
1853
|
+
const globalConfig = await loadEnvFile2(DISCORD_CONFIG_FILE);
|
|
1854
|
+
const localConfig = await loadEnvFile2(`${process.cwd()}/.env`);
|
|
1855
|
+
const config = {
|
|
1856
|
+
...globalConfig,
|
|
1857
|
+
...localConfig
|
|
1858
|
+
};
|
|
1859
|
+
if (process.env.DISCORD_BOT_TOKEN) config.DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
|
1860
|
+
if (process.env.DISCORD_USER_ID) config.DISCORD_USER_ID = process.env.DISCORD_USER_ID;
|
|
1861
|
+
const required = ["DISCORD_BOT_TOKEN", "DISCORD_USER_ID"];
|
|
1862
|
+
const missing = required.filter((key) => !config[key]);
|
|
1863
|
+
if (missing.length > 0) {
|
|
1864
|
+
console.error(`Missing config: ${missing.join(", ")}`);
|
|
1865
|
+
console.error("");
|
|
1866
|
+
console.error("Provide tokens via:");
|
|
1867
|
+
console.error(" - Environment variables (DISCORD_BOT_TOKEN, DISCORD_USER_ID)");
|
|
1868
|
+
console.error(" - Local .env file");
|
|
1869
|
+
console.error(' - Run "afk-code discord setup" for guided configuration');
|
|
1870
|
+
process.exit(1);
|
|
1871
|
+
}
|
|
1872
|
+
const { createDiscordApp: createDiscordApp2 } = await Promise.resolve().then(() => (init_discord_app(), discord_app_exports));
|
|
1873
|
+
const localEnvExists = await fileExists2(`${process.cwd()}/.env`);
|
|
1874
|
+
const globalEnvExists = await fileExists2(DISCORD_CONFIG_FILE);
|
|
1875
|
+
const source = localEnvExists ? ".env" : globalEnvExists ? DISCORD_CONFIG_FILE : "environment";
|
|
1876
|
+
console.log(`[AFK Code] Loaded config from ${source}`);
|
|
1877
|
+
console.log("[AFK Code] Starting Discord bot...");
|
|
1878
|
+
const discordConfig = {
|
|
1879
|
+
botToken: config.DISCORD_BOT_TOKEN,
|
|
1880
|
+
userId: config.DISCORD_USER_ID
|
|
1881
|
+
};
|
|
1882
|
+
const { client, sessionManager } = createDiscordApp2(discordConfig);
|
|
1883
|
+
try {
|
|
1884
|
+
await sessionManager.start();
|
|
1885
|
+
console.log("[AFK Code] Session manager started");
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
console.error("[AFK Code] Failed to start session manager:", err);
|
|
1888
|
+
process.exit(1);
|
|
1889
|
+
}
|
|
1890
|
+
try {
|
|
1891
|
+
await client.login(config.DISCORD_BOT_TOKEN);
|
|
1892
|
+
console.log("[AFK Code] Discord bot is running!");
|
|
1893
|
+
console.log("");
|
|
1894
|
+
console.log("Start a Claude Code session with: afk-code run -- claude");
|
|
1895
|
+
console.log("Each session will create an #afk-* channel");
|
|
1896
|
+
} catch (err) {
|
|
1897
|
+
console.error("[AFK Code] Failed to start Discord bot:", err);
|
|
1898
|
+
process.exit(1);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// src/cli/index.ts
|
|
1903
|
+
var args = process.argv.slice(2);
|
|
1904
|
+
var command = args[0];
|
|
1905
|
+
async function main() {
|
|
1906
|
+
switch (command) {
|
|
1907
|
+
case "run": {
|
|
1908
|
+
const separatorIndex = args.indexOf("--");
|
|
1909
|
+
if (separatorIndex === -1) {
|
|
1910
|
+
console.error("Usage: afk-code run -- <command> [args...]");
|
|
1911
|
+
console.error("Example: afk-code run -- claude");
|
|
1912
|
+
process.exit(1);
|
|
1913
|
+
}
|
|
1914
|
+
const cmd = args.slice(separatorIndex + 1);
|
|
1915
|
+
if (cmd.length === 0) {
|
|
1916
|
+
console.error("No command specified after --");
|
|
1917
|
+
process.exit(1);
|
|
1918
|
+
}
|
|
1919
|
+
await run(cmd);
|
|
1920
|
+
break;
|
|
1921
|
+
}
|
|
1922
|
+
case "slack": {
|
|
1923
|
+
if (args[1] === "setup") {
|
|
1924
|
+
await slackSetup();
|
|
1925
|
+
} else {
|
|
1926
|
+
await slackRun();
|
|
1927
|
+
}
|
|
1928
|
+
break;
|
|
1929
|
+
}
|
|
1930
|
+
case "discord": {
|
|
1931
|
+
if (args[1] === "setup") {
|
|
1932
|
+
await discordSetup();
|
|
1933
|
+
} else {
|
|
1934
|
+
await discordRun();
|
|
1935
|
+
}
|
|
1936
|
+
break;
|
|
1937
|
+
}
|
|
1938
|
+
case "help":
|
|
1939
|
+
case "--help":
|
|
1940
|
+
case "-h":
|
|
1941
|
+
case void 0: {
|
|
1942
|
+
console.log(`
|
|
1943
|
+
AFK Code - Monitor Claude Code sessions from Slack/Discord
|
|
1944
|
+
|
|
1945
|
+
Commands:
|
|
1946
|
+
slack Run the Slack bot
|
|
1947
|
+
slack setup Configure Slack integration
|
|
1948
|
+
discord Run the Discord bot
|
|
1949
|
+
discord setup Configure Discord integration
|
|
1950
|
+
run -- <command> Start a monitored session
|
|
1951
|
+
help Show this help message
|
|
1952
|
+
|
|
1953
|
+
Examples:
|
|
1954
|
+
afk-code slack setup # First-time Slack configuration
|
|
1955
|
+
afk-code slack # Start the Slack bot
|
|
1956
|
+
afk-code discord setup # First-time Discord configuration
|
|
1957
|
+
afk-code discord # Start the Discord bot
|
|
1958
|
+
afk-code run -- claude # Start a Claude Code session
|
|
1959
|
+
`);
|
|
1960
|
+
break;
|
|
1961
|
+
}
|
|
1962
|
+
default: {
|
|
1963
|
+
console.error(`Unknown command: ${command}`);
|
|
1964
|
+
console.error('Run "afk-code help" for usage');
|
|
1965
|
+
process.exit(1);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
main().catch((err) => {
|
|
1970
|
+
console.error("Error:", err.message);
|
|
1971
|
+
process.exit(1);
|
|
1972
|
+
});
|