compass-agent 2.0.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/.github/workflows/publish.yml +24 -0
- package/README.md +294 -0
- package/bun.lock +326 -0
- package/manifest.yml +66 -0
- package/package.json +25 -0
- package/src/app.ts +786 -0
- package/src/db.ts +398 -0
- package/src/handlers/assistant.ts +332 -0
- package/src/handlers/cwd-modal.test.ts +188 -0
- package/src/handlers/cwd-modal.ts +63 -0
- package/src/handlers/setStatus.test.ts +118 -0
- package/src/handlers/stream.test.ts +137 -0
- package/src/handlers/stream.ts +908 -0
- package/src/lib/log.ts +16 -0
- package/src/lib/thread-context.ts +99 -0
- package/src/lib/worktree.ts +103 -0
- package/src/mcp/server.ts +286 -0
- package/src/types.ts +118 -0
- package/src/ui/blocks.ts +155 -0
- package/tests/blocks.test.ts +73 -0
- package/tests/db.test.ts +261 -0
- package/tests/thread-context.test.ts +183 -0
- package/tests/utils.test.ts +75 -0
- package/tsconfig.json +14 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import dotenv from "dotenv";
|
|
7
|
+
|
|
8
|
+
// --- Environment loading (precedence: real env > --env-file > ~/.compass/.env > local .env) ---
|
|
9
|
+
// Snapshot real env vars so we can restore them after file-based loading
|
|
10
|
+
const realEnv = { ...process.env };
|
|
11
|
+
|
|
12
|
+
// 1. Local .env (lowest priority)
|
|
13
|
+
dotenv.config();
|
|
14
|
+
|
|
15
|
+
// 2. Home-dir config (overrides local .env)
|
|
16
|
+
const homeEnv = path.join(os.homedir(), ".compass", ".env");
|
|
17
|
+
if (fs.existsSync(homeEnv)) {
|
|
18
|
+
dotenv.config({ path: homeEnv, override: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 3. --env-file flag (overrides home-dir)
|
|
22
|
+
const envFileIdx = process.argv.indexOf("--env-file");
|
|
23
|
+
if (envFileIdx !== -1 && process.argv[envFileIdx + 1]) {
|
|
24
|
+
const custom = path.resolve(process.argv[envFileIdx + 1]);
|
|
25
|
+
if (!fs.existsSync(custom)) {
|
|
26
|
+
console.error(`Error: env file not found: ${custom}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
dotenv.config({ path: custom, override: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 4. Restore real env vars (always highest priority)
|
|
33
|
+
Object.assign(process.env, realEnv);
|
|
34
|
+
// -----------------------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
import { App } from "@slack/bolt";
|
|
37
|
+
import { execFileSync } from "child_process";
|
|
38
|
+
import {
|
|
39
|
+
getSession, upsertSession, setCwd, getCwdHistory, addCwdHistory,
|
|
40
|
+
getChannelDefault, setChannelDefault,
|
|
41
|
+
addTeaching, getTeachings, removeTeaching,
|
|
42
|
+
getStaleWorktrees, getActiveWorktrees, markWorktreeCleaned,
|
|
43
|
+
addFeedback, getWorktree, touchWorktree, upsertWorktree,
|
|
44
|
+
getDueReminders, updateNextTrigger, deactivateReminder,
|
|
45
|
+
} from "./db.ts";
|
|
46
|
+
import { randomUUID } from "crypto";
|
|
47
|
+
import { CronExpressionParser } from "cron-parser";
|
|
48
|
+
import { removeWorktree, hasUncommittedChanges, detectGitRepo, createWorktree, copyEnvFiles } from "./lib/worktree.ts";
|
|
49
|
+
import { buildHomeBlocks } from "./ui/blocks.ts";
|
|
50
|
+
import { createAssistant } from "./handlers/assistant.ts";
|
|
51
|
+
import { handleClaudeStream } from "./handlers/stream.ts";
|
|
52
|
+
import { log, logErr, toSqliteDatetime } from "./lib/log.ts";
|
|
53
|
+
import { fetchThreadContext } from "./lib/thread-context.ts";
|
|
54
|
+
import type { ActiveProcessMap, Ref } from "./types.ts";
|
|
55
|
+
|
|
56
|
+
const app = new App({
|
|
57
|
+
token: process.env.SLACK_BOT_TOKEN,
|
|
58
|
+
appToken: process.env.SLACK_APP_TOKEN,
|
|
59
|
+
socketMode: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Track active claude processes per thread
|
|
63
|
+
const activeProcesses: ActiveProcessMap = new Map();
|
|
64
|
+
|
|
65
|
+
// Shared refs so assistant.js can read the cached team_id
|
|
66
|
+
const cachedTeamIdRef: Ref<string | null> = { value: null };
|
|
67
|
+
const cachedBotUserIdRef: Ref<string | null> = { value: null };
|
|
68
|
+
|
|
69
|
+
// ── Register Assistant ──────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const assistant = createAssistant(activeProcesses, cachedTeamIdRef, cachedBotUserIdRef);
|
|
72
|
+
app.assistant(assistant);
|
|
73
|
+
|
|
74
|
+
// ── Feedback action handler ─────────────────────────────────
|
|
75
|
+
|
|
76
|
+
app.action("response_feedback", async ({ action, ack, body }: any) => {
|
|
77
|
+
await ack();
|
|
78
|
+
const [sentiment, sessionKey] = action.value.split(":");
|
|
79
|
+
const userId = body.user?.id;
|
|
80
|
+
const messageTs = body.message?.ts;
|
|
81
|
+
log(null, `Feedback: sentiment=${sentiment} session=${sessionKey} user=${userId} ts=${messageTs}`);
|
|
82
|
+
try {
|
|
83
|
+
addFeedback(sessionKey, userId, sentiment, messageTs);
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
logErr(null, `Failed to log feedback: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── Stop button ─────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
app.action("stop_claude", async ({ action, ack, body }: any) => {
|
|
92
|
+
await ack();
|
|
93
|
+
const threadKey = action.value;
|
|
94
|
+
log(threadKey, `Stop button pressed by user=${body.user?.id}`);
|
|
95
|
+
|
|
96
|
+
const proc = activeProcesses.get(threadKey);
|
|
97
|
+
if (proc) {
|
|
98
|
+
log(threadKey, `Killing claude process pid=${proc.pid}`);
|
|
99
|
+
proc.kill("SIGTERM");
|
|
100
|
+
} else {
|
|
101
|
+
log(threadKey, `No active process to stop`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── $cwd action handlers ────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
app.action("cwd_pick", async ({ action, ack, client, body }: any) => {
|
|
108
|
+
await ack();
|
|
109
|
+
const channelId = body.channel?.id || body.container?.channel_id;
|
|
110
|
+
const { path: chosenPath, threadTs, isTopLevel } = JSON.parse(action.selected_option.value);
|
|
111
|
+
|
|
112
|
+
const sessionKey = threadTs || channelId;
|
|
113
|
+
if (!getSession(sessionKey)) {
|
|
114
|
+
upsertSession(sessionKey, "pending");
|
|
115
|
+
}
|
|
116
|
+
setCwd(sessionKey, chosenPath);
|
|
117
|
+
addCwdHistory(chosenPath);
|
|
118
|
+
// Reset session so next message starts fresh (can't --resume in a different CWD)
|
|
119
|
+
upsertSession(sessionKey, "pending");
|
|
120
|
+
markWorktreeCleaned(sessionKey);
|
|
121
|
+
if (isTopLevel) {
|
|
122
|
+
setChannelDefault(channelId, chosenPath, body.user?.id);
|
|
123
|
+
log(channelId, `Channel default CWD set via picker to: ${chosenPath} (session reset)`);
|
|
124
|
+
} else {
|
|
125
|
+
log(channelId, `CWD set via picker to: ${chosenPath} (thread=${threadTs}, session reset)`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const confirmText = isTopLevel
|
|
129
|
+
? `Working directory set to \`${chosenPath}\` (default for this channel)`
|
|
130
|
+
: `Working directory set to \`${chosenPath}\``;
|
|
131
|
+
await client.chat.postEphemeral({
|
|
132
|
+
channel: channelId,
|
|
133
|
+
thread_ts: threadTs,
|
|
134
|
+
user: body.user?.id,
|
|
135
|
+
text: confirmText,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
app.action("cwd_add_new", async ({ action, ack, client, body }: any) => {
|
|
140
|
+
await ack();
|
|
141
|
+
const meta = JSON.parse(action.value);
|
|
142
|
+
log(meta.channelId, `cwd_add_new: opening modal user=${body.user?.id} thread=${meta.threadTs}`);
|
|
143
|
+
|
|
144
|
+
await client.views.open({
|
|
145
|
+
trigger_id: body.trigger_id,
|
|
146
|
+
view: {
|
|
147
|
+
type: "modal",
|
|
148
|
+
callback_id: "cwd_modal",
|
|
149
|
+
private_metadata: JSON.stringify(meta),
|
|
150
|
+
title: { type: "plain_text", text: "Set Working Directory" },
|
|
151
|
+
submit: { type: "plain_text", text: "Set" },
|
|
152
|
+
close: { type: "plain_text", text: "Cancel" },
|
|
153
|
+
blocks: [
|
|
154
|
+
{
|
|
155
|
+
type: "input",
|
|
156
|
+
block_id: "cwd_input_block",
|
|
157
|
+
element: {
|
|
158
|
+
type: "plain_text_input",
|
|
159
|
+
action_id: "cwd_input",
|
|
160
|
+
placeholder: { type: "plain_text", text: "/path/to/project" },
|
|
161
|
+
},
|
|
162
|
+
label: { type: "plain_text", text: "Directory path" },
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── /cwd slash command ──────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
app.command("/cwd", async ({ command, ack, client }: any) => {
|
|
172
|
+
await ack();
|
|
173
|
+
const history = getCwdHistory();
|
|
174
|
+
log(command.channel_id, `/cwd command invoked by user=${command.user_id}, history_count=${history.length}`);
|
|
175
|
+
|
|
176
|
+
const blocks: any[] = [];
|
|
177
|
+
|
|
178
|
+
if (history.length > 0) {
|
|
179
|
+
blocks.push({
|
|
180
|
+
type: "input",
|
|
181
|
+
block_id: "cwd_select_block",
|
|
182
|
+
optional: true,
|
|
183
|
+
element: {
|
|
184
|
+
type: "static_select",
|
|
185
|
+
action_id: "cwd_select",
|
|
186
|
+
placeholder: { type: "plain_text", text: "Choose a previous directory" },
|
|
187
|
+
options: history.map((h) => ({
|
|
188
|
+
text: { type: "plain_text", text: h.path },
|
|
189
|
+
value: h.path,
|
|
190
|
+
})),
|
|
191
|
+
},
|
|
192
|
+
label: { type: "plain_text", text: "Previous directories" },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
blocks.push({
|
|
197
|
+
type: "input",
|
|
198
|
+
block_id: "cwd_input_block",
|
|
199
|
+
optional: true,
|
|
200
|
+
element: {
|
|
201
|
+
type: "plain_text_input",
|
|
202
|
+
action_id: "cwd_input",
|
|
203
|
+
placeholder: { type: "plain_text", text: "/path/to/project" },
|
|
204
|
+
},
|
|
205
|
+
label: { type: "plain_text", text: "Or enter a new path" },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await client.views.open({
|
|
209
|
+
trigger_id: command.trigger_id,
|
|
210
|
+
view: {
|
|
211
|
+
type: "modal",
|
|
212
|
+
callback_id: "cwd_modal",
|
|
213
|
+
private_metadata: JSON.stringify({ channelId: command.channel_id, isTopLevel: true }),
|
|
214
|
+
title: { type: "plain_text", text: "Set Working Directory" },
|
|
215
|
+
submit: { type: "plain_text", text: "Set" },
|
|
216
|
+
close: { type: "plain_text", text: "Cancel" },
|
|
217
|
+
blocks,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const CLAUDE_PATH = process.env.CLAUDE_PATH || "claude";
|
|
223
|
+
|
|
224
|
+
// ── Modal submissions ───────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
app.view("cwd_modal", async ({ view, ack, client }: any) => {
|
|
227
|
+
const meta = JSON.parse(view.private_metadata);
|
|
228
|
+
const { channelId, threadTs, isTopLevel } = meta;
|
|
229
|
+
const values = view.state.values;
|
|
230
|
+
|
|
231
|
+
const inputVal = values.cwd_input_block?.cwd_input?.value;
|
|
232
|
+
const selectVal = values.cwd_select_block?.cwd_select?.selected_option?.value;
|
|
233
|
+
const chosenPath = inputVal || selectVal;
|
|
234
|
+
|
|
235
|
+
if (!chosenPath) {
|
|
236
|
+
await ack({
|
|
237
|
+
response_action: "errors",
|
|
238
|
+
errors: {
|
|
239
|
+
cwd_input_block: "Please enter a path or select one from the dropdown.",
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await ack();
|
|
246
|
+
|
|
247
|
+
addCwdHistory(chosenPath);
|
|
248
|
+
if (isTopLevel) {
|
|
249
|
+
setChannelDefault(channelId, chosenPath, view.user?.id);
|
|
250
|
+
log(channelId, `Channel default CWD set to: ${chosenPath}`);
|
|
251
|
+
// Also set thread session CWD if we're in a thread (top-level $cwd in channel)
|
|
252
|
+
if (threadTs) {
|
|
253
|
+
if (!getSession(threadTs)) upsertSession(threadTs, "pending");
|
|
254
|
+
setCwd(threadTs, chosenPath);
|
|
255
|
+
// Reset session so next message starts fresh
|
|
256
|
+
upsertSession(threadTs, "pending");
|
|
257
|
+
markWorktreeCleaned(threadTs);
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
const sessionKey = threadTs || channelId;
|
|
261
|
+
if (!getSession(sessionKey)) upsertSession(sessionKey, "pending");
|
|
262
|
+
setCwd(sessionKey, chosenPath);
|
|
263
|
+
// Reset session so next message starts fresh
|
|
264
|
+
upsertSession(sessionKey, "pending");
|
|
265
|
+
markWorktreeCleaned(sessionKey);
|
|
266
|
+
log(channelId, `CWD set to: ${chosenPath} (thread=${threadTs}, session reset)`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const confirmText = isTopLevel
|
|
270
|
+
? `Working directory set to \`${chosenPath}\` (default for this channel)`
|
|
271
|
+
: `Working directory set to \`${chosenPath}\``;
|
|
272
|
+
await client.chat.postEphemeral({
|
|
273
|
+
channel: channelId,
|
|
274
|
+
thread_ts: threadTs,
|
|
275
|
+
user: view.user?.id,
|
|
276
|
+
text: confirmText,
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
app.view("teaching_modal", async ({ view, ack, client, body }: any) => {
|
|
281
|
+
log(null, `teaching_modal: submission by user=${body.user?.id}`);
|
|
282
|
+
const instruction = view.state.values.teaching_input_block.teaching_input.value;
|
|
283
|
+
if (!instruction?.trim()) {
|
|
284
|
+
log(null, `teaching_modal: rejected empty instruction`);
|
|
285
|
+
await ack({
|
|
286
|
+
response_action: "errors",
|
|
287
|
+
errors: { teaching_input_block: "Please enter an instruction." },
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
await ack();
|
|
292
|
+
addTeaching(instruction.trim(), body.user.id);
|
|
293
|
+
log(null, `Teaching added via Home: "${instruction.trim()}" by user=${body.user.id}`);
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
await client.views.publish({
|
|
297
|
+
user_id: body.user.id,
|
|
298
|
+
view: { type: "home", blocks: buildHomeBlocks(activeProcesses) },
|
|
299
|
+
});
|
|
300
|
+
} catch (err: any) {
|
|
301
|
+
logErr(null, `Failed to refresh Home after teaching add: ${err.message}`);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ── App Home dashboard ──────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
app.event("app_home_opened", async ({ event, client }: any) => {
|
|
308
|
+
if (event.tab !== "home") return;
|
|
309
|
+
log(null, `App Home opened by user=${event.user}`);
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
await client.views.publish({
|
|
313
|
+
user_id: event.user,
|
|
314
|
+
view: { type: "home", blocks: buildHomeBlocks(activeProcesses) },
|
|
315
|
+
});
|
|
316
|
+
} catch (err: any) {
|
|
317
|
+
logErr(null, `Failed to publish App Home: ${err.message}`);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
app.action("home_view_teachings", async ({ ack, client, body }: any) => {
|
|
322
|
+
await ack();
|
|
323
|
+
log(null, `Home: "View Teachings" clicked by user=${body.user?.id}`);
|
|
324
|
+
const teachings = getTeachings("default");
|
|
325
|
+
log(null, `Home: fetched ${teachings.length} teachings for modal`);
|
|
326
|
+
const blocks = teachings.length > 0
|
|
327
|
+
? teachings.map((t) => ({
|
|
328
|
+
type: "section",
|
|
329
|
+
text: { type: "mrkdwn", text: `*#${t.id}* \u2014 ${t.instruction}\n_Added by <@${t.added_by}> on ${t.created_at}_` },
|
|
330
|
+
}))
|
|
331
|
+
: [{ type: "section", text: { type: "mrkdwn", text: "No teachings yet." } }];
|
|
332
|
+
|
|
333
|
+
await client.views.open({
|
|
334
|
+
trigger_id: body.trigger_id,
|
|
335
|
+
view: {
|
|
336
|
+
type: "modal",
|
|
337
|
+
title: { type: "plain_text", text: "Team Teachings" },
|
|
338
|
+
close: { type: "plain_text", text: "Close" },
|
|
339
|
+
blocks,
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
app.action("home_add_teaching", async ({ ack, client, body }: any) => {
|
|
345
|
+
await ack();
|
|
346
|
+
log(null, `Home: "Add Teaching" clicked by user=${body.user?.id}`);
|
|
347
|
+
await client.views.open({
|
|
348
|
+
trigger_id: body.trigger_id,
|
|
349
|
+
view: {
|
|
350
|
+
type: "modal",
|
|
351
|
+
callback_id: "teaching_modal",
|
|
352
|
+
title: { type: "plain_text", text: "Add Teaching" },
|
|
353
|
+
submit: { type: "plain_text", text: "Save" },
|
|
354
|
+
close: { type: "plain_text", text: "Cancel" },
|
|
355
|
+
blocks: [
|
|
356
|
+
{
|
|
357
|
+
type: "input",
|
|
358
|
+
block_id: "teaching_input_block",
|
|
359
|
+
element: {
|
|
360
|
+
type: "plain_text_input",
|
|
361
|
+
action_id: "teaching_input",
|
|
362
|
+
multiline: true,
|
|
363
|
+
placeholder: { type: "plain_text", text: "e.g., Use TypeScript for all new files" },
|
|
364
|
+
},
|
|
365
|
+
label: { type: "plain_text", text: "Team convention or instruction" },
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// ── Channel @mention handler ────────────────────────────────
|
|
373
|
+
|
|
374
|
+
const ALLOWED_USERS = new Set(
|
|
375
|
+
(process.env.ALLOWED_USERS || "").split(",").map((s) => s.trim()).filter(Boolean)
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
app.event("app_mention", async ({ event, client }: any) => {
|
|
379
|
+
const channelId = event.channel;
|
|
380
|
+
const threadTs = event.thread_ts || event.ts;
|
|
381
|
+
const userId = event.user;
|
|
382
|
+
const rawText = event.text || "";
|
|
383
|
+
// Strip the bot mention from text: "<@U12345> hello" -> "hello"
|
|
384
|
+
const userText = rawText.replace(/<@[A-Za-z0-9]+>/g, "").trim();
|
|
385
|
+
|
|
386
|
+
log(channelId, `app_mention: user=${userId} ts=${event.ts} thread_ts=${threadTs} raw="${rawText}" text="${userText}"`);
|
|
387
|
+
|
|
388
|
+
if (ALLOWED_USERS.size > 0 && !ALLOWED_USERS.has(userId) && userId !== cachedBotUserIdRef.value) {
|
|
389
|
+
log(channelId, `Blocked unauthorized user=${userId}`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── $cwd command ──────────────────────────────────────────
|
|
394
|
+
if (userText.match(/^\$cwd(\s|$)/i)) {
|
|
395
|
+
const pathArg = userText.replace(/^\$cwd\s*/i, "").trim();
|
|
396
|
+
const isTopLevel = !event.thread_ts;
|
|
397
|
+
log(channelId, `$cwd command: pathArg="${pathArg}" isTopLevel=${isTopLevel}`);
|
|
398
|
+
if (pathArg) {
|
|
399
|
+
if (!getSession(threadTs)) upsertSession(threadTs, "pending");
|
|
400
|
+
setCwd(threadTs, pathArg);
|
|
401
|
+
addCwdHistory(pathArg);
|
|
402
|
+
// Reset session so next message starts fresh (can't --resume in a different CWD)
|
|
403
|
+
upsertSession(threadTs, "pending");
|
|
404
|
+
markWorktreeCleaned(threadTs);
|
|
405
|
+
if (isTopLevel) {
|
|
406
|
+
setChannelDefault(channelId, pathArg, userId);
|
|
407
|
+
log(channelId, `Channel default CWD set to: ${pathArg} (session reset)`);
|
|
408
|
+
} else {
|
|
409
|
+
log(channelId, `Thread CWD set to: ${pathArg} (session reset)`);
|
|
410
|
+
}
|
|
411
|
+
const confirmText = isTopLevel
|
|
412
|
+
? `Working directory set to \`${pathArg}\` (default for this channel)`
|
|
413
|
+
: `Working directory set to \`${pathArg}\``;
|
|
414
|
+
try {
|
|
415
|
+
await client.chat.postEphemeral({ channel: channelId, thread_ts: threadTs, user: userId, text: confirmText });
|
|
416
|
+
} catch (err: any) {
|
|
417
|
+
logErr(channelId, `$cwd set reply failed: ${err.message}`);
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
// Bare $cwd — show interactive directory picker
|
|
421
|
+
const history = getCwdHistory();
|
|
422
|
+
log(channelId, `$cwd picker requested, history_count=${history.length}`);
|
|
423
|
+
|
|
424
|
+
const pickerBlocks: any[] = [
|
|
425
|
+
{ type: "header", text: { type: "plain_text", text: "Set Working Directory" } },
|
|
426
|
+
{ type: "divider" },
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
if (history.length > 0) {
|
|
430
|
+
pickerBlocks.push(
|
|
431
|
+
{ type: "section", text: { type: "mrkdwn", text: "*Recent directories:*" } },
|
|
432
|
+
{
|
|
433
|
+
type: "actions",
|
|
434
|
+
block_id: "cwd_picker_block",
|
|
435
|
+
elements: [{
|
|
436
|
+
type: "static_select",
|
|
437
|
+
action_id: "cwd_pick",
|
|
438
|
+
placeholder: { type: "plain_text", text: "Choose a directory..." },
|
|
439
|
+
options: history.map((h) => ({
|
|
440
|
+
text: { type: "plain_text", text: h.path },
|
|
441
|
+
value: JSON.stringify({ path: h.path, threadTs, isTopLevel }),
|
|
442
|
+
})),
|
|
443
|
+
}],
|
|
444
|
+
},
|
|
445
|
+
{ type: "divider" },
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
pickerBlocks.push(
|
|
450
|
+
{
|
|
451
|
+
type: "section",
|
|
452
|
+
text: { type: "mrkdwn", text: "Enter a new path:" },
|
|
453
|
+
accessory: {
|
|
454
|
+
type: "button",
|
|
455
|
+
action_id: "cwd_add_new",
|
|
456
|
+
text: { type: "plain_text", text: "Add new..." },
|
|
457
|
+
style: "primary",
|
|
458
|
+
value: JSON.stringify({ channelId, threadTs, isTopLevel }),
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
type: "context",
|
|
463
|
+
elements: [
|
|
464
|
+
{ type: "mrkdwn", text: "Or send `$cwd /path/to/dir` to set directly" },
|
|
465
|
+
],
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
await client.chat.postEphemeral({
|
|
471
|
+
channel: channelId,
|
|
472
|
+
thread_ts: threadTs,
|
|
473
|
+
user: userId,
|
|
474
|
+
blocks: pickerBlocks,
|
|
475
|
+
text: "Set working directory",
|
|
476
|
+
});
|
|
477
|
+
log(channelId, `$cwd picker sent (ephemeral to user=${userId})`);
|
|
478
|
+
} catch (err: any) {
|
|
479
|
+
logErr(channelId, `$cwd picker reply failed: ${err.message}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── $teach command ────────────────────────────────────────
|
|
486
|
+
if (userText.match(/^\$teach(\s|$)/i)) {
|
|
487
|
+
const teachArg = userText.replace(/^\$teach\s*/i, "").trim();
|
|
488
|
+
log(channelId, `$teach command: arg="${teachArg}"`);
|
|
489
|
+
try {
|
|
490
|
+
if (!teachArg || teachArg === "help") {
|
|
491
|
+
await client.chat.postMessage({ channel: channelId, thread_ts: threadTs, text: "`$teach <instruction>` — add\n`$teach list` — view all\n`$teach remove <id>` — remove" });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (teachArg === "list") {
|
|
495
|
+
const teachings = getTeachings("default");
|
|
496
|
+
const text = teachings.length > 0
|
|
497
|
+
? teachings.map((t) => `#${t.id} — ${t.instruction}`).join("\n")
|
|
498
|
+
: "No teachings yet.";
|
|
499
|
+
await client.chat.postMessage({ channel: channelId, thread_ts: threadTs, text });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const removeMatch = teachArg.match(/^remove\s+(\d+)$/i);
|
|
503
|
+
if (removeMatch) {
|
|
504
|
+
removeTeaching(parseInt(removeMatch[1], 10));
|
|
505
|
+
await client.chat.postMessage({ channel: channelId, thread_ts: threadTs, text: `Teaching #${removeMatch[1]} removed.` });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const instruction = teachArg.replace(/^["']|["']$/g, "");
|
|
509
|
+
addTeaching(instruction, userId);
|
|
510
|
+
await client.chat.postMessage({ channel: channelId, thread_ts: threadTs, text: `Learned: _${instruction}_` });
|
|
511
|
+
} catch (err: any) {
|
|
512
|
+
logErr(channelId, `$teach reply failed: ${err.message}`);
|
|
513
|
+
}
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
await processMessage({ channelId, threadTs, messageTs: event.ts, userText, userId, client });
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
interface ProcessMessageOpts {
|
|
521
|
+
channelId: string;
|
|
522
|
+
threadTs: string;
|
|
523
|
+
messageTs: string;
|
|
524
|
+
userText: string;
|
|
525
|
+
userId: string;
|
|
526
|
+
client: any;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Core message processing: session lookup, CWD gate, worktree setup, Claude spawn.
|
|
531
|
+
* Called from app_mention handler and reminder polling loop.
|
|
532
|
+
*/
|
|
533
|
+
async function processMessage({ channelId, threadTs, messageTs, userText, userId, client }: ProcessMessageOpts): Promise<void> {
|
|
534
|
+
// ── Guard: already processing ─────────────────────────────
|
|
535
|
+
if (activeProcesses.has(threadTs)) {
|
|
536
|
+
log(channelId, `Rejecting — already processing`);
|
|
537
|
+
try {
|
|
538
|
+
await client.chat.postMessage({ channel: channelId, thread_ts: threadTs, text: "Still processing the previous message..." });
|
|
539
|
+
} catch (err: any) {
|
|
540
|
+
logErr(channelId, `Busy reply failed: ${err.message}`);
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!userText) {
|
|
546
|
+
log(channelId, `Empty mention, ignoring`);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── Session lookup ────────────────────────────────────────
|
|
551
|
+
const session = getSession(threadTs);
|
|
552
|
+
let sessionId: string;
|
|
553
|
+
let isResume = false;
|
|
554
|
+
if (session && session.session_id && session.session_id !== "pending") {
|
|
555
|
+
sessionId = session.session_id;
|
|
556
|
+
isResume = true;
|
|
557
|
+
log(channelId, `Resuming session: ${sessionId}`);
|
|
558
|
+
} else {
|
|
559
|
+
sessionId = randomUUID();
|
|
560
|
+
if (!session) upsertSession(threadTs, "pending");
|
|
561
|
+
log(channelId, `New session: ${sessionId}`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── CWD gate (thread CWD → channel default → block) ─────
|
|
565
|
+
const currentSession = getSession(threadTs);
|
|
566
|
+
let effectiveCwd = currentSession?.cwd;
|
|
567
|
+
|
|
568
|
+
if (!effectiveCwd) {
|
|
569
|
+
const channelDefault = getChannelDefault(channelId);
|
|
570
|
+
if (channelDefault?.cwd) {
|
|
571
|
+
effectiveCwd = channelDefault.cwd;
|
|
572
|
+
// Inherit: write channel default into this thread's session
|
|
573
|
+
if (!currentSession) upsertSession(threadTs, "pending");
|
|
574
|
+
setCwd(threadTs, effectiveCwd);
|
|
575
|
+
log(channelId, `Inherited channel default CWD: ${effectiveCwd}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!effectiveCwd) {
|
|
580
|
+
log(channelId, `No CWD set, blocking`);
|
|
581
|
+
try {
|
|
582
|
+
await client.chat.postMessage({ channel: channelId, thread_ts: threadTs, text: "No working directory set. Send `$cwd /path/to/dir` to set one." });
|
|
583
|
+
} catch (err: any) {
|
|
584
|
+
logErr(channelId, `CWD gate reply failed: ${err.message}`);
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── Thread context (gap messages) ────────────────────────
|
|
590
|
+
let enrichedText = userText;
|
|
591
|
+
log(channelId, `Thread context check: messageTs=${messageTs} threadTs=${threadTs} inThread=${messageTs !== threadTs}`);
|
|
592
|
+
if (messageTs !== threadTs) {
|
|
593
|
+
const context = await fetchThreadContext(client, channelId, threadTs, messageTs, cachedBotUserIdRef.value);
|
|
594
|
+
if (context) {
|
|
595
|
+
enrichedText = context + enrichedText;
|
|
596
|
+
log(channelId, `Enriched prompt: ${enrichedText.length} chars (was ${userText.length})`);
|
|
597
|
+
} else {
|
|
598
|
+
log(channelId, `No thread context returned`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ── Worktree setup ────────────────────────────────────────
|
|
603
|
+
let spawnCwd = effectiveCwd;
|
|
604
|
+
const existingWt = getWorktree(threadTs);
|
|
605
|
+
if (existingWt && !existingWt.cleaned_up) {
|
|
606
|
+
spawnCwd = existingWt.worktree_path;
|
|
607
|
+
touchWorktree(threadTs);
|
|
608
|
+
log(channelId, `Reusing worktree: ${spawnCwd}`);
|
|
609
|
+
} else {
|
|
610
|
+
const gitInfo = detectGitRepo(effectiveCwd);
|
|
611
|
+
log(channelId, `Git detection: cwd=${effectiveCwd} isGit=${gitInfo.isGit}`);
|
|
612
|
+
if (gitInfo.isGit) {
|
|
613
|
+
try {
|
|
614
|
+
const { worktreePath, branchName } = createWorktree(gitInfo.repoRoot!, threadTs);
|
|
615
|
+
copyEnvFiles(effectiveCwd, worktreePath);
|
|
616
|
+
upsertWorktree(threadTs, gitInfo.repoRoot!, worktreePath, branchName);
|
|
617
|
+
spawnCwd = worktreePath;
|
|
618
|
+
log(channelId, `Created worktree: ${worktreePath}`);
|
|
619
|
+
} catch (err: any) {
|
|
620
|
+
logErr(channelId, `Worktree creation failed: ${err.message}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ── Delegate to stream handler ──────────────────────────────
|
|
626
|
+
await handleClaudeStream({
|
|
627
|
+
channelId, threadTs, userText: enrichedText, userId, client,
|
|
628
|
+
spawnCwd, isResume, sessionId,
|
|
629
|
+
setStatus: async (statusOrOpts) => {
|
|
630
|
+
const params = typeof statusOrOpts === "string"
|
|
631
|
+
? { status: statusOrOpts }
|
|
632
|
+
: statusOrOpts;
|
|
633
|
+
await client.assistant.threads.setStatus({
|
|
634
|
+
channel_id: channelId,
|
|
635
|
+
thread_ts: threadTs,
|
|
636
|
+
...params,
|
|
637
|
+
}).catch((err: any) => {
|
|
638
|
+
logErr(channelId, `setStatus failed (channel): ${err.message}`);
|
|
639
|
+
});
|
|
640
|
+
},
|
|
641
|
+
activeProcesses,
|
|
642
|
+
cachedTeamId: cachedTeamIdRef.value,
|
|
643
|
+
botUserId: cachedBotUserIdRef.value,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ── Startup ─────────────────────────────────────────────────
|
|
648
|
+
|
|
649
|
+
(async () => {
|
|
650
|
+
// Validate CLAUDE_PATH exists before starting
|
|
651
|
+
const claudePath = process.env.CLAUDE_PATH || "claude";
|
|
652
|
+
// Check if it's an absolute path that exists, or resolve via Bun.which
|
|
653
|
+
if (path.isAbsolute(claudePath)) {
|
|
654
|
+
if (!fs.existsSync(claudePath)) {
|
|
655
|
+
console.error(`ERROR: CLAUDE_PATH not found: ${claudePath}`);
|
|
656
|
+
process.exit(1);
|
|
657
|
+
}
|
|
658
|
+
} else if (!Bun.which(claudePath)) {
|
|
659
|
+
console.error(`ERROR: CLAUDE_PATH "${claudePath}" not found in PATH. Set CLAUDE_PATH in .env to the full path of the claude binary.`);
|
|
660
|
+
process.exit(1);
|
|
661
|
+
}
|
|
662
|
+
log(null, `Claude CLI found: ${claudePath}`);
|
|
663
|
+
|
|
664
|
+
// Register MCP server (idempotent — overwrites if already exists)
|
|
665
|
+
try {
|
|
666
|
+
const mcpServerPath = path.join(import.meta.dir, "mcp", "server.ts");
|
|
667
|
+
const mcpEnv: Record<string, string | undefined> = { ...process.env };
|
|
668
|
+
delete mcpEnv.CLAUDECODE; // avoid nested-session check
|
|
669
|
+
execFileSync(claudePath, [
|
|
670
|
+
"mcp", "add",
|
|
671
|
+
"--transport", "stdio",
|
|
672
|
+
"--scope", "user",
|
|
673
|
+
"compass",
|
|
674
|
+
"--",
|
|
675
|
+
"bun", mcpServerPath,
|
|
676
|
+
], { encoding: "utf-8", timeout: 10000, env: mcpEnv as NodeJS.ProcessEnv, stdio: ["pipe", "pipe", "pipe"] });
|
|
677
|
+
log(null, `MCP server registered: compass -> ${mcpServerPath}`);
|
|
678
|
+
} catch (err: any) {
|
|
679
|
+
logErr(null, `Failed to register MCP server (non-fatal): ${err.message}`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
log(null, `Starting Slack bot...`);
|
|
683
|
+
|
|
684
|
+
// Cache team ID and bot user ID
|
|
685
|
+
try {
|
|
686
|
+
const authResult = await app.client.auth.test();
|
|
687
|
+
cachedTeamIdRef.value = authResult.team_id ?? null;
|
|
688
|
+
cachedBotUserIdRef.value = authResult.user_id ?? null;
|
|
689
|
+
log(null, `Cached team_id: ${cachedTeamIdRef.value}, bot_user_id: ${cachedBotUserIdRef.value}`);
|
|
690
|
+
} catch (err: any) {
|
|
691
|
+
logErr(null, `Failed to cache team_id/bot_user_id: ${err.message}`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
await app.start();
|
|
695
|
+
|
|
696
|
+
console.log(`
|
|
697
|
+
_____
|
|
698
|
+
/ ____|
|
|
699
|
+
| | ___ _ __ ___ _ __ __ _ ___ ___
|
|
700
|
+
| | / _ \\| '_ \` _ \\| '_ \\ / _\` / __/ __|
|
|
701
|
+
| |___| (_) | | | | | | |_) | (_| \\__ \\__ \\
|
|
702
|
+
\\_____\\___/|_| |_| |_| .__/ \\__,_|___/___/
|
|
703
|
+
| |
|
|
704
|
+
|_|
|
|
705
|
+
`);
|
|
706
|
+
|
|
707
|
+
log(null, `Slack bot is running in Socket Mode`);
|
|
708
|
+
|
|
709
|
+
// Worktree cleanup: every hour, remove worktrees idle >24h
|
|
710
|
+
setInterval(() => {
|
|
711
|
+
try {
|
|
712
|
+
const stale = getStaleWorktrees(1440);
|
|
713
|
+
log(null, `Worktree cleanup: found ${stale.length} stale worktree(s) (idle >24h)`);
|
|
714
|
+
for (const wt of stale) {
|
|
715
|
+
if (activeProcesses.has(wt.session_key)) continue;
|
|
716
|
+
if (hasUncommittedChanges(wt.worktree_path)) {
|
|
717
|
+
log(null, `Skipping stale worktree with uncommitted changes: ${wt.worktree_path}`);
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
removeWorktree(wt.repo_path, wt.worktree_path, wt.branch_name);
|
|
722
|
+
markWorktreeCleaned(wt.session_key);
|
|
723
|
+
log(null, `Cleaned stale worktree: ${wt.worktree_path}`);
|
|
724
|
+
} catch (err: any) {
|
|
725
|
+
logErr(null, `Failed to clean worktree ${wt.worktree_path}: ${err.message}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
} catch (err: any) {
|
|
729
|
+
logErr(null, `Worktree cleanup error: ${err.message}`);
|
|
730
|
+
}
|
|
731
|
+
}, 60 * 60 * 1000);
|
|
732
|
+
|
|
733
|
+
// Reminder polling: fire due reminders
|
|
734
|
+
async function pollReminders(): Promise<void> {
|
|
735
|
+
try {
|
|
736
|
+
const due = getDueReminders();
|
|
737
|
+
if (due.length === 0) return;
|
|
738
|
+
log(null, `Reminder poll: ${due.length} due reminder(s)`);
|
|
739
|
+
|
|
740
|
+
for (const reminder of due) {
|
|
741
|
+
try {
|
|
742
|
+
// Post the reminder message in the channel
|
|
743
|
+
const posted = await app.client.chat.postMessage({
|
|
744
|
+
channel: reminder.channel_id,
|
|
745
|
+
text: `<@${reminder.bot_id}> ${reminder.content}`,
|
|
746
|
+
});
|
|
747
|
+
log(null, `Reminder #${reminder.id} fired in channel=${reminder.channel_id}: "${reminder.content}"`);
|
|
748
|
+
|
|
749
|
+
// Directly process the message (self-mentions don't trigger app_mention)
|
|
750
|
+
const threadTs = posted.ts!;
|
|
751
|
+
await processMessage({
|
|
752
|
+
channelId: reminder.channel_id,
|
|
753
|
+
threadTs,
|
|
754
|
+
messageTs: posted.ts!,
|
|
755
|
+
userText: reminder.content,
|
|
756
|
+
userId: reminder.user_id,
|
|
757
|
+
client: app.client,
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
if (reminder.one_time) {
|
|
761
|
+
deactivateReminder(reminder.id);
|
|
762
|
+
log(null, `Reminder #${reminder.id} deactivated (one-time)`);
|
|
763
|
+
} else {
|
|
764
|
+
try {
|
|
765
|
+
const interval = CronExpressionParser.parse(reminder.cron_expression!);
|
|
766
|
+
const nextTrigger = toSqliteDatetime(interval.next().toDate());
|
|
767
|
+
updateNextTrigger(nextTrigger, reminder.id);
|
|
768
|
+
log(null, `Reminder #${reminder.id} next trigger: ${nextTrigger}`);
|
|
769
|
+
} catch (err: any) {
|
|
770
|
+
logErr(null, `Failed to compute next trigger for reminder #${reminder.id}: ${err.message}`);
|
|
771
|
+
deactivateReminder(reminder.id);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
} catch (err: any) {
|
|
775
|
+
logErr(null, `Failed to fire reminder #${reminder.id}: ${err.message}`);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
} catch (err: any) {
|
|
779
|
+
logErr(null, `Reminder poll error: ${err.message}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Fire any missed reminders immediately, then poll every 60s
|
|
784
|
+
pollReminders();
|
|
785
|
+
setInterval(pollReminders, 60 * 1000);
|
|
786
|
+
})();
|